Writing a CLI in Rust

In this post I’ll walk through the setup of an example project to show how to build a modern CLI in Rust.

Background

I recently embedded on another team at work in order to improve their testing processes. In doing so I created a CLI to provide a better developer experience and eventually a unified interface for interacting with their repository.

I have a fair amount of experience developing CLIs, such as Python’s Hatch and the tool my current team uses to maintain hundreds of packages while staying lean.

My go-to framework in Python is Click, which allows for a declarative approach to defining commands. Using their example:

# file: "hello.py"
import click

@click.command()
@click.option("--count", default=1, help="Number of greetings.")
@click.option("--name", prompt="Your name", help="The person to greet.")
def hello(count, name):
    """Simple program that greets NAME for a total of COUNT times."""
    for _ in range(count):
        click.echo(f"Hello, {name}!")

if __name__ == '__main__':
    hello()

I quite like this style and the overall development velocity granted by Python, so I was worried about losing one or both of these. Fortunately, Clap provides similar functionality and I’m just as productive developing in Rust.

Setup

Install Rust then create an app called rusty:

cargo new rusty
cd rusty

We’ll add Clap with the derive feature for the actual CLI and anyhow for easy error propagation:

cargo add clap -F derive
cargo add anyhow

The commands themselves, which are each just a struct, will be stored in the src/commands directory. We want every command group/namespace to have its own directory with a cli module, including the root rusty command group. All commands will have an exec method that provides the actual implementation, which in the case of command groups will simply be calling sub-commands.

Create the following files:

// file: "src/commands/mod.rs"
pub mod cli;
// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::Parser;

/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        println!("Hello, World!");

        Ok(())
    }
}

Then update the binary entry point:

// file: "src/main.rs"
mod commands;

use anyhow::Result;
use clap::Parser;

use crate::commands::cli::Cli;

fn main() -> Result<()> {
    let cli = Cli::parse();

    cli.exec()
}

At this point the project’s structure should look like:

src
├── commands
│   ├── cli.rs
│   └── mod.rs
└── main.rs
Cargo.lock
Cargo.toml

Now let’s test the app:

❯ cargo run -q -- --help
Rusty example app

Usage: rusty

Options:
  -h, --help     Print help information
  -V, --version  Print version information
❯ cargo run -q -- --version
rusty 0.1.0
❯ cargo run -q
Hello, World!

The help text is derived from the documentation on the command we defined and the version comes from the Cargo.toml file.

Argument passing

Very often you’ll want to pass arguments through to other executables, like when working in Kubernetes environments. To illustrate this, we’ll add an exec sub-command that will spawn a process with the supplied arguments.

Create the following file:

// file: "src/commands/exec.rs"
use anyhow::Result;
use clap::Args;
use std::process::{exit, Command};

/// Execute an arbitrary command
///
/// All arguments are passed through unless --help is first
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
    #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
    args: Vec<String>,
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        let mut command = Command::new(&self.args[0]);
        if self.args.len() > 1 {
            command.args(&self.args[1..]);
        }

        let status = command.status()?;
        exit(status.code().unwrap_or(1));
    }
}

Then add the new module:

// file: "src/commands/mod.rs"
pub mod cli;
pub mod exec;

Finally, modify the root command:

// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};

/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    Exec(super::exec::Cli),
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        match &self.command {
            Commands::Exec(cli) => cli.exec(),
        }
    }
}

At this point the project’s structure should look like:

src
├── commands
│   ├── cli.rs
│   ├── exec.rs
│   └── mod.rs
└── main.rs
Cargo.lock
Cargo.toml

Let’s try it:

❯ cargo run -q -- --help
Rusty example app

Usage: rusty <COMMAND>

Commands:
  exec  Execute an arbitrary command

Options:
  -h, --help     Print help information
  -V, --version  Print version information
❯ cargo run -q -- exec --help
Execute an arbitrary command

All arguments are passed through unless --help is first

Usage: rusty exec <ARGS>...

Arguments:
  <ARGS>...


Options:
  -h, --help
          Print help information (use `-h` for a summary)
❯ cargo run -q -- exec ls -a
.
..
.git
.gitignore
Cargo.lock
Cargo.toml
src
target

Verbosity

Let’s add an option to influence the app’s verbosity.

Flags

We’ll use the clap-verbosity-flag crate:

cargo add clap-verbosity-flag

Modify the root command:

// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};

/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
    #[clap(flatten)]
    pub verbose: Verbosity<InfoLevel>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    Exec(super::exec::Cli),
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        match &self.command {
            Commands::Exec(cli) => cli.exec(),
        }
    }
}

The <InfoLevel> indicates that the default level will show informational output.

Now every command can influence the output level:

❯ cargo run -q -- --help
Rusty example app

Usage: rusty [OPTIONS] <COMMAND>

Commands:
  exec  Execute an arbitrary command

Options:
  -v, --verbose...  More output per occurrence
  -q, --quiet...    Less output per occurrence
  -h, --help        Print help information
  -V, --version     Print version information
❯ cargo run -q -- exec --help
Execute an arbitrary command

All arguments are passed through unless --help is first

Usage: rusty exec [OPTIONS] <ARGS>...

Arguments:
  <ARGS>...


Options:
  -v, --verbose...
          More output per occurrence

  -q, --quiet...
          Less output per occurrence

  -h, --help
          Print help information (use `-h` for a summary)

Access

Next we’ll provide a way to access the configured verbosity level globally using the once_cell crate:

cargo add once_cell

The log crate is also required for the level enums:

cargo add log

Create the following file:

// file: "src/app.rs"
use log::LevelFilter;
use once_cell::sync::OnceCell;

static VERBOSITY: OnceCell<LevelFilter> = OnceCell::new();

pub fn verbosity() -> &'static LevelFilter {
    VERBOSITY.get().expect("verbosity is not initialized")
}

pub fn set_global_verbosity(verbosity: LevelFilter) {
    VERBOSITY.set(verbosity).expect("could not set verbosity")
}

Then update the binary entry point:

// file: "src/main.rs"
mod app;
mod commands;

use anyhow::Result;
use clap::Parser;

use crate::commands::cli::Cli;

fn main() -> Result<()> {
    let cli = Cli::parse();
    app::set_global_verbosity(cli.verbose.log_level_filter());

    cli.exec()
}

At this point the project’s structure should look like:

src
├── commands
│   ├── cli.rs
│   ├── exec.rs
│   └── mod.rs
├── app.rs
└── main.rs
Cargo.lock
Cargo.toml

Now the entire app can access the configured verbosity level with:

*crate::app::verbosity()

Output macros

With the verbosity set globally, we can create declarative macros that will be available throughout the app for conditionally displaying text based on the verbosity.

Create the following file:

// file: "src/macros.rs"
macro_rules! define_display_macro {
    ($name:ident, $level:ident, $d:tt) => {
        macro_rules! $name {
            ($d($d arg:tt)*) => {
                if log::Level::$level <= *$crate::app::verbosity() {
                    eprintln!($d($d arg)*);
                }
            };
        }
    };
}

define_display_macro!(trace, Trace, $);
define_display_macro!(debug, Debug, $);
define_display_macro!(info, Info, $);
define_display_macro!(warn, Warn, $);
define_display_macro!(error, Error, $);

Here we’re maximizing boilerplate reduction by defining a single macro that will generate the macros the app will actually use.

The $ hack is a workaround for a limitation that is being worked on (see rust-lang/rust#35853 and rust-lang/rust#83527).

Then add the module to the very top of the binary entry point preceded by #[macro_use]:

// file: "src/main.rs"
#[macro_use]
mod macros;
mod app;
mod commands;

use anyhow::Result;
use clap::Parser;

use crate::commands::cli::Cli;

fn main() -> Result<()> {
    let cli = Cli::parse();
    app::set_global_verbosity(cli.verbose.log_level_filter());

    cli.exec()
}

Now let’s create a hidden sub-command to test the macros:

// file: "src/commands/test.rs"
use anyhow::Result;
use clap::Args;

/// Test conditional output
#[derive(Args, Debug)]
#[command(hide = true)]
pub struct Cli {
    text: String,
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        trace!("trace {}", self.text);
        debug!("debug {}", self.text);
        info!("info {}", self.text);
        warn!("warn {}", self.text);
        error!("error {}", self.text);

        Ok(())
    }
}

Then add the new module:

// file: "src/commands/mod.rs"
pub mod cli;
pub mod exec;
pub mod test;

Finally, add it to the root command:

// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};

/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
    #[clap(flatten)]
    pub verbose: Verbosity<InfoLevel>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    Exec(super::exec::Cli),
    Test(super::test::Cli),
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        match &self.command {
            Commands::Exec(cli) => cli.exec(),
            Commands::Test(cli) => cli.exec(),
        }
    }
}

At this point the project’s structure should look like:

src
├── commands
│   ├── cli.rs
│   ├── exec.rs
│   ├── mod.rs
│   └── test.rs
├── app.rs
├── macros.rs
└── main.rs
Cargo.lock
Cargo.toml

Let’s try it out:

❯ cargo run -q -- --help
Rusty example app

Usage: rusty [OPTIONS] <COMMAND>

Commands:
  exec  Execute an arbitrary command

Options:
  -v, --verbose...  More output per occurrence
  -q, --quiet...    Less output per occurrence
  -h, --help        Print help information
  -V, --version     Print version information
❯ cargo run -q -- test hello
info hello
warn hello
error hello
❯ cargo run -q -- test hello -vv
trace hello
debug hello
info hello
warn hello
error hello
❯ cargo run -q -- test hello -qq
error hello

Colors

Now let’s apply styling to the output levels using owo-colors with the supports-colors feature for conditional use based on TTY detection:

cargo add owo-colors -F supports-colors

Update the macros and the test command:

// file: "src/macros.rs"
macro_rules! display {
    ($($arg:tt)*) => {{
        use owo_colors::OwoColorize;
        println!(
            "{}",
            format!($($arg)*)
                .if_supports_color(owo_colors::Stream::Stdout, |text| text.bold())
        );
    }};
}

macro_rules! critical {
    ($($arg:tt)*) => {{
        use owo_colors::OwoColorize;
        eprintln!(
            "{}",
            format!($($arg)*)
                .if_supports_color(owo_colors::Stream::Stderr, |text| text.bright_red())
        );
    }};
}

macro_rules! define_display_macro {
    ($name:ident, $level:ident, $style:ident, $d:tt) => (
        macro_rules! $name {
            ($d($d arg:tt)*) => {{
                use owo_colors::OwoColorize;
                if log::Level::$level <= *$crate::app::verbosity() {
                    eprintln!(
                        "{}",
                        format!($d($d arg)*)
                            .if_supports_color(owo_colors::Stream::Stderr, |text| text.$style())
                    );
                }
            }};
        }
    );
}

define_display_macro!(trace, Trace, underline, $);
define_display_macro!(debug, Debug, italic, $);
define_display_macro!(info, Info, bold, $);
define_display_macro!(success, Info, bright_cyan, $);
define_display_macro!(waiting, Info, bright_magenta, $);
define_display_macro!(warn, Warn, bright_yellow, $);
define_display_macro!(error, Error, bright_red, $);

For standard or informational output we use bold rather than bright white for terminals with white backgrounds.

// file: "src/commands/test.rs"
use anyhow::Result;
use clap::Args;

/// Test conditional output
#[derive(Args, Debug)]
#[command(hide = true)]
pub struct Cli {
    text: String,
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        trace!("trace {}", self.text);
        debug!("debug {}", self.text);
        info!("info {}", self.text);
        success!("success {}", self.text);
        waiting!("waiting {}", self.text);
        warn!("warn {}", self.text);
        error!("error {}", self.text);
        display!("display {}", self.text);
        critical!("critical {}", self.text);

        Ok(())
    }
}

The display!/critical! macros provide a way to always output using println!/eprintln! but with color.

❯ cargo run -q -- test hello -qqq
display hello
critical hello

Configuration

Loading

Now we’ll persist app settings using confy and serde with the derive feature:

cargo add serde -F derive
cargo add confy

Create the following file:

// file: "src/config.rs"
use anyhow::{Context, Result};
use confy;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

const APP_NAME: &str = "rusty";
const FILE_STEM: &str = "config";

#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Config {
    pub repo: String,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            repo: "".into(),
        }
    }
}

pub fn path() -> Result<PathBuf> {
    confy::get_configuration_file_path(APP_NAME, FILE_STEM)
        .with_context(|| "unable to find the config file")
}

pub fn load() -> Result<Config> {
    confy::load(APP_NAME, FILE_STEM).with_context(|| "unable to load config")
}

pub fn save(config: Config) -> Result<()> {
    confy::store(APP_NAME, FILE_STEM, config).with_context(|| "unable to save config")
}

Here we defined a single option named repo. Now let’s load the configuration and provide global access to it like we did for the verbosity.

Edit the following file:

// file: "src/app.rs"
use log::LevelFilter;
use once_cell::sync::OnceCell;

use crate::config::Config;

static VERBOSITY: OnceCell<LevelFilter> = OnceCell::new();
static CONFIG: OnceCell<Config> = OnceCell::new();

pub fn verbosity() -> &'static LevelFilter {
    VERBOSITY.get().expect("verbosity is not initialized")
}

pub fn config() -> &'static Config {
    CONFIG.get().expect("config is not initialized")
}

pub fn set_global_verbosity(verbosity: LevelFilter) {
    VERBOSITY.set(verbosity).expect("could not set verbosity")
}

pub fn set_global_config(config: Config) {
    CONFIG.set(config).expect("could not set config")
}

Then update the binary entry point:

// file: "src/main.rs"
#[macro_use]
mod macros;
mod app;
mod commands;
mod config;

use anyhow::Result;
use clap::Parser;

use crate::commands::cli::Cli;

fn main() -> Result<()> {
    let cli = Cli::parse();
    app::set_global_verbosity(cli.verbose.log_level_filter());
    app::set_global_config(config::load()?);

    cli.exec()
}

At this point the project’s structure should look like:

src
├── commands
│   ├── cli.rs
│   ├── exec.rs
│   ├── mod.rs
│   └── test.rs
├── app.rs
├── config.rs
├── macros.rs
└── main.rs
Cargo.lock
Cargo.toml

Now the entire app can access the loaded configuration with:

crate::app::config()

Windows canonical paths

Before we procede, let’s add a utility for resolving a path that works around a long-standing issue on Windows.

We’ll use the dunce crate:

cargo add dunce

Create the following file:

// file: "src/platform.rs"
pub fn canonicalize_path(path: &String) -> String {
    match dunce::canonicalize(path) {
        Ok(p) => p.display().to_string(),
        Err(_) => path.to_string(),
    }
}

Then add the module to the binary entry point:

// file: "src/main.rs"
#[macro_use]
mod macros;
mod app;
mod commands;
mod config;
mod platform;

use anyhow::Result;
use clap::Parser;

use crate::commands::cli::Cli;

fn main() -> Result<()> {
    let cli = Cli::parse();
    app::set_global_verbosity(cli.verbose.log_level_filter());
    app::set_global_config(config::load()?);

    cli.exec()
}

Commands

Let’s now add an interface for managing the configuration.

First create the following files:

// file: "src/commands/config/mod.rs"
pub mod cli;
pub mod find;
pub mod set;
// file: "src/commands/config/cli.rs"
use anyhow::Result;
use clap::{Args, Subcommand};

/// Manage the config file
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    Find(super::find::Cli),
    Set(super::set::cli::Cli),
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        match &self.command {
            Commands::Find(cli) => cli.exec(),
            Commands::Set(cli) => cli.exec(),
        }
    }
}
// file: "src/commands/config/find.rs"
use anyhow::Result;
use clap::Args;

use crate::config;

/// Locate the config file
#[derive(Args, Debug)]
#[command()]
pub struct Cli {}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        display!("{}", config::path()?.display());

        Ok(())
    }
}
// file: "src/commands/config/set/mod.rs"
pub mod cli;
pub mod repo;
// file: "src/commands/config/set/cli.rs"
use anyhow::Result;
use clap::{Args, Subcommand};

/// Modify the config file
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    Repo(super::repo::Cli),
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        match &self.command {
            Commands::Repo(cli) => cli.exec(),
        }
    }
}
// file: "src/commands/config/set/repo.rs"
use anyhow::Result;
use clap::Args;

use crate::{app, config, platform};

/// Set the path to the repository
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
    path: String,
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        let path = platform::canonicalize_path(&self.path);
        debug!("Setting repository path to: {}", &path);

        let mut config = app::config().clone();
        config.repo = path;
        config::save(config)?;

        Ok(())
    }
}

Then add the new module and update the root command:

// file: "src/commands/mod.rs"
pub mod cli;
pub mod config;
pub mod exec;
pub mod test;
// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};

/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
    #[clap(flatten)]
    pub verbose: Verbosity<InfoLevel>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    Config(super::config::cli::Cli),
    Exec(super::exec::Cli),
    Test(super::test::Cli),
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        match &self.command {
            Commands::Config(cli) => cli.exec(),
            Commands::Exec(cli) => cli.exec(),
            Commands::Test(cli) => cli.exec(),
        }
    }
}

At this point the project’s structure should look like:

src
├── commands
│   ├── config
│   │   ├── set
│   │   │   ├── cli.rs
│   │   │   ├── mod.rs
│   │   │   └── repo.rs
│   │   ├── cli.rs
│   │   ├── find.rs
│   │   └── mod.rs
│   ├── cli.rs
│   ├── exec.rs
│   ├── mod.rs
│   └── test.rs
├── app.rs
├── config.rs
├── macros.rs
├── main.rs
└── platform.rs
Cargo.lock
Cargo.toml

Let’s try it out:

❯ cargo run -q -- --help
Rusty example app

Usage: rusty [OPTIONS] <COMMAND>

Commands:
  config  Manage the config file
  exec    Execute an arbitrary command

Options:
  -v, --verbose...  More output per occurrence
  -q, --quiet...    Less output per occurrence
  -h, --help        Print help information
  -V, --version     Print version information
❯ cargo run -q -- config --help
Manage the config file

Usage: rusty config [OPTIONS] <COMMAND>

Commands:
  find  Locate the config file
  set   Modify the config file

Options:
  -v, --verbose...  More output per occurrence
  -q, --quiet...    Less output per occurrence
  -h, --help        Print help information
❯ cargo run -q -- config set repo . -v
Setting repository path to: C:\Users\ofek\Desktop\code\rusty

Shell completion

Let’s offer shell completion using clap_complete:

cargo add clap_complete

Create the following file:

// file: "src/commands/complete.rs"
use anyhow::Result;
use clap::{Args, CommandFactory};
use clap_complete::{generate, Shell};
use std::io;

use crate::commands::cli::Cli as RootCli;

/// Display the completion file for a given shell
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
    #[arg(value_enum)]
    shell: Shell,
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        let mut cmd = RootCli::command();
        let bin_name = cmd.get_name().to_string();
        generate(self.shell, &mut cmd, bin_name, &mut io::stdout());

        Ok(())
    }
}

Then add the new module and update the root command:

// file: "src/commands/mod.rs"
pub mod cli;
pub mod complete;
pub mod config;
pub mod exec;
pub mod test;
// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};

/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
    #[clap(flatten)]
    pub verbose: Verbosity<InfoLevel>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    Complete(super::complete::Cli),
    Config(super::config::cli::Cli),
    Exec(super::exec::Cli),
    Test(super::test::Cli),
}

impl Cli {
    pub fn exec(&self) -> Result<()> {
        match &self.command {
            Commands::Complete(cli) => cli.exec(),
            Commands::Config(cli) => cli.exec(),
            Commands::Exec(cli) => cli.exec(),
            Commands::Test(cli) => cli.exec(),
        }
    }
}

Let’s see:

❯ cargo run -q -- --help
Rusty example app

Usage: rusty [OPTIONS] <COMMAND>

Commands:
  complete  Display the completion file for a given shell
  config    Manage the config file
  exec      Execute an arbitrary command

Options:
  -v, --verbose...  More output per occurrence
  -q, --quiet...    Less output per occurrence
  -h, --help        Print help information
  -V, --version     Print version information
❯ cargo run -q -- complete --help
Display the completion file for a given shell

Usage: rusty complete [OPTIONS] <SHELL>

Arguments:
  <SHELL>  [possible values: bash, elvish, fish, powershell, zsh]

Options:
  -v, --verbose...  More output per occurrence
  -q, --quiet...    Less output per occurrence
  -h, --help        Print help information

Conclusion

Now you should be all set to implement your own command line applications using the same strategies and directory structure described. The final code lives here.


© 2019-present. All rights reserved.