clap / Chapter 5 of 5

Shell completion and CLI testing

Generate clap shell completions and man pages, test a CLI with assert_cmd, and follow a complete Rust file-search command example.

7 topicsUpdated
21 / 27

clap_complete for shell completion#

Generate completion scripts from the same command definition used for parsing.

The clap_complete package can generate scripts for Bash, Zsh, Fish, PowerShell, and Elvish. A ValueEnum shell argument restricts the choices; CommandFactory exposes the derived command definition.

ShellAdd the package
cargo add clap_complete
RustAdd a completions subcommand
use std::io;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};

#[derive(Parser)]
#[command(name = "shelf")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    List,
    Completions { shell: Shell },
}

fn main() {
    match Cli::parse().command {
        Command::List => println!("books"),
        Command::Completions { shell } => {
            let mut cmd = Cli::command();
            generate(shell, &mut cmd, "shelf", &mut io::stdout());
        }
    }
}
ShellSave the generated script
cargo run -- completions bash > shelf.bash
cargo run -- completions zsh > _shelf
Installation

The generated file must be installed or sourced according to the user’s shell. Regenerate completion scripts when the CLI’s subcommands or arguments change. ValueHint can suggest files or directories to completion engines.

22 / 27

clap_mangen for manual pages#

Generate a Unix manual page from the command definition.

clap_mangen turns a clap::Command into roff text. The example writes a .1 page for a binary; distribute it with the program and install it in the right manual-page directory.

ShellAdd the package
cargo add clap_mangen
RustGenerate a page from the builder API
use clap::{Arg, Command};
use clap_mangen::Man;
use std::{fs::File, io};

fn main() -> io::Result<()> {
    let cmd = Command::new("shelf")
        .about("Manage a book shelf")
        .arg(Arg::new("book").help("Book to look up"));
    let mut file = File::create("shelf.1")?;
    Man::new(cmd).render(&mut file)?;
    Ok(())
}
ShellPreview locally
cargo run
man -l ./shelf.1
Generate pages during packaging

Generate manual pages ahead of release or from a build task, rather than on every invocation.

23 / 27

Test a CLI without exiting the test process#

Use try_parse_from to check help, invalid input, and typed values.

RustParser tests
use clap::{error::ErrorKind, Parser};

#[derive(Debug, Parser)]
struct Cli {
    #[arg(long)]
    limit: u8,
    input: String,
}

#[test]
fn parses_valid_arguments() {
    let cli = Cli::try_parse_from(["shelf", "--limit", "4", "books.txt"]).unwrap();
    assert_eq!(cli.limit, 4);
    assert_eq!(cli.input, "books.txt");
}

#[test]
fn rejects_invalid_number() {
    let err = Cli::try_parse_from(["shelf", "--limit", "many", "books.txt"]).unwrap_err();
    assert_eq!(err.kind(), ErrorKind::ValueValidation);
}

#[test]
fn help_is_a_display_result() {
    let err = Cli::try_parse_from(["shelf", "--help"]).unwrap_err();
    assert_eq!(err.kind(), ErrorKind::DisplayHelp);
    assert!(err.to_string().contains("--limit"));
}

For a full command, test the process as well: stdout, stderr, exit status, and created files. Parser-only tests cannot detect a broken write path or accidental diagnostics on stdout.

24 / 27

assert_cmd for end-to-end CLI tests#

Run the compiled binary and assert what a user actually sees.

assert_cmd starts a process and checks its exit status and output. Add it as a development dependency. The example assumes your binary is called shelf and prints a greeting for a name.

ShellAdd development dependencies
cargo add --dev assert_cmd predicates
Rusttests/cli.rs
use assert_cmd::prelude::*;
use assert_cmd::cargo::cargo_bin_cmd;

#[test]
fn greeting_goes_to_stdout() {
    let mut cmd = cargo_bin_cmd!("shelf");
    cmd.arg("Ada")
        .assert()
        .success()
        .stdout("Hello, Ada!\n")
        .stderr("");
}

#[test]
fn missing_name_is_a_failure() {
    let mut cmd = cargo_bin_cmd!("shelf");
    cmd.assert().failure().stderr(predicates::str::contains("required"));
}
Match stable behavior

Exact output assertions are useful for your own stable text; match only key fragments when clap’s diagnostic wording may change.

25 / 27

Putting it together: a useful file search CLI#

Combine subcommands, typed choices, file I/O, JSON, and contextual errors in one program.

Search a UTF-8 file and emit matching lines as text or JSON. Results go to stdout; file errors go to stderr through Result.

ShellPackages
cargo new finder
cd finder
cargo add clap --features derive
cargo add anyhow
cargo add serde --features derive
cargo add serde_json
Rustsrc/main.rs
use std::{fs, io::{self, Write}, path::PathBuf};
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::Serialize;

#[derive(Parser)]
#[command(version, about = "Search a text file")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Search lines in a file.
    Search(SearchArgs),
    /// Count lines in a file.
    Count { file: PathBuf },
}

#[derive(Args)]
struct SearchArgs {
    /// Text to find.
    pattern: String,
    /// UTF-8 file to read.
    file: PathBuf,
    #[arg(short, long)]
    ignore_case: bool,
    #[arg(long, value_enum, default_value = "text")]
    format: Format,
}

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

#[derive(Serialize)]
struct Match<'a> { line: usize, text: &'a str }

fn read(file: &PathBuf) -> Result<String> {
    fs::read_to_string(file).with_context(|| format!("could not read {}", file.display()))
}

fn main() -> Result<()> {
    match Cli::parse().command {
        Command::Count { file } => {
            println!("{}", read(&file)?.lines().count());
        }
        Command::Search(args) => {
            let contents = read(&args.file)?;
            let needle = if args.ignore_case {
                args.pattern.to_lowercase()
            } else {
                args.pattern.clone()
            };
            let matches: Vec<_> = contents.lines().enumerate()
                .filter_map(|(index, line)| {
                    let haystack = if args.ignore_case {
                        line.to_lowercase()
                    } else {
                        line.to_owned()
                    };
                    haystack.contains(&needle).then_some(Match {
                        line: index + 1, text: line,
                    })
                }).collect();
            let stdout = io::stdout();
            let mut output = stdout.lock();
            match args.format {
                Format::Json => {
                    serde_json::to_writer(&mut output, &matches)?;
                    writeln!(output)?;
                }
                Format::Text => {
                    for hit in matches {
                        writeln!(output, "{}:{}", hit.line, hit.text)?;
                    }
                }
            }
        }
    }
    Ok(())
}
ShellRun the binary
printf 'Rust\nPython\nrust tools\n' > languages.txt
cargo run -- search rust languages.txt --ignore-case
# 1:Rust
# 3:rust tools
cargo run -- search rust languages.txt --format json
# [{"line":3,"text":"rust tools"}]
cargo run -- count languages.txt
# 3
cargo run -- search rust missing.txt  # contextual error on stderr
Case sensitivity

Calling to_lowercase() gives a simple Unicode lowercase comparison, not full Unicode case folding. Choose a dedicated search library when you need advanced matching, encodings, or very large streaming files.

26 / 27

Common mistakes and quick fixes#

Common argument-parsing and output errors.

SymptomLikely causeFix
Your program never sees --flagCargo parsed it.Use cargo run -- --flag.
--name is rejectedThe field is positional.Add #[arg(long)].
--item a b rejects bOne named occurrence expects one value.Repeat --item or set num_args.
An option works at root but fails after a subcommandIt is scoped to the root.Use it before the subcommand or mark it global.
A dash-leading filename looks like a flagParsing still treats it as an option.Use -- -file.
A JSON pipeline cannot parse outputLogs or banners reached stdout.Write diagnostics to stderr; serialize data.
A secret appears in help or logsEnvironment value or parsed struct was displayed.Hide environment values and avoid debug-printing credentials.
27 / 27

Reference links#

API documentation for clap and its companion crates.

Clap derive tutorial · Derive attribute reference · Builder tutorial · clap_complete · clap_mangen · clap_verbosity_flag · anyhow · assert_cmd.

The examples use clap 4-style derive and builder APIs. Use the linked references for the exact release you install.