Builder Based CLIs

kbknapp

Kevin K.

Posted on December 27, 2023

Builder Based CLIs

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.

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
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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"),
                        ),
                ),
        )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let's commit this as our starting point.

$ git commit -am "starting point"
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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

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

$ cargo run -- update footoolchain
updating toolchain...footoolchain
Enter fullscreen mode Exit fullscreen mode

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(())
}

Enter fullscreen mode Exit fullscreen mode
// 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(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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(())
}

Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
kbknapp
Kevin K.

Posted on December 27, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

CLI Contexts
rust CLI Contexts

December 27, 2023

Using a Trait in Builder CLIs
rust Using a Trait in Builder CLIs

December 27, 2023

Using a Trait in Derive CLIs
rust Using a Trait in Derive CLIs

December 27, 2023

Derive Based CLIs
rust Derive Based CLIs

December 27, 2023

Builder Based CLIs
rust Builder Based CLIs

December 27, 2023