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.
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.
cargo add clap_completeuse 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());
}
}
}cargo run -- completions bash > shelf.bash
cargo run -- completions zsh > _shelfThe 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.
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.
cargo add clap_mangenuse 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(())
}cargo run
man -l ./shelf.1Generate manual pages ahead of release or from a build task, rather than on every invocation.
Test a CLI without exiting the test process#
Use try_parse_from to check help, invalid input, and typed values.
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.
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.
cargo add --dev assert_cmd predicatesuse 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"));
}Exact output assertions are useful for your own stable text; match only key fragments when clap’s diagnostic wording may change.
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.
cargo new finder
cd finder
cargo add clap --features derive
cargo add anyhow
cargo add serde --features derive
cargo add serde_jsonuse 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(())
}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 stderrCalling 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.
Common mistakes and quick fixes#
Common argument-parsing and output errors.
| Symptom | Likely cause | Fix |
|---|---|---|
Your program never sees --flag | Cargo parsed it. | Use cargo run -- --flag. |
--name is rejected | The field is positional. | Add #[arg(long)]. |
--item a b rejects b | One named occurrence expects one value. | Repeat --item or set num_args. |
| An option works at root but fails after a subcommand | It is scoped to the root. | Use it before the subcommand or mark it global. |
| A dash-leading filename looks like a flag | Parsing still treats it as an option. | Use -- -file. |
| A JSON pipeline cannot parse output | Logs or banners reached stdout. | Write diagnostics to stderr; serialize data. |
| A secret appears in help or logs | Environment value or parsed struct was displayed. | Hide environment values and avoid debug-printing credentials. |
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.
Help improve CodeRef
Allow Google Analytics to measure page visits and use of the guides? It uses cookies after you accept. Your choice won’t affect access to the site. Privacy notice