Home

A Rust CLI that feels finished

Rust is my default for command-line tools, mostly because a single static binary with no runtime is exactly what a CLI should be. clap does the argument parsing, and the rest of this is about the parts that make a tool feel finished rather than like a script.

The derive API

use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "notes", version, about = "A note-taking tool")]
struct Cli {
    /// Path to the notes directory
    #[arg(short, long, env = "NOTES_DIR", default_value = "~/notes")]
    directory: PathBuf,

    /// Increase output verbosity
    #[arg(short, long, action = clap::ArgAction::Count)]
    verbose: u8,

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

#[derive(Subcommand)]
enum Command {
    /// Create a new note
    New {
        title: String,
        #[arg(short, long)]
        tags: Vec<String>,
    },
    /// Search existing notes
    Search {
        query: String,
        #[arg(short, long, default_value_t = 10)]
        limit: usize,
    },
}

Three things in there are worth pointing at:

Doc comments become help text. The /// above each field is what --help prints. Documentation and help text cannot drift apart because they are the same thing.

env = "NOTES_DIR" makes an argument settable from the environment, with the command-line flag taking precedence. One attribute, and the tool is configurable in CI without a config file.

version with no value reads it from Cargo.toml, so --version cannot go stale.

Parsing is then one line, and --help, --version, error messages and suggestions for mistyped flags all exist:

let cli = Cli::parse();

The four things that make it feel finished

Exit codes

A tool that always exits 0 cannot be used in a script.

use std::process::ExitCode;

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("error: {error:#}");
            ExitCode::FAILURE
        }
    }
}

{error:#} with anyhow prints the whole error chain rather than just the outermost message, which is the difference between “failed” and “failed: could not open ~/notes: permission denied”.

stdout for data, stderr for everything else

The rule that makes a tool composable:

println!("{}", result);              // data — pipeable
eprintln!("Searching {} notes…", count);   // progress — not pipeable

Progress messages on stdout end up in the file when someone runs notes search foo > results.txt. This is the most common mistake in first-time CLIs and it is invisible until someone pipes your output.

Detect whether you are in a terminal

Colour codes and progress bars are noise when the output is a pipe:

use std::io::IsTerminal;

let use_colour = std::io::stdout().is_terminal();

Respect NO_COLOR too — it is a one-line check and it is the convention.

Shell completions

Generated at build time, and they are what makes a tool feel native:

use clap_complete::{generate, Shell};

Command::Completions { shell } => {
    generate(shell, &mut Cli::command(), "notes", &mut std::io::stdout());
}

notes completions zsh > ~/.zfunc/_notes and tab completion works for every subcommand and flag, derived from the same struct.

Tip

--dry-run on anything destructive, and make it print exactly what would happen. It costs an hour and it is the difference between a tool people trust with --force and one they run carefully every time.

Structuring for testability

Keep main thin and put the work in a library:

src/
  main.rs        — argument parsing, exit codes
  lib.rs         — everything else

main.rs becomes twenty lines and everything real is testable without spawning a process. For the end-to-end cases, assert_cmd runs the binary and asserts on output and exit code:

#[test]
fn search_with_no_results_exits_zero() {
    Command::cargo_bin("notes").unwrap()
        .args(["search", "nonexistent"])
        .assert()
        .success()
        .stdout(predicate::str::contains("No matches"));
}

Do you need async

Usually not, and it is worth resisting.

A CLI doing a handful of sequential operations is simpler, faster to compile and easier to debug with blocking I/O. ureq for HTTP and std::fs for files cover most tools, and the binary is substantially smaller without a runtime.

Reach for tokio when the tool is genuinely concurrent — fetching fifty URLs, watching several files — and not because async is the modern default. For a program that does three things in order, it is overhead with no benefit.