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.