Skip to content
AgileLogics
6 min read

Building Command-Line Apps

Prerequisites: Writing tests

Command-line tools are one of the most common things people build in Rust: the binaries start instantly, have no runtime to install, and are easy to ship as a single file. A CLI program needs three things: read its arguments, report errors on stderr, and exit with a meaningful exit code (0 for success, non-zero for failure) so scripts can react to it.

Syntax

std::env::args() returns an iterator over the arguments as Strings. The first item is the program's own path, so you usually skip it.

use std::env;
use std::process;

fn main() {
    let args: Vec<String> = env::args().skip(1).collect();
    if args.is_empty() {
        eprintln!("usage: greet <name> [times]");   // eprintln! writes to stderr
        process::exit(2);                           // exit immediately with code 2
    }
    println!("got {} argument(s)", args.len());
}

process::exit(code) ends the program right away, without running destructors, so call it only once there is nothing left to clean up — usually at the top of main. When main returns Err, Rust exits with code 1.

Example

Parsing arguments by hand, in src/main.rs of a package named greet:

use std::env;
use std::process;

struct Config {
    name: String,
    times: u32,
}

fn parse_args(args: &[String]) -> Result<Config, String> {
    match args {
        [name] => Ok(Config { name: name.clone(), times: 1 }),
        [name, times] => {
            let times = times
                .parse()
                .map_err(|_| format!("'{times}' is not a valid number"))?;
            Ok(Config { name: name.clone(), times })
        }
        _ => Err(String::from("usage: greet <name> [times]")),
    }
}

fn main() {
    let args: Vec<String> = env::args().skip(1).collect();
    let config = parse_args(&args).unwrap_or_else(|err| {
        eprintln!("error: {err}");
        process::exit(2);
    });

    for i in 1..=config.times {
        println!("{i}: Hello, {}!", config.name);
    }
}

Arguments after -- are passed to your program rather than to Cargo:

cargo run -- Ada 2

Expected output

1: Hello, Ada!
2: Hello, Ada!

cargo run -- Ada two prints error: 'two' is not a valid number to stderr and exits with status 2.

Using clap

Hand-written parsing gets tedious once you want flags, defaults and a --help page. clap is the most widely used argument parser. With its derive API you describe the arguments as a struct, and doc comments become the help text.

cargo add clap --features derive
use clap::Parser;

/// Print a friendly greeting.
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    /// Who to greet
    name: String,

    /// How many times to print the greeting
    #[arg(short = 'n', long, default_value_t = 1)]
    times: u8,

    /// Print in upper case
    #[arg(short, long)]
    shout: bool,
}

fn main() {
    let args = Args::parse(); // on bad input: prints an error and exits with code 2

    let mut line = format!("Hello, {}!", args.name);
    if args.shout {
        line = line.to_uppercase();
    }
    for _ in 0..args.times {
        println!("{line}");
    }
}
  • A plain field (name) is a required positional argument.
  • #[arg(short, long)] creates -s / --shout; a bool field is a flag that is true when present.
  • default_value_t gives a default; Option<T> makes an argument optional.
  • clap converts each value to the field's type and reports a clear error if that fails.

Running cargo run -- Ada -n 2 --shout prints:

HELLO, ADA!
HELLO, ADA!

And cargo run -- --help prints the generated help page (the program name in the Usage line comes from how the binary was invoked, so on Windows it may show as greet.exe):

Print a friendly greeting

Usage: greet [OPTIONS] <NAME>

Arguments:
  <NAME>  Who to greet

Options:
  -n, --times <TIMES>  How many times to print the greeting [default: 1]
  -s, --shout          Print in upper case
  -h, --help           Print help
  -V, --version        Print version

Common mistakes

  • Forgetting that args[0] is the program path, then treating it as the first real argument. Use .skip(1).
  • Indexing args[1] without checking the length. It panics with index out of bounds when the user passes nothing; match on the slice or check len() first.
  • Printing errors with println!. Errors belong on stderr (eprintln!), so they are still visible when normal output is redirected to a file.
  • Exiting with code 0 after an error. Scripts and CI rely on a non-zero code to notice failures.
  • Using clap's derive attributes without the derive feature: error: cannot find derive macro `Parser` in this scope.
  • Running cargo run --shout and wondering why Cargo complains about an unexpected argument. Put your program's arguments after --.

Practice

Using clap, write a tool sum that takes one or more integers as positional arguments (Vec<i64>, marked #[arg(required = true)]) and a --mean flag. It prints the sum, or the mean with two decimals when --mean is given. cargo run -- 3 4 8 --mean should print mean = 5.00.

Show solution
use clap::Parser;

/// Add up numbers.
#[derive(Parser)]
struct Args {
    /// Numbers to add
    #[arg(required = true)]
    numbers: Vec<i64>,

    /// Print the mean instead of the sum
    #[arg(long)]
    mean: bool,
}

fn main() {
    let args = Args::parse();
    let total: i64 = args.numbers.iter().sum();
    if args.mean {
        let mean = total as f64 / args.numbers.len() as f64;
        println!("mean = {mean:.2}");
    } else {
        println!("sum = {total}");
    }
}
mean = 5.00