ZetCode

Rust command-line arguments

last modified November 13, 2026

In this article we show how to work with command-line arguments in Rust.

Command-line arguments are the words typed after the program's name when it is started. The standard library gives access to them through std::env::args, which is enough for small tools. For real command-line interfaces with options, help pages, and error messages, the clap crate is the de facto standard.

When a program is started with Cargo, the arguments for the program come after a double dash: cargo run -q -- arg1 arg2. Everything before the -- is meant for Cargo itself.

The examples were tested with Rust 1.97, the 2024 edition, and clap 4.6. For the clap examples, the name of the Cargo project matches the program name that appears in the help output.

Reading the arguments

The env::args function returns an iterator over the arguments as Strings.

main.rs
use std::env;

fn main() {
    for (i, arg) in env::args().enumerate() {
        println!("{i}: {arg}");
    }
}

We print each argument with its index.

for (i, arg) in env::args().enumerate() {

The first item is the path of the program as it was invoked, here the binary built by Cargo. The shell splits the command line on spaces, so the quoted "b c" arrives as a single argument without the quotes.

$ cargo run -q -- a "b c"
0: target/debug/args
1: a
2: b c

Collecting the arguments

Usually we skip the program name and collect the rest into a vector.

main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().skip(1).collect();

    if args.is_empty() {
        println!("no arguments given");
        return;
    }

    println!("{} arguments", args.len());
    for arg in &args {
        println!("{arg:?} has {} chars", arg.chars().count());
    }
}

The skip(1) call drops the program path.

let args: Vec<String> = env::args().skip(1).collect();

if args.is_empty() {
    println!("no arguments given");
    return;
}

A program must be prepared for missing arguments. Indexing with args[0] on an empty vector would panic.

$ cargo run -q
no arguments given
$ cargo run -q -- x "héllo w"
2 arguments
"x" has 1 chars
"héllo w" has 7 chars

Parsing numbers and exit codes

Arguments are always strings. Numbers have to be parsed, and invalid input should produce an error message and a non-zero exit code.

main.rs
use std::env;
use std::process;

fn main() {
    let args: Vec<String> = env::args().skip(1).collect();

    if args.is_empty() {
        eprintln!("usage: sum NUMBER...");
        process::exit(2);
    }

    let mut total = 0.0;
    for arg in &args {
        match arg.parse::<f64>() {
            Ok(n) => total += n,
            Err(e) => {
                eprintln!("sum: invalid number {arg:?}: {e}");
                process::exit(1);
            }
        }
    }

    println!("{total}");
}

The program adds up the numbers given on the command line.

if args.is_empty() {
    eprintln!("usage: sum NUMBER...");
    process::exit(2);
}

Error messages go to standard error with eprintln!, so they do not mix with the output when it is redirected. By convention, exit code 2 means wrong usage.

Err(e) => {
    eprintln!("sum: invalid number {arg:?}: {e}");
    process::exit(1);
}

A value that is not a number ends the program with exit code 1. The process::exit function terminates immediately, without running destructors of values on the stack.

$ cargo run -q -- 1 2.5 3
6.5
$ cargo run -q -- 1 x 3
sum: invalid number "x": invalid float literal
$ echo $?
1
$ cargo run -q
usage: sum NUMBER...
$ echo $?
2

Matching on arguments

Slice patterns are a concise way to handle simple subcommands.

main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().skip(1).collect();
    let args: Vec<&str> = args.iter().map(String::as_str).collect();

    match args.as_slice() {
        [] | ["help"] => println!("commands: greet NAME, add A B, upper WORD..."),
        ["greet", name] => println!("Hello, {name}!"),
        ["add", a, b] => match (a.parse::<i64>(), b.parse::<i64>()) {
            (Ok(a), Ok(b)) => println!("{a} + {b} = {}", a + b),
            _ => eprintln!("add: both arguments must be integers"),
        },
        ["upper", words @ ..] if !words.is_empty() => {
            println!("{}", words.join(" ").to_uppercase());
        }
        [cmd, ..] => eprintln!("unknown command or wrong arguments: {cmd}"),
    }
}

To match string literals, we convert the Strings into a vector of &str.

match args.as_slice() {
    [] | ["help"] => println!("commands: greet NAME, add A B, upper WORD..."),
    ["greet", name] => println!("Hello, {name}!"),

Each arm describes one valid command line: its length and fixed words. The arm for greet matches only when exactly one name follows.

["upper", words @ ..] if !words.is_empty() => {

The .. rest pattern binds the remaining arguments as a slice. The last arm catches everything else.

$ cargo run -q -- greet Lucia
Hello, Lucia!
$ cargo run -q -- add 2 40
2 + 40 = 42
$ cargo run -q -- upper rust is fast
RUST IS FAST
$ cargo run -q -- greet
unknown command or wrong arguments: greet
$ cargo run -q -- help
commands: greet NAME, add A B, upper WORD...

Parsing options by hand

Options such as -v or -n 3 can be parsed with a loop over the argument iterator.

main.rs
use std::env;
use std::process;

#[derive(Debug, Default)]
struct Config {
    verbose: bool,
    count: u32,
    name: Option<String>,
    files: Vec<String>,
}

fn parse() -> Result<Config, String> {
    let mut cfg = Config { count: 1, ..Default::default() };
    let mut args = env::args().skip(1);

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "-v" | "--verbose" => cfg.verbose = true,
            "-n" | "--count" => {
                let v = args.next().ok_or("-n needs a value")?;
                cfg.count = v.parse().map_err(|_| format!("bad count: {v}"))?;
            }
            "--" => {
                cfg.files.extend(args.by_ref());
            }
            s if s.starts_with("--name=") => cfg.name = Some(s["--name=".len()..].to_string()),
            s if s.starts_with('-') => return Err(format!("unknown option: {s}")),
            _ => cfg.files.push(arg),
        }
    }
    Ok(cfg)
}

fn main() {
    match parse() {
        Ok(cfg) => println!("{cfg:?}"),
        Err(e) => {
            eprintln!("error: {e}");
            process::exit(2);
        }
    }
}

The parse function fills a Config struct and returns an error for invalid input.

"-n" | "--count" => {
    let v = args.next().ok_or("-n needs a value")?;

An option that takes a value pulls the next argument from the same iterator, so the value is not treated as a separate argument.

"--" => {
    cfg.files.extend(args.by_ref());
}

A double dash ends the options. Everything after it is taken as a file name, even if it starts with a dash. The by_ref method lets extend consume the iterator that the loop also uses.

s if s.starts_with("--name=") => cfg.name = Some(s["--name=".len()..].to_string()),

Guards handle the --name=value form and reject unknown options. Even this small parser has many cases; combined short flags such as -vn3 or help text would add many more. That is the job of clap.

$ cargo run -q -- -v -n 3 --name=report a.txt -- -b.txt
Config { verbose: true, count: 3, name: Some("report"), files: ["a.txt", "-b.txt"] }
$ cargo run -q -- -n
error: -n needs a value
$ echo $?
2
$ cargo run -q -- -x
error: unknown option: -x
$ echo $?
2

Non-UTF-8 arguments

On Unix, arguments are arbitrary bytes. env::args panics if an argument is not valid UTF-8; env::args_os does not.

main.rs
use std::env;

fn main() {
    for arg in env::args_os().skip(1) {
        match arg.to_str() {
            Some(s) => println!("valid UTF-8: {s}"),
            None => println!("not UTF-8: {}", arg.to_string_lossy()),
        }
    }

    let args: Vec<String> = env::args().skip(1).collect();
    println!("{args:?}");
}

We pass an argument that contains the byte 0xFF, which never occurs in UTF-8. The shell's printf produces it from the octal escape \377.

for arg in env::args_os().skip(1) {
    match arg.to_str() {

The args_os function returns OsString values. The to_str method returns None when the value is not valid Unicode, and to_string_lossy replaces the invalid bytes with the replacement character.

let args: Vec<String> = env::args().skip(1).collect();

The second loop uses env::args, which panics on the same argument. Programs that take file names should use args_os and convert the values to PathBuf, because file names do not have to be valid UTF-8.

$ cargo run -q -- a "$(printf 'b\377')"
valid UTF-8: a
not UTF-8: b�

thread 'main' (143302) panicked at /rustc/8bab26f4f68e0e26f0bb7960be334d5b520ea452/library/std/src/env.rs:876:51:
called `Result::unwrap()` on an `Err` value: "b\xFF"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ echo $?
101

The clap crate

The clap crate generates a parser from a struct definition. We add it with the derive feature: cargo add clap --features derive.

Cargo.toml
[package]
name = "greet"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4.6.7", features = ["derive"] }

The derive feature enables the Parser derive macro and the #[arg] and #[command] attributes. The project is named after the first program, greet.

A first clap program

Each field of the struct becomes an argument.

main.rs
use clap::Parser;

#[derive(Parser)]
struct Cli {
    name: String,

    #[arg(short, long, default_value_t = 1)]
    count: u8,
}

fn main() {
    let cli = Cli::parse();

    for _ in 0..cli.count {
        println!("Hello, {}!", cli.name);
    }
}

A field without attributes is a positional argument. Because its type is String and not Option<String>, it is required.

#[arg(short, long, default_value_t = 1)]
count: u8,

The short and long attributes turn the field into the options -c and --count, named after the field. The value is parsed into a u8, and the default is used when the option is missing.

let cli = Cli::parse();

The parse method reads env::args_os. On an error, or when help is requested, it prints a message and exits the process; errors use exit code 2. Help is generated automatically.

$ cargo run -q -- Anna
Hello, Anna!
$ cargo run -q -- -c 2 Anna
Hello, Anna!
Hello, Anna!
$ cargo run -q -- --count=3 Boris
Hello, Boris!
Hello, Boris!
Hello, Boris!
$ cargo run -q -- --help
Usage: greet [OPTIONS] <NAME>

Arguments:
  <NAME>  

Options:
  -c, --count <COUNT>  [default: 1]
  -h, --help           Print help
$ cargo run -q
error: the following required arguments were not provided:
  <NAME>

Usage: greet <NAME>

For more information, try '--help'.
$ echo $?
2
$ cargo run -q -- Anna -c 300
error: invalid value '300' for '--count <COUNT>': 300 is not in 0..=255

For more information, try '--help'.
$ echo $?
2

Help text and validation

Doc comments become help text, and value parsers validate input before our code runs.

main.rs
use clap::Parser;
use std::path::PathBuf;

/// Resize an image to the given width
#[derive(Parser, Debug)]
#[command(name = "resize", version, about)]
struct Cli {
    /// Input image file
    input: PathBuf,

    /// Output file; defaults to the input name with a suffix
    #[arg(short, long)]
    output: Option<PathBuf>,

    /// New width in pixels
    #[arg(short, long, value_parser = clap::value_parser!(u32).range(16..=8192))]
    width: u32,

    /// JPEG quality
    #[arg(short, long, default_value_t = 85)]
    quality: u8,
}

fn main() {
    let cli = Cli::parse();

    let output = cli.output.clone().unwrap_or_else(|| {
        let stem = cli.input.file_stem().unwrap_or_default().to_string_lossy();
        cli.input.with_file_name(format!("{stem}-{}.jpg", cli.width))
    });

    println!("{} -> {} (width {}, quality {})", cli.input.display(), output.display(), cli.width, cli.quality);
}

The program pretends to resize an image; it only computes and prints the settings.

/// Resize an image to the given width
#[derive(Parser, Debug)]
#[command(name = "resize", version, about)]

The doc comment on the struct is the description of the program, and the about attribute puts it into the help. The version attribute adds --version with the version from Cargo.toml.

#[arg(short, long, value_parser = clap::value_parser!(u32).range(16..=8192))]
width: u32,

The width is a required option, because its type is not an Option. The value parser rejects values outside the range with a clear message.

input: PathBuf,
output: Option<PathBuf>,

Paths are parsed as PathBuf, so non-UTF-8 file names work. An Option field is an optional argument.

$ cargo run -q -- --help
Resize an image to the given width

Usage: resize [OPTIONS] --width <WIDTH> <INPUT>

Arguments:
  <INPUT>  Input image file

Options:
  -o, --output <OUTPUT>    Output file; defaults to the input name with a suffix
  -w, --width <WIDTH>      New width in pixels
  -q, --quality <QUALITY>  JPEG quality [default: 85]
  -h, --help               Print help
  -V, --version            Print version
$ cargo run -q -- --version
resize 0.1.0
$ cargo run -q -- photo.png -w 800
photo.png -> photo-800.jpg (width 800, quality 85)
$ cargo run -q -- photo.png -w 800 -o small.jpg -q 70
photo.png -> small.jpg (width 800, quality 70)
$ cargo run -q -- photo.png -w 10
error: invalid value '10' for '--width <WIDTH>': 10 is not in 16..=8192

For more information, try '--help'.
$ echo $?
2
$ cargo run -q -- photo.png
error: the following required arguments were not provided:
  --width <WIDTH>

Usage: resize --width <WIDTH> <INPUT>

For more information, try '--help'.
$ echo $?
2

Flags

A bool field is a flag that takes no value. Other actions count repeated flags or turn a default off.

main.rs
use clap::{ArgAction, Parser};

#[derive(Parser)]
#[command(name = "verbose")]
struct Cli {
    #[arg(short, long, action = ArgAction::Count)]
    verbose: u8,

    #[arg(short, long)]
    quiet: bool,

    #[arg(long = "no-color", action = ArgAction::SetFalse)]
    color: bool,
}

fn main() {
    let cli = Cli::parse();

    let level = match (cli.quiet, cli.verbose) {
        (true, _) => "error",
        (false, 0) => "warn",
        (false, 1) => "info",
        (false, 2) => "debug",
        _ => "trace",
    };

    println!("log level: {level}, color: {}", cli.color);
}

The verbosity level grows with each -v.

#[arg(short, long, action = ArgAction::Count)]
verbose: u8,

ArgAction::Count counts the occurrences, so -v -v and -vv both give 2. Short flags can be combined behind one dash.

#[arg(long = "no-color", action = ArgAction::SetFalse)]
color: bool,

With SetFalse the field is true by default and the --no-color flag sets it to false.

$ cargo run -q
log level: warn, color: true
$ cargo run -q -- -v
log level: info, color: true
$ cargo run -q -- -vvv --no-color
log level: trace, color: false
$ cargo run -q -- -q -vv
log level: error, color: true

Multiple values

A Vec field accepts any number of values.

main.rs
use clap::Parser;

#[derive(Parser)]
#[command(name = "tags")]
struct Cli {
    #[arg(required = true)]
    files: Vec<String>,

    #[arg(short, long)]
    tag: Vec<String>,

    #[arg(long, value_delimiter = ',')]
    exclude: Vec<String>,
}

fn main() {
    let cli = Cli::parse();

    println!("files:   {:?}", cli.files);
    println!("tags:    {:?}", cli.tag);
    println!("exclude: {:?}", cli.exclude);
}

A positional Vec collects all remaining arguments.

#[arg(required = true)]
files: Vec<String>,

A Vec is optional by default and may be empty. Here we require at least one file.

#[arg(short, long)]
tag: Vec<String>,

#[arg(long, value_delimiter = ',')]
exclude: Vec<String>,

An option of type Vec can be repeated: each occurrence adds one value. With value_delimiter, one occurrence can also contain several comma-separated values.

$ cargo run -q -- a.txt b.txt -t rust --tag cli --exclude tmp,bak
files:   ["a.txt", "b.txt"]
tags:    ["rust", "cli"]
exclude: ["tmp", "bak"]
$ cargo run -q -- a.txt
files:   ["a.txt"]
tags:    []
exclude: []
$ cargo run -q
error: the following required arguments were not provided:
  <FILES>...

Usage: tags <FILES>...

For more information, try '--help'.
$ echo $?
2

Enumerated values

The ValueEnum derive restricts an option to a fixed set of values.

main.rs
use clap::{Parser, ValueEnum};

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

#[derive(Parser)]
#[command(name = "export")]
struct Cli {
    #[arg(short, long, value_enum, default_value_t = Format::Text)]
    format: Format,
}

fn main() {
    let cli = Cli::parse();
    let rows = [("Anna", 82), ("Boris", 95)];

    match cli.format {
        Format::Text => rows.iter().for_each(|(n, s)| println!("{n:<6}{s:>4}")),
        Format::Csv => {
            println!("name,score");
            rows.iter().for_each(|(n, s)| println!("{n},{s}"));
        }
        Format::Json => {
            let items: Vec<String> = rows
                .iter()
                .map(|(n, s)| format!("{{\"name\":\"{n}\",\"score\":{s}}}"))
                .collect();
            println!("[{}]", items.join(","));
        }
    }
}

The output format can be text, CSV, or JSON.

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

The variant names are converted to lowercase values. The help and error messages list the possible values automatically.

match cli.format {
    Format::Text => rows.iter().for_each(|(n, s)| println!("{n:<6}{s:>4}")),

In our code we work with a Rust enum instead of comparing strings, and the compiler checks that every format is handled.

$ cargo run -q
Anna    82
Boris   95
$ cargo run -q -- -f csv
name,score
Anna,82
Boris,95
$ cargo run -q -- --format json
[{"name":"Anna","score":82},{"name":"Boris","score":95}]
$ cargo run -q -- -f yaml
error: invalid value 'yaml' for '--format <FORMAT>'
  [possible values: text, csv, json]

For more information, try '--help'.
$ echo $?
2

Subcommands

Tools like git or cargo have subcommands, each with its own arguments. Clap derives them from an enum.

main.rs
use clap::{Parser, Subcommand};

/// A minimal task manager
#[derive(Parser)]
#[command(name = "todo", version, about)]
struct Cli {
    /// Use this task list instead of the default one
    #[arg(short, long, global = true, default_value = "tasks.txt")]
    list: String,

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

#[derive(Subcommand)]
enum Command {
    /// Add a new task
    Add {
        text: Vec<String>,
        #[arg(short, long, default_value_t = 2)]
        priority: u8,
    },
    /// List tasks
    #[command(alias = "ls")]
    List {
        #[arg(long)]
        all: bool,
    },
    /// Mark a task as done
    Done { id: u32 },
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Command::Add { text, priority } => {
            println!("[{}] add {:?} with priority {priority}", cli.list, text.join(" "));
        }
        Command::List { all } => {
            println!("[{}] list {} tasks", cli.list, if all { "all" } else { "open" });
        }
        Command::Done { id } => println!("[{}] task {id} done", cli.list),
    }
}

Each variant of the Command enum is a subcommand, and its fields are the subcommand's arguments.

#[command(subcommand)]
command: Command,

The subcommand attribute connects the enum to the main struct. Since the field is not an Option, a subcommand is required.

#[arg(short, long, global = true, default_value = "tasks.txt")]
list: String,

A global option can be given before or after the subcommand.

/// List tasks
#[command(alias = "ls")]
List {

The variant List becomes the subcommand list, with ls as a hidden alias. Clap also adds a help subcommand.

$ cargo run -q -- --help
A minimal task manager

Usage: todo [OPTIONS] <COMMAND>

Commands:
  add   Add a new task
  list  List tasks
  done  Mark a task as done
  help  Print this message or the help of the given subcommand(s)

Options:
  -l, --list <LIST>  Use this task list instead of the default one [default: tasks.txt]
  -h, --help         Print help
  -V, --version      Print version
$ cargo run -q -- add buy milk -p 1
[tasks.txt] add "buy milk" with priority 1
$ cargo run -q -- ls --all -l work.txt
[work.txt] list all tasks
$ cargo run -q -- done 3
[tasks.txt] task 3 done
$ cargo run -q -- done
error: the following required arguments were not provided:
  <ID>

Usage: todo done <ID>

For more information, try '--help'.
$ echo $?
2

The builder API

Besides the derive API, clap has a builder API that constructs the parser at run time.

main.rs
use clap::{Arg, ArgAction, Command, value_parser};

fn main() {
    let matches = Command::new("builder")
        .about("Repeats a word")
        .arg(Arg::new("word").required(true))
        .arg(
            Arg::new("times")
                .short('t')
                .long("times")
                .value_parser(value_parser!(usize))
                .default_value("2"),
        )
        .arg(Arg::new("upper").short('u').action(ArgAction::SetTrue))
        .get_matches();

    let word = matches.get_one::<String>("word").unwrap();
    let times = *matches.get_one::<usize>("times").unwrap();
    let upper = matches.get_flag("upper");

    let w = if upper { word.to_uppercase() } else { word.clone() };
    println!("{}", vec![w; times].join(" "));
}

Arguments are defined with Arg::new and configured with method calls.

Arg::new("times")
    .short('t')
    .long("times")
    .value_parser(value_parser!(usize))
    .default_value("2"),

The value parser converts the string into a usize; the default is given as a string and parsed the same way.

let times = *matches.get_one::<usize>("times").unwrap();
let upper = matches.get_flag("upper");

Values are looked up by their ID. The type in get_one must match the value parser, which is checked only at run time; the derive API avoids this. The builder API is useful when the arguments are not known at compile time.

$ cargo run -q -- hello
hello hello
$ cargo run -q -- hello -t 3 -u
HELLO HELLO HELLO

Source

std::env::args documentation

The Rust Programming Language: Accepting Command Line Arguments

clap derive tutorial

In this article we have worked with command-line arguments in Rust. Cargo itself is covered in Rust Cargo, and parsing strings in Rust type conversion.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.