This is a blog post version of a short talk I gave recently at a Rust Finland meetup.

Introduction

Command line programs are nicer to use if the command line interface (CLI) is nicer to use. This means, among other things:

  • the CLI conforms to the conventions of the underlying platform
    • I only use Linux, so what I talk about here may not apply to other systems
    • long and short options, and values for options
  • the CLI should have built-in help of some sort
    • the --help option is non-negotiable
    • the -h alias for --help would be nice
  • for complex programs, allow subcommands
    • with their own help
  • the program should check for errors the user may have made and give helpful error messages

Motivation

Why would you care about making a nice CLI?

  • your users will like it
    • you do like your users, don't you?
  • you will like it
    • you do use your own software, don't you?
  • a nice CLI tends to be less error prone to use
    • this means you get fewer support requests
  • if you have competition, a nicer user experience will give you a boost
  • it turns out that a nice CLI is easier and cheaper to maintain
    • a nice CLI requires a nice command line parser and that, in turn, means code to define the CLI is simpler, easier to get right

Program to greet

Below I will show a few ways to implement a CLI for a program that greets the user ("hello, world"). The program is used like this:

  • greet → "hello, world"
  • greet --whom Earth → "hello, Earth"
  • greet --whom=Earth → "hello, Earth"

For reasons of how the Unix command line conventions evolved, a long option value may be part of the argument (with an equals sign) or the next argument. Users expect this, but it complicates the command line parser.

CLI without dependencies beyond std

The code below uses only the std library, and parses the command line manually. Note that it is buggy: this usually happens, when you write command line parsing manually.

let mut whom = Some("world".into());
let mut got_whom = false;
let mut args = std::env::args();
args.next();
for arg in args {
    if let Some(suffix) = arg.strip_prefix("--whom=") {
        whom = Some(suffix.to_string());
    } else if arg == "--whom" {
        got_whom = true;
    } else if got_whom {
        whom = Some(arg.to_string());
        got_whom = false;
    } else {
        eprintln!("usage error!");
        std::process::exit(1);
    }
}

CLI with clap, imperative

The clap crate is by far the most commonly used Rust library for command line parsing. A lot of effort has been put into making it both a pleasure to use for the programmer, and for the user.

The code below uses the traditional, imperative approach: you create a Command value, and configure that to know about the accepted command line arguments. This is straightforward, but scales badly if to programs with very large numbers of options: curl, for example, has 255 options.

Note that this code lacks help texts, for brevity.

use clap::{Arg, Command};

fn main() {
    let matches = Command::new("greet")
        .arg(Arg::new("whom")
            .long("whom")
            .default_value("world"))
        .get_matches();
    let whom: &String = matches.get_one("whom").unwrap();
    println!("hello, {}", whom);
}

CLI with clap, declarative

The derive feature of clap allows a declarative approach for defining command line syntax. The code below does that. It still lacks help texts.

This style feels more magic, but is easy to work with and fully as powerful as the imperative style. Using the declarative style would be a good idea for anyone who wants to write a curl clone in a weekend.

use clap::Parser;

fn main() {
    let args = Args::parse();
    println!("{}", args.whom);
}

#[derive(Parser)]
struct Args {
    #[clap(long, default_value = "world")]
    whom: String,
}

CLI with clap, with subcommands (1/4)

The example program we're looking at could have a farewell mode as well as a greeting mode. This can be done using subcommands:

  • greet hello --whom=world
  • greet goodbye --whom=world

The code below demonstrates one approach for how to implement this using clap. The doc comments get turned into help text shown to the user.

use clap::{Parser, Subcommand};

fn main() {
    let args = Args::parse();
    match args.cmd {
        Command::Hello(x) => x.run(),
        Command::Goodbye(x) => x.run(),
    }
}

/// General purpose greet/farewell messaging.
#[derive(Parser)]
struct Args {
    #[command(subcommand)]
    cmd: Command,
}

#[derive(Subcommand)]
enum Command {
    Hello(Hello),
    Goodbye(Goodbye),
}

/// Greet someone.
#[derive(Parser)]
struct Hello {
    /// Whom should we greet?
    #[clap(long, default_value("world"))]
    whom: String,
}

impl Hello {
    fn run(&self) {
        println!("hello, {}", self.whom);
    }
}

/// Say good bye to someone.
#[derive(Parser)]
struct Goodbye {
    /// Whom should we say good by to?
    #[clap(long, default_value("cruel world"))]
    whom: String,
}

impl Goodbye {
    fn run(&self) {
        println!("good bye, {}", self.whom);
    }
}

Output: top level help

$ cargo run -q -- --help
General purpose greet/farewell messaging

Usage: greet <COMMAND>

Commands:
  hello    Greet someone
  goodbye  Say good bye to someone
  help     Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

Output: help for a subcommand

$ cargo run -q -- hello --help
Greet someone

Usage: greet hello [OPTIONS]

Options:
      --whom <WHOM>  Whom should we greet? [default: world]
  -h, --help         Print help

Output: subcommands

$ cargo run -q -- hello
hello, world

$ cargo run -q -- hello --whom=Earth
hello, Earth

Error: missing subcommand

$ cargo run -q -- 
General purpose greet/farewell messaging

Usage: greet <COMMAND>

Commands:
  hello    Greet someone
  goodbye  Say good bye to someone
  help     Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

Error: extra argument

$ cargo run -q -- hello there
error: unexpected argument 'there' found

Usage: greet hello [OPTIONS]

For more information, try '--help'.

AD: I do Rust training for money

Basics of Rust