Skip to content
rusthacks.dev
CLI

A Command Line Tool in Rust From Clap to a Shipped Release

Build a command line tool in Rust from the first clap derive to a release binary: arguments, config, output, exit codes, tests and distribution.

17 min read
Summarize in AI
Flat illustration for the post “A Command Line Tool in Rust From Clap to a Shipped Release”, composed around ownership.

Most command line tools that people abandon do not fail on features. They fail on manners: they print errors to standard output, exit with status 0 after failing, refuse to read from a pipe, ignore --help, and ship as a source tree that needs a compiler to install. Rust makes the engine easy, so the work in a good CLI is the behavior around the engine and the path from cargo new to a binary a stranger can download.

This guide builds one small tool, tally, which counts lines, words and bytes and ranks frequent words. It goes from the argument parser through error handling, tests, shell completions and a man page, to a release profile and a CI workflow that attaches binaries to a GitHub release. The code was built and run on Rust 1.99 with edition 2024, clap 4.6.7 and anyhow 1.0.104, and the outputs below are what it printed.

Decide the contract before writing code

A CLI is an interface that other programs call, so its behavior is a contract. Four rules cover most of what users and scripts rely on.

  1. Results go to standard output and diagnostics go to standard error. A script that runs tally file | sort must not find error text in the pipe.
  2. The exit status tells the truth: 0 for success, non-zero for failure. Shell &&, set -e and CI all depend on it. A common convention is 1 for a runtime failure and 2 for a usage error, which is also what clap uses.
  3. With no file arguments, read standard input. That single decision makes the tool composable with everything else on the machine.
  4. Print nothing decorative when the output is not a terminal. Colors and progress bars belong to humans, and a pipe is not a human.

Everything below follows from those rules.

The argument parser

clap with the derive feature turns a struct into a parser. You describe the interface as data, and the crate generates parsing, validation, --help and --version. The whole tool is short enough to read in one sitting, so here it is as a complete file.

Cargo.tomlTOML
[package]
name = "tally"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
src/main.rsrust
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Read};
use std::path::PathBuf;
use std::process::ExitCode;

use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};

/// Count words, lines and bytes in files or standard input.
#[derive(Parser, Debug)]
#[command(name = "tally", version, about)]
struct Cli {
    /// Files to read. Reads standard input when none are given.
    files: Vec<PathBuf>,

    /// Show the N most frequent words instead of totals.
    #[arg(short, long, value_name = "N")]
    top: Option<usize>,

    /// Output format.
    #[arg(long, value_enum, default_value_t = Format::Text)]
    format: Format,

    /// Ignore case when counting words.
    #[arg(short, long)]
    ignore_case: bool,
}

#[derive(Copy, Clone, Debug, ValueEnum)]
enum Format {
    Text,
    Json,
}

fn read_input(files: &[PathBuf]) -> Result<String> {
    let mut text = String::new();
    if files.is_empty() {
        io::stdin().read_to_string(&mut text).context("reading standard input")?;
    } else {
        for path in files {
            let part = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
            text.push_str(&part);
        }
    }
    Ok(text)
}

fn run(cli: &Cli) -> Result<()> {
    let text = read_input(&cli.files)?;

    if let Some(n) = cli.top {
        let mut counts: BTreeMap<String, usize> = BTreeMap::new();
        for word in text.split_whitespace() {
            let word = if cli.ignore_case { word.to_lowercase() } else { word.to_string() };
            *counts.entry(word).or_default() += 1;
        }
        let mut ranked: Vec<_> = counts.into_iter().collect();
        ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
        for (word, count) in ranked.into_iter().take(n) {
            println!("{count:>5} {word}");
        }
        return Ok(());
    }

    let (lines, words, bytes) = (text.lines().count(), text.split_whitespace().count(), text.len());
    match cli.format {
        Format::Text => println!("{lines} lines, {words} words, {bytes} bytes"),
        Format::Json => println!("{{\"lines\":{lines},\"words\":{words},\"bytes\":{bytes}}}"),
    }
    Ok(())
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    match run(&cli) {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("tally: {err:#}");
            ExitCode::from(2)
        }
    }
}

The doc comments are the help text. clap reads /// comments on the struct and its fields and prints them, which keeps the documentation next to the thing it documents and out of a separate string that drifts. ValueEnum on Format gives you validation and a list of allowed values for free, with no hand-written match on a string.

A few choices in main deserve attention. Cli::parse() exits the process on bad arguments, printing the error and the usage hint and returning status 2, so the rest of the program only sees valid input. The real work lives in run, which returns Result<()>, and main is a thin shell that converts the result to an exit code. That split makes run easy to test and keeps the exit logic in one place.

What it looks like to use

Running it shows the contract from the outside. The binary here is named tally.

Text
$ tally --help
Count words, lines and bytes in files or standard input

Usage: tally [OPTIONS] [FILES]...

Arguments:
  [FILES]...  Files to read. Reads standard input when none are given

Options:
  -t, --top <N>          Show the N most frequent words instead of totals
      --format <FORMAT>  Output format [default: text] [possible values: text, json]
  -i, --ignore-case      Ignore case when counting words
  -h, --help             Print help
  -V, --version          Print version

$ tally notes.txt
3 lines, 8 words, 31 bytes

$ tally --format json notes.txt
{"lines":3,"words":8,"bytes":31}

$ tally -t 2 -i notes.txt
    3 the
    1 cat

$ echo "a b" | tally
1 lines, 2 words, 4 bytes

Notice that the -t short flag was never declared by hand: #[arg(short, long)] derives -t and --top from the field name. If two fields start with the same letter, clap panics in debug builds at startup, which is a feature: the conflict is found the first time you run the program, not when a user types it. A one-line test, Cli::command().debug_assert(), runs the same check in CI so a conflict never reaches a release.

The failure paths

Two failures matter, and both exit with status 2 and write to standard error:

Text
$ tally /nope.txt
tally: reading /nope.txt: No such file or directory (os error 2)
$ echo $?
2

$ tally --format yaml notes.txt
error: invalid value 'yaml' for '--format <FORMAT>'
  [possible values: text, json]

For more information, try '--help'.

The first message comes from anyhow. The raw operating system error says only "No such file or directory", which is useless in a loop over fifty files. with_context(|| format!("reading {}", path.display())) adds the path, and the {err:#} format prints the whole chain on one line, context: cause. This is the same pattern as in error handling with anyhow: add context where you know what you were doing, print the chain where you decide what to do about it.

The second message is clap rejecting the value before run is ever called. The user is told what is allowed, which saves a trip to the documentation.

A test that runs the real binary

Unit tests of run are fine, but the contract lives in the process boundary: exit codes, which stream got the text, whether stdin works. Cargo builds the binary for integration tests and tells you where it is through the CARGO_BIN_EXE_<name> variable, so a test can run the genuine executable with only the standard library.

tests/cli.rsrust
use std::io::Write;
use std::process::{Command, Stdio};

fn tally() -> Command {
    Command::new(env!("CARGO_BIN_EXE_tally"))
}

#[test]
fn counts_stdin() {
    let mut child = tally().stdin(Stdio::piped()).stdout(Stdio::piped()).spawn().unwrap();
    child.stdin.take().unwrap().write_all(b"one two\nthree\n").unwrap();
    let out = child.wait_with_output().unwrap();
    assert!(out.status.success());
    assert_eq!(String::from_utf8_lossy(&out.stdout), "2 lines, 3 words, 14 bytes\n");
}

#[test]
fn missing_file_exits_2_and_names_the_file_on_stderr() {
    let out = tally().arg("/definitely/not/here.txt").output().unwrap();
    assert_eq!(out.status.code(), Some(2));
    assert!(out.stdout.is_empty());
    assert!(String::from_utf8_lossy(&out.stderr).contains("/definitely/not/here.txt"));
}

#[test]
fn unknown_format_is_rejected_by_clap() {
    let out = tally().args(["--format", "yaml"]).output().unwrap();
    assert_eq!(out.status.code(), Some(2));
    assert!(String::from_utf8_lossy(&out.stderr).contains("possible values: text, json"));
}
Text
running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s

Each test pins one clause of the contract. counts_stdin proves the pipe works. The second proves the exit code, that nothing leaked onto standard output, and that the message names the file. The third proves the parser error reaches the user. These are the three regressions that break scripts that use your tool, and they are the ones a unit test cannot see. For richer assertions, the assert_cmd and predicates crates add fluent matchers, but the dependency-free version above does the job.

Make it pleasant: completions and a man page

Two small additions separate a tool people try from a tool people keep. Shell completion lets users tab-complete your flags, and a man page makes man tally work. Both are generated from the same Cli definition, so they cannot drift from the real options.

src/bin/gen.rsrust
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};

#[derive(Parser)]
#[command(name = "tally", version, about = "Count words, lines and bytes")]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand)]
enum Command {
    /// Print a shell completion script.
    Completions { shell: Shell },
    /// Print the man page in roff format.
    Man,
}

fn main() -> std::io::Result<()> {
    let cli = Cli::parse();
    let mut cmd = Cli::command();
    match cli.command {
        Some(Command::Completions { shell }) => generate(shell, &mut cmd, "tally", &mut std::io::stdout()),
        Some(Command::Man) => clap_mangen::Man::new(cmd).render(&mut std::io::stdout())?,
        None => println!("nothing to do"),
    }
    Ok(())
}

The first lines of what it prints are real output from this program:

Text
$ gen completions bash | head -4
_tally() {
    local i cur prev opts cmd
    COMPREPLY=()
    if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then

$ gen man | head -4
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH tally 1  "tally 0.1.0"
.SH NAME

You can expose this as a hidden completions subcommand on the real tool, as the example does, or generate the files in a build.rs or a release job and ship them in the archive. Generating them in the release job is cleaner, because the installed binary does not carry a subcommand most users never call, and package maintainers prefer files they can install into the standard directories.

Do not load the whole input

The first version of tally reads every file into one String. That is the simplest thing and it is wrong for a tool whose job is processing text: a 20 GB log needs 20 GB of memory, and the process dies long before it prints a count. The fix is to stream, reading a line at a time through BufRead, which holds memory constant whatever the input size. The version below also handles the case from the checklist, a reader that closes the pipe early.

src/main.rsrust
use std::io::{self, BufRead, BufWriter, ErrorKind, Write};
use std::process::ExitCode;

/// Stream standard input line by line: constant memory, whatever the input size.
fn count(reader: impl BufRead) -> io::Result<(usize, usize)> {
    let (mut lines, mut words) = (0, 0);
    for line in reader.lines() {
        let line = line?;
        lines += 1;
        words += line.split_whitespace().count();
    }
    Ok((lines, words))
}

fn run() -> io::Result<()> {
    let (lines, words) = count(io::stdin().lock())?;
    let mut out = BufWriter::new(io::stdout().lock());
    for i in 0..3 {
        writeln!(out, "pass {i}: {lines} lines, {words} words")?;
    }
    out.flush()
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        // `tally big.txt | head -1` closes the pipe early. That is not an error.
        Err(e) if e.kind() == ErrorKind::BrokenPipe => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("tally: {e}");
            ExitCode::from(2)
        }
    }
}

Feeding it a million lines and cutting the output with head -1 shows both behaviors at once:

Text
$ yes "alpha beta gamma delta" | head -1000000 | tally | head -1
pass 0: 1000000 lines, 4000000 words

Three details here are the difference between a tool and a toy. io::stdin().lock() takes the lock once instead of per line, which matters when you read millions of them. BufWriter batches output so that the three writeln! calls become one system call instead of three; without it, a tool that prints a line per record spends most of its time in the kernel. And the BrokenPipe arm turns the closed pipe into a normal exit. Without that arm, Rust's println! would panic with "failed printing to stdout: Broken pipe" and the user would see a stack trace for doing something perfectly reasonable. If you stream bytes that may not be valid UTF-8, use read_until(b'\n', &mut buf) or split(b'\n') instead of lines(), which returns an error on the first invalid sequence.

Subcommands when the tool grows

One command with flags is right until the second verb appears. When it does, model verbs as an enum, which makes the compiler enforce that every subcommand is handled.

src/main.rsrust
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "tally", version, about)]
struct Cli {
    /// Print what would happen without changing anything.
    #[arg(long, global = true)]
    dry_run: bool,

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

#[derive(Subcommand)]
enum Command {
    /// Count lines, words and bytes.
    Count { files: Vec<std::path::PathBuf> },
    /// Rank the most frequent words.
    Top {
        #[arg(short, long, default_value_t = 10)]
        n: usize,
        files: Vec<std::path::PathBuf>,
    },
}

global = true makes --dry-run valid before or after the subcommand, so tally --dry-run top and tally top --dry-run both work, which is what people try. A match cli.command in main then dispatches, and adding a variant without handling it is a compile error rather than a silent no-op at runtime. For a large tool, give each variant its own module with a run(args, &ctx) function and keep main.rs as a table of contents.

Where configuration comes from

Once a tool has options people set every time, they ask for defaults. Resolve them in a fixed order and document it: command line flag, then environment variable, then config file, then built-in default. A flag always wins because it is the most explicit and the most recent statement of intent. clap supports the second layer directly: add env = "TALLY_FORMAT" to the #[arg] attribute and the flag falls back to the variable when absent, and the generated help lists the variable name. For a config file, read it from the platform's config directory (the dirs crate resolves ~/.config on Linux, ~/Library/Application Support on macOS and %APPDATA% on Windows) and merge it under the flags. Keep the file format boring, TOML, and print the effective configuration from a --debug-config flag, since "why is it using that value" is the question every config system eventually raises.

Versioning, changelogs and what users depend on

Anything a script can observe is part of your interface: flag names, exit codes, the exact text of the default output, the JSON field names. Treat a change to any of them as a breaking change that needs a major version, or avoid it. The practical consequence is to give machine-readable output its own flag, as --format json does here, and promise stability for that output only. Then you are free to improve the human text without breaking a pipeline.

Keep a CHANGELOG.md with one section per release and write entries for readers, not for commits. Before 0.1.0 you may break things freely, which is a good reason to publish early and say so in the README. After 1.0.0, cargo install users and Homebrew users will upgrade silently, so a removed flag is an outage in somebody's cron job.

Install paths users expect

Offer more than one way to get the tool, in order of how many people use each. A tarball from the GitHub release works everywhere and needs nothing else. A one-line install script that downloads the right archive saves people the "which target is mine" decision. A package manager entry (Homebrew tap, a scoop bucket, an AUR package) is where people who already live in a package manager will look. And cargo install tally serves Rust developers, who will not mind the compile. Whichever you offer, put the exact command in the README above the fold, and test the install on a machine that has never seen your repository. The release workflow above produces what all of those need, and each additional channel is a small file that points at the same archives.

Output manners: terminals, pipes and color

Add color only when it helps and only when the output is a terminal. The standard library has std::io::IsTerminal (stable since Rust 1.70), which answers the question directly:

src/main.rsrust
use std::io::{IsTerminal, stdout};

let color = stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();

Honor NO_COLOR, which is the widely adopted convention for letting users opt out, and add a --color auto|always|never flag if you need an override for CI logs that do render color. For progress output on a long task, write it to standard error so that standard output stays machine-readable, and suppress it when standard error is not a terminal either.

Measure and shrink the binary

The default release build is fast but larger than it needs to be. Four settings in Cargo.toml change that, and they are safe for a CLI:

Cargo.tomlTOML
[profile.release]
lto = "fat"
codegen-units = 1
strip = true
panic = "abort"

lto = "fat" lets the optimizer see across crate boundaries and delete what is unused. codegen-units = 1 gives it the whole crate at once, at the cost of slower compiles. strip = true removes symbols and debug information from the shipped file. panic = "abort" drops the unwinding machinery, which is fine when a panic should simply end the process. Measured on this tool on an Apple silicon Mac:

BuildSize in bytes
Default --release1,068,688
Four settings above551,280
Plus opt-level = "z"453,088

The first change halves the file. The size-optimizing level z trims another 18 percent but can slow hot loops, so keep the default opt-level = 3 unless size is a hard requirement, as it can be on embedded targets. Your numbers will differ on Linux, but the ratios will be similar. One caution for panic = "abort": it also changes how tests that rely on catch_unwind behave, and it applies to the whole dependency graph, so do not set it if a dependency needs unwinding.

Cross-platform releases with GitHub Actions

Users will not install a Rust toolchain to try your tool. The release workflow builds the binary for each target on a matching runner, packages it, and attaches it to a GitHub release when you push a version tag. Here is a workflow with a matrix of three targets.

.github/workflows/release.ymlYAML
name: release
on:
  push:
    tags: ['v*']

permissions:
  contents: write

jobs:
  build:
    strategy:
      matrix:
        include:
          - { os: ubuntu-latest,  target: x86_64-unknown-linux-gnu }
          - { os: macos-latest,   target: aarch64-apple-darwin }
          - { os: windows-latest, target: x86_64-pc-windows-msvc }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - run: cargo build --release --target ${{ matrix.target }}
      - name: Package
        shell: bash
        run: |
          name=tally-${{ github.ref_name }}-${{ matrix.target }}
          mkdir "$name"
          cp README.md "$name"/
          cp target/${{ matrix.target }}/release/tally* "$name"/ 2>/dev/null || true
          tar czf "$name.tar.gz" "$name"
      - uses: softprops/action-gh-release@v2
        with:
          files: tally-*.tar.gz

Tag and push to trigger it: git tag v0.1.0 && git push origin v0.1.0. The glob copy picks up tally on Unix and tally.exe on Windows. This hand-rolled workflow teaches what a release is made of, but it leaves the checksums, the installer scripts and the Homebrew formula to you. A tool called cargo-dist generates a more complete workflow, with installers and checksums, from a few lines of configuration; it gets its own treatment in releasing a Rust CLI with cargo dist. Publishing to crates.io with cargo publish gives Rust users cargo install tally, but it compiles from source on their machine, so offer binaries as well.

Pin the toolchain used for releases with a rust-toolchain.toml so a new compiler release does not change your artifacts without a commit that says so, and attach a checksum file so users can verify downloads. The exact binary you ship should be the one you tested: build once in CI, run the integration tests against that artifact, and upload the same file.

The checklist before you tag

  • tally --help and tally --version print sensible text, and the version comes from Cargo.toml (the version attribute does this).
  • Errors go to standard error with a non-zero status, and the message names the thing that failed.
  • It reads standard input when given no files, and exits quietly (status 0) if its output pipe is closed early, as in tally big.txt | head -1. Rust ignores SIGPIPE by default and println! panics on a broken pipe, so a tool that prints a lot should use writeln! and treat ErrorKind::BrokenPipe as success.
  • The integration tests run on all three operating systems in CI.
  • The README shows install, one example and the exit codes.

Where this design stops fitting

A tool with many subcommands, shared global flags and plugins outgrows a single derive struct, and the answer is #[command(subcommand)] with an enum, one module per subcommand, and a shared context struct. A tool whose main job is a long-running interactive session wants a terminal UI instead of flags, and that is a different guide (terminal interfaces with Ratatui). For a one-off script that only you will run, clap is more ceremony than a std::env::args() loop, and a shell script may be shorter still. And if startup time matters, because the tool runs thousands of times in a build step, measure it: clap adds a small amount of parsing time that is invisible interactively and visible in a hot loop, and lexopt or pico-args are lighter if you need to shave it.

Also inRust
Taggedcliclap
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.