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 x works 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.
  • ExitCode makes the exit status part of the type of main, 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.
  • verbosity as a count gives -v, -vv for free, which is friendlier than a numeric level nobody remembers.
  • anyhow at 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
ArtifactHow it is produced
Linux x86_64 (musl)Static binary, no glibc dependency, runs on any distribution
macOS arm64 and x86_64Two builds, or a universal binary with lipo
Windows x86_64MSVC toolchain by default; the GNU toolchain avoids the Visual Studio dependency
Shell completionsclap_complete generates bash, zsh, fish and PowerShell scripts
Checksumssha256sum per artifact, published beside the binary
Release notesGenerated 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-run then cargo publish for the crate, and cargo dist or 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.

Setting up Rust: rustup, Cargo and edition 2024 File I/O, serde and everyday crates

Last refreshed 2026-09-18.