I love the UNIXy shells

I expect software to work the way that I want it too; my tools exist to help me do my work, so they need to work my way. With open source software you can typically modify it to suit your needs, but if the upstream project doesn't want your changes then you end up maintaining your patches indefinitely[1]. Other times the functionality you desire is difficult or impossible due to the design of the software. When dealing with closed-source software you have fewer options.

Your shell is often the best tool for extending your (CLI) tools and can help you integrate them into a cohesive system. The rest of this post will contain a few examples of problems I've solved using my shells (zsh and bash, though any shell should be able to do this).

Today's problem was with Cargo, Rust's build tool. I wanted to run a single test but misspelled the name; since cargo outputted a bunch of green text and no failure message I assumed it had passed and moved on. I need cargo to tell me if no tests were run at all. This had been requested back in 2016, but their solution was to report the number of tests filtered out with the other statistics, which are per-crate stats. Cargo has an alias feature allowing you to create new subcommands, but does not allow replacing or overriding existing commands (like test).

So I used a function to wrap cargo and extend the test subcommand. Unfortunately tee is doing something weird in bash, so the function doesn't work properly; since I expect to only need it in zsh I decided that it wasn't worth putting more than 20 minutes into troubleshooting, opting for a warning in case I forget that I never solved the problem.

# Extend cargo subcommands.
function cargo() {
    # Alternatively, just above this function:
    # unset -f cargo
    # CARGO=$(which cargo)
    local CARGO="$HOME/.cargo/bin/cargo"

    case $1 in
        "test")
            exec 3>&1
            # Removing `tee` from the pipeline allows bash to run this, but we
            # lose our test output. There are ways around it but since I only
            # expect to need this function in zsh I'm not planning to take the
            # time to fix it.
            local TESTS=$("$CARGO" $@ | tee >&3 \
                | grep '^running ' \
                | cut -f2 -d' ' \
                | paste -sd+ \
                | bc)
            exec 3>&-

            if [ -z "$TESTS" ]; then
                echo -e "\n\033[0;31mWARNING\033[0m: Bash does not tee test" \
                        "output properly. Ensure your test(s) actually ran."
            elif [ "$TESTS" -eq 0 ]; then
                echo -e "\a\n\033[0;31mWARNING\033[0m: Did not run any tests"
            fi
            ;;
        *)
            $CARGO $@
            ;;
    esac
 }

We use exec to create a copy of our standard output, then use tee to write to that copy (i.e., to the console) because the rest of the pipeline is going to consume what would otherwise have been printed (and the second exec closes our new file). Then we grab the lines that tell us how many tests are being run and reduce them to the number itself. paste then prints these numbers on a single line with a plus sign to delimit them and we send it to a calculator, finally printing a warning if the sum is 0.

We have quite easily extended cargo's behavior. We could go further, for example we could print the total number of tests run or passed since cargo only reports the totals by crate.

I've also used a shell alias to fix another cargo shortcoming. You can use Cargo to install development tools. However, there's no convenient way to update all of those tools at once; we can implement that ourselves though (requires Cargo 0.42.0 or later):

alias cargo-update="cargo install --list \
    | grep -v '    ' \
    | awk '{print $1}' \
    | xargs cargo install"

There is a cross-platform cargo-update subcommand for that, but if we don't care about Windows, we don't need it! If we do care about Windows, PowerShell can do this too. I should note that cargo-update can do things this alias won't, and I haven't compared performance -- the alias upgrades one at a time, but its possible (I don't know one way or the other) that the subcommand downloads or installs multiple packages at once.

As another example of what we can solve with the shell, I have implemented a Pomodoro variant with a few simple shell aliases and functions:

# Begin a pomodoro timer. This sends me a desktop notification when it's time to
# take a break.
alias pom="echo 'notify-send --urgency=normal \"STEP AWAY FROM THE COMPUTER\"' \
    | at now + 50 minutes"

# List all pomodoro projects.
alias pomp="cut -f1 $HOME/.pom_log | sort | uniq"

# Record a completed pomodoro to the log file.
function poml() {
    if [ -z "$1" ]; then
        echo 'Usage: poml <project-name>'
        echo 'Current projects are:'
        pomp
        return
    fi

    echo "$1\t`date --rfc-3339=seconds`" >> $HOME/.pom_log \
        && date --rfc-3339=seconds
}

# Report the number of completed pomodoros today.
# Future expansions:
# - Allow specifying a date or date offset from today
# - Basic weekly/monthly graphs
function pomr() {
    tail -n25 "$HOME/.pom_log" | while read rec; do
        case $(echo "$rec" | cut -f2) in $(date -Idate)*) echo
        esac
    done | wc -l
}

Each record is just a timestamp and project name. That's enough to generate graphs in the future if I want to. I may add a third column to mark short sessions (25 minutes) so I can record them too; I currently don't worry about it since this is a tool to help me focus without focusing too much, so reporting isn't a high priority for me.

The pomr function above that I use to report the number of sessions I've completed today may need a bit of explaining. I use tail to read the last twenty-five lines of the file (this is a performance optimization; with 24 hours in a day, I don't expect to ever reach anything close to twenty-five 50-minute sessions, so this is safe[2]). The while loop checks each row to see if its date is today; if so we output a new (empty) line. We pipe the output from the loop (everything is an expression!) to wc which prints the number of lines.


[1]: If you run into this problem, you run Archlinux, and the software you're patching is in the AUR, take a look at makeppkg.

[2]: It is actually possible to get 28 entries in a day, but that's never going to happen.