In Part 2 we dive into some actual code to see what it would look like to use these patterns for CLIs that utilize the clap Builder method.

Series Contents

Previously On...

Reminder I said, the commands and arguments of our bustup will only print messages to the terminal (without color...perhaps I'll fully explore Ctx initialization and well defined output coloring in another post).

Also another reminder, we don't care about the tool itself here, so forgive the brevity and code dump.

Let's gooooo

First, some setup:

$ cargo new bustup
$ cd bustup
$ git add .
$ git commit -m "Initial Commit"
$ git switch -c builder
$ cargo add clap anyhow

And now the code:

NOTE This post is not attempting to show all the cool things you can do with clap, or even trying to use any of the developer niceties. That would get in the way of what we're trying to demo, so the code is naturally a little terse to keep the post shorter bearable.

// src/main.rs
mod cli;

fn main() -> anyhow::Result<()> {
    let args = cli::build().get_matches();

    todo!("Run the program!");

    Ok(())
}

And now the actual CLI:

// src/cli.rs
use clap::{Arg, ArgAction, Command};

pub fn build() -> Command {
    Command::new("bustup")
        .about("Not rustup")
        .subcommand(
            Command::new("update")
                .about("update toolchains")
                .arg(
                    Arg::new("toolchain")
                        .help("toolchain to update")
                        .action(ArgAction::Set)
                        .default_value("default"),
                )
                .arg(
                    Arg::new("force")
                        .short('f')
                        .long("force")
                        .help("Forcibly update")
                        .action(ArgAction::SetTrue),
                ),
        )
        .subcommand(
            Command::new("target")
                .about("manage targets")
                .arg(
                    Arg::new("toolchain")
                        .help("toolchain to use")
                        .long("toolchain")
                        .short('t')
                        .action(ArgAction::Set)
                        .default_value("default"),
                        .global(true),
                )
                .subcommand(
                    Command::new("add")
                        .about("add a target")
                        .arg(
                            Arg::new("target")
                                .help("The target to add")
                                .action(ArgAction::Set)
                            ),
                )
                .subcommand(
                    Command::new("list").about("list targets").arg(
                        Arg::new("installed")
                            .help("Only list installed targets")
                            .long("installed")
                            .short('i')
                            .action(ArgAction::SetTrue),
                    ),
                )
                .subcommand(
                    Command::new("remove")
                        .about("remove a target")
                        .arg(
                            Arg::new("target")
                                .help("The target to remove")
                                .action(ArgAction::Set)
                                .default_value("default"),
                        ),
                ),
        )
}

We can see that the CLI build properly by passing the --help flag to the various commands:

$ cargo run -q -- --help
Not rustup

Usage: bustup [COMMAND]

Commands:
  update  update toolchains
  target  manage targets
  help    Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help
  
$ cargo run -q -- update --help
update toolchains

Usage: bustup update [OPTIONS] [toolchain]

Arguments:
  [toolchain]  toolchain to update

Options:
  -f, --force    Forcibly update
  -h, --help     Print help
  
$ cargo run -q -- target --help
manage targets

Usage: bustup target [OPTIONS] [COMMAND]

Commands:
  add     add a target
  list    list targets
  remove  remove a target
  help    Print this message or the help of the given subcommand(s)

Options:
  -t, --toolchain <toolchain>  toolchain to use [default: default]
  -h, --help                   Print help
  
$ cargo run -q -- target list --help
list targets

Usage: bustup target list [OPTIONS]

Options:
  -i, --installed              Only list installed targets
  -t, --toolchain <toolchain>  toolchain to use [default: default]
  -h, --help                   Print help

However, if we try to run it, we get a panic due to our todo!():

$ cargo run
thread 'main' panicked at src/main.rs:6:5:
not yet implemented: Run the program!
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Let's commit this as our starting point.

$ git commit -am "starting point"

Running the Program

So we have the basic CLI structure, now how should we structure our program?

Naive Matching and no Ctx

The naive method is to match on a particular subcommand, and dispatch to some run-like function that takes a clap::ArgMatches as a context. This is a common approach, but there are downsides. Let's implement this method for a single command bustup update just so we can contrast it later.

NOTE I'm going to omit code examples that only contain things like declaring module structure for brevity. The full code is located in the repository if interested.

// src/cli/cmds/update.rs
use anyhow::Result;
use clap::ArgMatches;

pub fn run(args: &ArgMatches) -> Result<()> {
    println!(
        "updating toolchain...{}",
        args.get_one::<String>("toolchain")
    );
    Ok(())
}

And finally, our main.rs:

// src/main.rs
use crate::cli::cmds::update;

fn main() -> anyhow::Result<()> {
    let args = cli::build().get_matches();

    match args.subcommand() {
        Some(("update", args)) => {
            update::run(args)?;
        }
        _ => todo!("implement other subcommands"),
    }

    Ok(())
}

We can see that it works by running the update command:

$ cargo run -- update
updating toolchain...default

$ cargo run -- update footoolchain
updating toolchain...footoolchain

This is a perfectly valid approach! For a small number of subcommands, or CLIs with single-layer subcommands this approach is usually fine. However, it can start to go sideways quickly when using multiple layers of configuration or nested subcommand layers, especially when context/run-actions need to happen at each individual layer.

Adding Ctx

It's so tempting to just use clap::ArgMatches as the passed in context like we did above. And for a simple CLI, it'd probably be fine. But we're pretending to build a large and complex CLI.

Based on everything we learned when talking about Ctx above, we're already convinced we should be using a Context Struct. And we know initializing and updating one can be a complex process.

But we're starting small and we won't be adding configuration files or environment variables to complicate things in this post. So let's just create our Ctx and pass that to update::run:

// src/main.rs
mod cli;
// 👇 new
mod context;

//                             👇 new
use crate::{cli::cmds::update, context::Ctx};

fn main() -> anyhow::Result<()> {
    let args = cli::build().get_matches();

    match args.subcommand() {
        Some(("update", args)) => {
            // 👇 new
            let ctx = Ctx::from_update(args);
            update::run(&ctx)?;
        }
        _ => todo!("implement other subcommands"),
    }

    Ok(())
}

// src/context.rs
use clap::ArgMatches;

pub struct Ctx {
    pub toolchain: String,
}

impl Ctx {
    pub fn from_update(args: &ArgMatches) -> Self {
        Self {
            toolchain: args.get_one::<String>("toolchain").unwrap().to_string(),
        }
    }
}
// src/cli/cmds/update.rs
use anyhow::Result;

// 👇 new
use crate::context::Ctx;

//         👇 new
pub fn run(ctx: &Ctx) -> Result<()> {
    //                                 👇 new
    println!("updating toolchain...{}", ctx.toolchain);
    Ok(())
}

This also works!

But for more complex CLIs, this will get tedious and error prone as well for a few reasons:

  • In the above code we're creating the Ctx from scratch which wouldn't be an option with nested subcommands that each need to update a context
  • As we nest subcommands the code to update the context and call the next subcommand is going to become pure boilerplate noise.

Next Time

In the next post we'll see how to use traits to perform some magic and enforce structure on what could otherwise become unbridled chaos.