11 Nov 2018

Improving CLI Ergonomics

In my day-to-day, I use a lot of command-line tools. I generally find myself to be much more productive working in a non-GUI environment, mostly down to the fact that I can type much faster than I can move and aim a mouse and the less time I spend switching between keyboard and mouse the more time I can spend typing. Unfortunately, command-line tools have one major drawback: discoverability. If I am given a GUI application, I can click around, hover over different UI elements, and generally get a feel for how the interface is laid out. In a command-line tool, I don’t have any of these visual cues to help me learn the functionality; I have to start typing and see what happens. To supplement this lack of visual cues, good command-line tools generally have autocomplete snippets for your shell so you can type the command name and start hitting TAB to see what options are available at a particular time, as well as extensive man pages that describe all the various options, commands, and any sub-commands (though these man pages are often extraordinarily verbose and rarely provide useful examples of how the tool is commonly used, so alternatives such as tldr fill this gap).

I want supernova to be a good command-line tool, so I have been thinking about ways to improve discoverability and make the tool easier to use. A really interesting post by Jeff Dickey, an engineer at Heroku, crossed my timeline recently titled 12 Factor CLI Apps In his post, Jeff provides twelve principles to guide CLI design in a similar fashion to Heroku’s original Twelve-Factor App methodology. Out of the twelve principles, Principle 2 stood out to me, particularly in the context of supernova; Jeff suggests to “prefer flags to args” when designing a CLI. He says:

Sometimes args are just fine though when the argument is obvious such as $rm file_to_remove. A good rule of thumb is 1 type of argument is fine, 2 types are very suspect, and 3 are never good.

This rule of thumb got me thinking about supernova’s current calling convention. Currently, you would use supernova like this:

$ supernova <username> [<auth-token>]

This violates Jeff’s rule of thumb because <username> and <auth-token> are not the same type of argument, and it’s arguably even worse because the <auth-token> is optional here.

I opened 0xazure/supernova#16 to track this problem with our current calling convention. In it, I suggest using the clap crate to improve our CLI and provide the facilities to do command-line argument parsing.

Introducing Clap

Clap is a great crate that makes it super easy to add all kinds of CLI goodies to your command-line tool including auto-generated help, version, and usage information which are all, as Jeff highlights in Principle 1: Great help is essential, important to good CLIs.

Adding clap to the project would check off Principles 1 & 2, so I went ahead and created a pull request to add clap and improve supernova’s calling convention. All that was necessary was to create a new clap-based App with the desired arguments and flags, provide good help messages, and set <auth-token> to be an optional argument. Having done all that, this is what clap generates, all for 13 lines of code:

$ supernova --help
supernova 0.1.0

USAGE:
    supernova [OPTIONS] <USERNAME>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -t, --token <TOKEN>    Sets the authentication token for requests to GitHub

ARGS:
    <USERNAME>    The user whose stars to collect

Clap will print the name as well as the version with every --help request. I was a little wary of this at first, because while I definitely want to include this information in my CLI, I also don’t want the maintenance burden of having to remember to update the version string in main.rs every time a new version is published. However, clap exposes some very handy macros that make use of environment variables exported by cargo at build time to pull this information out of Cargo.toml, so these values will always be up to date with the crate’s metadata and don’t require setting the values in code.

To actually extract all of the parsed arguments from clap, we call App::get_matches() which produces an ArgMatches struct we can query for specific arguments by name. Instead of parsing out each argument, I decided to try my hand at implementing the From trait to convert ArgMatches into supernova’s Config type so it can be passed directly to the next function call.

Traits in Rust

Before I talk about implementing the From trait, I want to quickly talk about what traits actually are, as well as why I chose to implement From instead of one of the other conversion traits.

The Rust Book explains traits like this:

A trait tells the Rust compiler about functionality a particular type has and can share with other types. We can use traits to define shared behavior in an abstract way.

If you are familiar with the idea of interfaces in other languages such as Java or Go, traits are a very similar idea.

There is an important caveat with traits. Again, from the Rust Book, Chapter 10.2, Implementing a Trait on a Type:

One restriction to note with trait implementations is that we can implement a trait on a type only if either the trait or the type is local to our crate.

This means that I will need to implement my conversion on a type that I control, Config, to convert between ArgMatches and Config.

The restriction on traits is very interesting, because while it is not necessary in my case it raises the question of how to convert from types that I do control into types defined in the standard library or in other crates. If I wanted to convert a Config object into a String for example, it might make sense to use the conveniently named Into trait. However, the documentation for Into states:

Library authors should not directly implement this trait, but should prefer implementing the From trait, which offers greater flexibility and provides an equivalent Into implementation for free, thanks to a blanket implementation in the standard library.

So, instead of implementing Into, I should implement the conversion of Config from String, and I get the equivalent Into implementation for free? It seems a bit backwards since we are defining a trait that converts in the opposite direction of our needs, but because of this blanket implementation in the standard library we get symmetric conversions between types for free as long as we implement From on the type. That means we only have to write the conversion function once instead of needing to write it once in each direction, and I’m definitely in favour of anything that reduces the amount of code I need to write.

The From Trait

Actually implementing the From trait was straight-forward, other than the necessary addition of a lifetime annotation because of how ArgMatches is implemented on clap’s side. Implementing From also let me replace another method in Config which reduces the surface area of Config’s implementation.

I’m not totally happy with implementing From on Config because I had to pull clap into the library side of supernova instead of leaving arguments parsing completely in main, so I may go back and change this implementation to provide better separation between data and arguments parsing. Or I may decide to move Config out to its own module which would also increase separation of concerns. Either way, I was very happy to get my hands back on the keyboard writing Rust code this week, and I hope to be writing more in the near future!