A command-line tool end to end
Designing the CLI, parsing arguments, configuration and environment, error reporting, logging, tests, cross-compilation and publishing a release.
Designing the interface
Start from the invocations you want to support and write the --help output by hand before writing any Rust. A CLI is an API that is hard to change once scripts depend on it, and flag naming is the part users notice.
notes 0.1.0
Manage local notes.
USAGE:
notes [OPTIONS] <COMMAND>
COMMANDS:
add <TEXT> Append a note
list List notes, newest first
rm <ID> Remove a note by id
export <PATH> Write all notes to JSON
OPTIONS:
-c, --config <PATH> Config file [default: ~/.notes.toml]
-v, --verbose... Increase verbosity
-q, --quiet Suppress non-error output
-h, --help Print help
-V, --version Print version
EXIT CODES:
0 success, 1 usage error, 2 runtime error, 3 not found- Follow the conventions users already know:
-h/--help,-V/--version,--to end options,-for standard input, and a non-zero exit code on failure. - A subcommand per verb beats a pile of flags.
notes add "text"is discoverable;notes -a -m "text"is a puzzle. - Reserve standard output for data and standard error for diagnostics, so
notes list | grep xworks and progress messages do not corrupt it. - Document exit codes. Scripts branch on them, and an unintended 0 on failure is the bug users notice last.
Arguments, config and errors
use clap::{Parser, Subcommand};
use serde::Deserialize;
use std::{path::PathBuf, process::ExitCode};
#[derive(Parser, Debug)]
#[command(version, about)]
struct Cli {
#[arg(short, long, default_value_os_t = default_config())]
config: PathBuf,
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Add { text: String },
List { #[arg(long)] json: bool },
Rm { id: u64 },
Export { path: PathBuf },
}
#[derive(Deserialize, Debug, Default)]
struct Config {
#[serde(default = "default_db")]
database: PathBuf,
}
fn default_db() -> PathBuf { PathBuf::from("notes.db") }
fn default_config() -> PathBuf {
std::env::var_os("NOTES_CONFIG")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("notes.toml"))
}
#[derive(thiserror::Error, Debug)]
enum Error {
#[error("no note with id {0}")]
NotFound(u64),
#[error("cannot read {path}: {source}")]
Io { path: PathBuf, #[source] source: std::io::Error },
#[error(transparent)]
Json(#[from] serde_json::Error),
}
fn load(cli: &Cli) -> Result<Config, Error> {
match std::fs::read_to_string(&cli.config) {
Ok(text) => serde_json::from_str(&text).map_err(Error::from),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
Err(source) => Err(Error::Io { path: cli.config.clone(), source }),
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let cfg = match load(&cli) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::from(2);
}
};
match run(&cli, &cfg) {
Ok(()) => ExitCode::SUCCESS,
Err(Error::NotFound(_)) => {
eprintln!("error: note not found");
ExitCode::from(3)
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(2)
}
}
}
fn run(cli: &Cli, _cfg: &Config) -> Result<(), Error> {
match &cli.command {
Cmd::Add { text } => println!("added: {text}"),
Cmd::List { json } => println!("json={json}"),
Cmd::Rm { id } => return Err(Error::NotFound(*id)),
Cmd::Export { path } => println!("exporting to {}", path.display()),
}
Ok(())
}- Precedence for configuration is a convention worth following: command-line flag, then environment variable, then config file, then built-in default. Document it once and stick to it.
ExitCodemakes the exit status part of the type ofmain, so a failure path cannot be forgotten.#[arg(default_value_os_t = ...)]handles non-UTF-8 paths; string defaults panic on a path that is not valid Unicode.verbosityas a count gives-v,-vvfor free, which is friendlier than a numeric level nobody remembers.anyhowat the top level with.context("loading config")is a reasonable alternative to the error enum here; use whichever the rest of the codebase uses.
Tests, cross-compilation and release
// tests/cli.rs — drive the real binary, not just the library
use std::process::Command;
#[test]
fn add_then_list_shows_the_note() {
let dir = tempfile::tempdir().unwrap();
let bin = env!("CARGO_BIN_EXE_notes"); // path to the built binary
let add = Command::new(bin)
.args(["add", "buy milk"])
.env("NOTES_CONFIG", dir.path().join("notes.toml"))
.output()
.unwrap();
assert!(add.status.success(), "stderr: {}", String::from_utf8_lossy(&add.stderr));
assert!(!add.stdout.is_empty());
}
#[test]
fn rm_missing_id_exits_with_code_3() {
let out = Command::new(env!("CARGO_BIN_EXE_notes"))
.args(["rm", "999"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3)); // the documented exit code
}cargo test --all-features
cargo clippy --all-targets -- -D warnings
cargo build --release
# cross-compile: a static musl binary runs on any Linux
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
# or use cargo-dist to generate installers and a release workflow
cargo install cargo-dist
cargo dist init
# single-file man page and shell completions, generated from the clap definition
notes --help > notes.1| Artifact | How it is produced |
|---|---|
| Linux x86_64 (musl) | Static binary, no glibc dependency, runs on any distribution |
| macOS arm64 and x86_64 | Two builds, or a universal binary with lipo |
| Windows x86_64 | MSVC toolchain by default; the GNU toolchain avoids the Visual Studio dependency |
| Shell completions | clap_complete generates bash, zsh, fish and PowerShell scripts |
| Checksums | sha256sum per artifact, published beside the binary |
| Release notes | Generated from conventional commits, or written by hand from the tag range |
- Test the binary as a process, not only the library. Exit codes, stdout format and flag parsing are the contract, and
env!("CARGO_BIN_EXE_name")gives you the path to the build. - Cross-compilation with musl plus
CGO_ENABLED=0(or a matching musl cross-linker) yields a static binary that does not care which distribution runs it. - Build every release artifact in CI from a tag, and attach checksums. A binary that was built on someone's laptop cannot be reproduced or verified.
- Follow the tag with the version in
Cargo.toml:cargo publish --dry-runthencargo publishfor the crate, andcargo distor a release workflow for the binaries. - Ship a changelog. For a CLI, the list of changed flags is the most valuable documentation you can hand a user.
💡
The end-to-end shape that works: a thin
main that parses arguments and maps errors to exit codes, all logic in the library so it is testable from tests/, and configuration loaded in one place with a documented precedence order. Everything else in this course — traits, collections, iterators, serde, error types — is in service of keeping those three parts small.FAQ
How do I test a CLI that talks to the network or the clock?
Keep the effect behind a trait in the library, and inject the real implementation from
main. The process-level tests then run against a fake or an in-memory backend, which keeps them fast and deterministic.Why does my binary not run on another Linux machine?
It was linked against the glibc of the build machine. Build for
x86_64-unknown-linux-musl, or build inside an old base image, so the binary depends only on the kernel.Related
Setting up Rust: rustup, Cargo and edition 2024 File I/O, serde and everyday crates
Last refreshed 2026-09-18.