Guide contents

Lesson 55 of 94

Command-Line Arguments, Environment & Processes

A command-line program talks to the outside world through four channels: its arguments, its environment variables, its standard streams (the previous lesson), and its exit code. It may also start other programs. This lesson covers the rest of that list, first with the standard library and then with clap, the argument parser almost every Rust command-line tool uses.

Task Rust
Read the arguments std::env::args()
Parse arguments clap
Exit with a code return ExitCode::from(2), or process::exit(2)
Read an environment variable env::var("HOME")
Run another program std::process::Command
Wait and get the exit status child.wait() / status.code()
Pipe input to a child .stdin(Stdio::piped())

Part 1: Arguments and exit codes

1. Reading the arguments

std::env::args() returns an iterator over the arguments as Strings. The first item is the program itself, so the real arguments start at index 1.

Rust
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("program: {}", args[0]);
    for (i, arg) in args.iter().enumerate().skip(1) {
        println!("argument {i}: {arg}");
    }
}
Text
$ cargo run -- report.csv --verbose
program: target/debug/demo
argument 1: report.csv
argument 2: --verbose

With cargo run, everything after -- goes to your program rather than to Cargo. env::args() panics if an argument isn't valid Unicode; env::args_os() returns OsStrings instead, for programs that must accept any file name.

2. Parsing arguments by hand

For a tool with one or two options, you can parse by hand. Take the arguments as a parameter rather than calling env::args() inside, so the parser can be tested with any list.

Rust
#[derive(Debug)]
struct Config {
    pattern: String,
    files: Vec<String>,
    ignore_case: bool,
    max_results: Option<usize>,
}

const USAGE: &str = "usage: search [-i] [--max N] PATTERN FILE...";

fn parse_args(args: impl IntoIterator<Item = String>) -> Result<Config, String> {
    let mut args = args.into_iter().skip(1); // skip the program name
    let mut positional = Vec::new();
    let (mut ignore_case, mut max_results) = (false, None);

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "-i" | "--ignore-case" => ignore_case = true,
            "--max" => {
                let value = args.next().ok_or("--max needs a value")?;
                max_results = Some(value.parse().map_err(|_| format!("--max: {value:?} is not a number"))?);
            }
            flag if flag.starts_with('-') => return Err(format!("unknown option {flag}\n{USAGE}")),
            _ => positional.push(arg),
        }
    }

    let mut positional = positional.into_iter();
    let pattern = positional.next().ok_or(USAGE)?;
    let files: Vec<String> = positional.collect();
    if files.is_empty() {
        return Err(format!("no files given\n{USAGE}"));
    }
    Ok(Config { pattern, files, ignore_case, max_results })
}

fn main() {
    // The real program: parse_args(std::env::args())
    let attempts = [
        vec!["search", "-i", "todo", "src/main.rs", "src/lib.rs"],
        vec!["search", "--max", "5", "error", "app.log"],
        vec!["search", "--max", "lots", "error", "app.log"],
        vec!["search", "--color", "x", "y"],
        vec!["search", "todo"],
    ];
    for attempt in attempts {
        match parse_args(attempt.into_iter().map(String::from)) {
            Ok(config) => println!("{config:?}"),
            Err(e) => println!("error: {e}"),
        }
    }
}
Output
Config { pattern: "todo", files: ["src/main.rs", "src/lib.rs"], ignore_case: true, max_results: None }
Config { pattern: "error", files: ["app.log"], ignore_case: false, max_results: Some(5) }
error: --max: "lots" is not a number
error: unknown option --color
usage: search [-i] [--max N] PATTERN FILE...
error: no files given
usage: search [-i] [--max N] PATTERN FILE...

This gets tedious quickly: no --max=5 form, no combined flags like -iv, no help text. That's what clap is for (example 5).

3. Exit codes

By convention, exit code 0 means success and anything else means failure; tools such as grep give different codes different meanings. Return std::process::ExitCode from main to choose one. Returning Err from a main that returns Result exits with code 1, after printing the error.

Rustexits with an error
use std::process::ExitCode;

fn check(config: &str) -> Result<(), String> {
    if config.contains("port") { Ok(()) } else { Err(String::from("no port configured")) }
}

fn main() -> ExitCode {
    match check("host = example.com") {
        Ok(()) => {
            println!("configuration ok");
            ExitCode::SUCCESS
        }
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::from(2) // 2: invalid configuration
        }
    }
}
Output
error: no port configured

In a shell, echo $? right after the program shows the code: 2 here.

4. process::exit skips cleanup

std::process::exit ends the program immediately, like System.exit, from anywhere. Unlike returning from main, it doesn't run any cleanup for values still in scope, so a BufWriter that hasn't been flushed loses its contents:

Rustexits with an error
use std::io::{self, BufWriter, Write};
use std::process;

fn main() {
    let mut out = BufWriter::new(io::stdout());
    writeln!(out, "this line never appears").unwrap();
    process::exit(3); // `out` is never dropped, so its buffer is never flushed
}

This program prints nothing at all. Prefer returning an ExitCode from main, and use process::exit only when there's nothing left to clean up.

Part 2: clap

5. Declaring the arguments as a struct

clap's derive API turns a struct into an argument parser: each field is an argument, its type says how to parse it, and attributes add short and long names and defaults. Add it with cargo add clap --features derive.

Rust
use clap::Parser;
use std::path::PathBuf;

/// Resize every image in a folder.
#[derive(Parser, Debug)]
#[command(name = "resize", version = "1.2.0")]
struct Cli {
    /// Folder containing the images
    input: PathBuf,

    /// Target width in pixels
    #[arg(short, long, default_value_t = 800)]
    width: u32,

    /// Overwrite files that already exist
    #[arg(short, long)]
    force: bool,

    /// Where to write the results (default: next to the originals)
    #[arg(short, long)]
    output: Option<PathBuf>,
}

fn main() {
    // A real program calls Cli::parse(), which reads std::env::args().
    let cli = Cli::parse_from(["resize", "photos", "--width", "1024", "-f"]);
    println!("{cli:?}");

    let cli = Cli::parse_from(["resize", "--output=small", "screenshots"]);
    println!("{cli:?}");
}
Output
Cli { input: "photos", width: 1024, force: true, output: None }
Cli { input: "screenshots", width: 800, force: false, output: Some("small") }

A field without an attribute is a positional argument; bool fields are flags; Option fields are optional; everything else is required unless it has a default. Values are parsed with FromStr, so any type that implements it works as a field.

6. Help text for free

The doc comments become the help text, and --help and --version are handled automatically. In a real program, Cli::parse() prints the help and exits; here try_parse_from returns it as an error value so the example can print it.

Rust
use clap::Parser;
use std::path::PathBuf;

/// Resize every image in a folder.
#[derive(Parser, Debug)]
#[command(name = "resize", version = "1.2.0")]
struct Cli {
    /// Folder containing the images
    input: PathBuf,

    /// Target width in pixels
    #[arg(short, long, default_value_t = 800)]
    width: u32,

    /// Overwrite files that already exist
    #[arg(short, long)]
    force: bool,
}

fn main() {
    match Cli::try_parse_from(["resize", "--help"]) {
        Ok(cli) => println!("{cli:?}"),
        Err(e) => print!("{e}"),
    }
}
Output
Resize every image in a folder

Usage: resize [OPTIONS] <INPUT>

Arguments:
  <INPUT>  Folder containing the images

Options:
  -w, --width <WIDTH>  Target width in pixels [default: 800]
  -f, --force          Overwrite files that already exist
  -h, --help           Print help
  -V, --version        Print version

7. Helpful errors for bad input

Missing arguments, unknown options, and values that don't parse all produce an error that says what's wrong and how to call the program correctly. Cli::parse() prints it to stderr and exits with code 2; try_parse_from hands it to you.

Rust
use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "resize")]
struct Cli {
    input: String,
    #[arg(short, long, default_value_t = 800)]
    width: u32,
}

fn main() {
    for args in [vec!["resize"], vec!["resize", "photos", "--width", "wide"], vec!["resize", "photos", "--hieght", "5"]] {
        match Cli::try_parse_from(&args) {
            Ok(cli) => println!("{cli:?}"),
            Err(e) => println!("{args:?} -> {:?}:\n{e}", e.kind()),
        }
    }
}
Output
["resize"] -> MissingRequiredArgument:
error: the following required arguments were not provided:
  <INPUT>

Usage: resize <INPUT>

For more information, try '--help'.

["resize", "photos", "--width", "wide"] -> ValueValidation:
error: invalid value 'wide' for '--width <WIDTH>': invalid digit found in string

For more information, try '--help'.

["resize", "photos", "--hieght", "5"] -> UnknownArgument:
error: unexpected argument '--hieght' found

  tip: to pass '--hieght' as a value, use '-- --hieght'

Usage: resize <INPUT>

For more information, try '--help'.

8. Choices, lists, and validated numbers

A #[derive(ValueEnum)] enum restricts an option to a fixed set of values, and the help lists them. A Vec field collects repeated options. value_parser!(u16).range(...) validates a number while parsing it.

Rust
use clap::{Parser, ValueEnum};

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

#[derive(Parser, Debug)]
#[command(name = "report")]
struct Cli {
    /// Output format
    #[arg(long, value_enum, default_value_t = Format::Table)]
    format: Format,

    /// Only include these tags (repeatable)
    #[arg(long = "tag")]
    tags: Vec<String>,

    /// How many days to include
    #[arg(long, default_value_t = 7, value_parser = clap::value_parser!(u16).range(1..=365))]
    days: u16,
}

fn main() {
    let ok = Cli::parse_from(["report", "--format", "json", "--tag", "api", "--tag", "db", "--days", "30"]);
    println!("{ok:?}");
    println!("{:?}", Cli::parse_from(["report"]));

    for bad in [vec!["report", "--format", "xml"], vec!["report", "--days", "0"]] {
        if let Err(e) = Cli::try_parse_from(&bad) {
            println!("{}", e.to_string().lines().next().unwrap_or(""));
        }
    }
}
Output
Cli { format: Json, tags: ["api", "db"], days: 30 }
Cli { format: Table, tags: [], days: 7 }
error: invalid value 'xml' for '--format <FORMAT>'
error: invalid value '0' for '--days <DAYS>': 0 is not in 1..=365

9. Subcommands

Tools like git and cargo have subcommands, each with its own arguments. In clap they're an enum: one variant per subcommand, with its arguments as fields. global = true makes an option usable with any subcommand.

Rust
use clap::{Parser, Subcommand};

#[derive(Parser, Debug)]
#[command(name = "todo")]
struct Cli {
    /// Print more details
    #[arg(short, long, global = true)]
    verbose: bool,

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

#[derive(Subcommand, Debug)]
enum Command {
    /// Add a task
    Add {
        title: String,
        #[arg(short, long, default_value_t = 3)]
        priority: u8,
    },
    /// List tasks
    List {
        /// Include finished tasks
        #[arg(long)]
        all: bool,
    },
    /// Mark a task as done
    Done { id: u32 },
}

fn run(cli: Cli) -> String {
    let detail = if cli.verbose { " (verbose)" } else { "" };
    match cli.command {
        Command::Add { title, priority } => format!("added {title:?} with priority {priority}{detail}"),
        Command::List { all } => format!("listing {} tasks{detail}", if all { "all" } else { "open" }),
        Command::Done { id } => format!("task {id} done{detail}"),
    }
}

fn main() {
    for args in [vec!["todo", "add", "write report", "-p", "1"], vec!["todo", "list", "--all", "-v"], vec!["todo", "done", "7"]] {
        println!("{}", run(Cli::parse_from(args)));
    }

    match Cli::try_parse_from(["todo", "--help"]) {
        Ok(_) => {}
        Err(e) => print!("{e}"),
    }
}
Output
added "write report" with priority 1
listing all tasks (verbose)
task 7 done
Usage: todo [OPTIONS] <COMMAND>

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

Options:
  -v, --verbose  Print more details
  -h, --help     Print help

Part 3: The environment

10. Reading environment variables

env::var returns a Result: Err if the variable isn't set (or isn't valid Unicode). For configuration, read variables in one place and pass the values on, and give the reading function a lookup closure, so tests don't depend on the real environment.

Rust
use std::collections::HashMap;
use std::env;

#[derive(Debug)]
struct Config {
    port: u16,
    database_url: String,
    debug: bool,
}

fn config_from(get: impl Fn(&str) -> Option<String>) -> Result<Config, String> {
    let port = match get("APP_PORT") {
        Some(raw) => raw.parse().map_err(|_| format!("APP_PORT: {raw:?} is not a port number"))?,
        None => 8080,
    };
    let database_url = get("DATABASE_URL").ok_or("DATABASE_URL must be set")?;
    let debug = get("APP_DEBUG").is_some_and(|v| v == "1" || v == "true");
    Ok(Config { port, database_url, debug })
}

fn main() {
    println!("PATH is set: {}", env::var("PATH").is_ok());
    println!("{:?}", env::var("SURELY_NOT_SET_42"));

    // The real program: config_from(|key| env::var(key).ok())
    let dev = HashMap::from([("DATABASE_URL", "postgres://localhost/shop"), ("APP_DEBUG", "1")]);
    println!("{:?}", config_from(|key| dev.get(key).map(|v| v.to_string())));

    let broken = HashMap::from([("DATABASE_URL", "postgres://db/shop"), ("APP_PORT", "http")]);
    println!("{:?}", config_from(|key| broken.get(key).map(|v| v.to_string())));
    println!("{:?}", config_from(|_| None));
}
Output
PATH is set: true
Err(NotPresent)
Ok(Config { port: 8080, database_url: "postgres://localhost/shop", debug: true })
Err("APP_PORT: \"http\" is not a port number")
Err("DATABASE_URL must be set")

Changing your own process's environment with env::set_var is unsafe since the 2024 edition, because another thread could be reading it at the same moment. Set variables for the programs you start instead, as in example 13. clap can also fall back to an environment variable for an option: #[arg(long, env = "APP_PORT")], with the env feature enabled.

Part 4: Running other programs

11. Running a command and capturing its output

Command builds and runs another program. .output() runs the program to completion and collects its exit status, stdout, and stderr. The output is bytes, since programs don't have to print valid UTF-8.

Rust
use std::io;
use std::process::Command;

fn main() -> io::Result<()> {
    let output = Command::new("echo").args(["hello", "from", "a child process"]).output()?;
    println!("status: {}", output.status);
    println!("stdout: {:?}", String::from_utf8_lossy(&output.stdout));
    Ok(())
}
Output
status: exit status: 0
stdout: "hello from a child process\n"

The examples in this part run Unix commands (echo, sh, sort), which Linux and macOS have; on Windows, run cmd /C ... or PowerShell commands instead.

12. Exit status, stderr, and missing programs

A program that runs but fails is not an Err: .output() succeeds, and you check status.success() or status.code(). Err means the program couldn't be started at all, for example because it doesn't exist.

Rust
use std::io;
use std::process::Command;

fn main() -> io::Result<()> {
    let output = Command::new("sh").args(["-c", "echo partial result; echo 'disk full' >&2; exit 3"]).output()?;
    println!("success: {}, code: {:?}", output.status.success(), output.status.code());
    println!("stdout: {:?}", String::from_utf8_lossy(&output.stdout));
    println!("stderr: {:?}", String::from_utf8_lossy(&output.stderr));

    match Command::new("no-such-program").output() {
        Ok(out) => println!("ran: {}", out.status),
        Err(e) => println!("could not start: {:?}", e.kind()),
    }
    Ok(())
}
Output
success: false, code: Some(3)
stdout: "partial result\n"
stderr: "disk full\n"
could not start: NotFound

code() returns an Option because on Unix a process killed by a signal has no exit code.

13. Environment and working directory for the child

env sets a variable for the child only, current_dir sets the directory it starts in, and status() runs it with its output going straight to your own stdout, like ProcessBuilder.inheritIO().

Rust
use std::fs;
use std::io;
use std::process::Command;

fn main() -> io::Result<()> {
    fs::create_dir_all("workspace")?;
    let status = Command::new("sh")
        .args(["-c", r#"echo "$GREETING from $(basename "$PWD")""#])
        .env("GREETING", "hello")
        .current_dir("workspace")
        .status()?;
    println!("child finished: {status}");
    Ok(())
}
Output
hello from workspace
child finished: exit status: 0

Arguments are passed to the program directly, not through a shell, so spaces and quotes in them need no escaping. This example asks for a shell explicitly (sh -c) only because it uses shell features.

14. Sending input to a child

Stdio::piped() connects a stream of the child to your program. Write to the child's stdin, close it so the child sees the end of the input, then collect the output.

Rust
use std::io::{self, Write};
use std::process::{Command, Stdio};

fn sort_lines(lines: &[&str]) -> io::Result<String> {
    let mut child = Command::new("sort").stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?;
    {
        let mut stdin = child.stdin.take().expect("stdin was piped");
        for line in lines {
            writeln!(stdin, "{line}")?;
        }
    } // `stdin` is dropped here, which closes the pipe: `sort` sees the end of its input
    let output = child.wait_with_output()?;
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

fn main() -> io::Result<()> {
    print!("{}", sort_lines(&["pear", "apple", "fig", "banana"])?);
    Ok(())
}
Output
apple
banana
fig
pear

Writing all the input before reading any output works when the child reads everything first, as sort does. With a child that produces output while it reads, and lots of data, both pipes can fill up and each process waits for the other; write the input from a separate thread in that case (Threads comes later in the course).

15. Streaming a child's output as it runs

.output() waits until the child is done. To react to each line as it's printed, for example to show build progress, pipe stdout, wrap it in a BufReader, and read lines while the child runs.

Rust
use std::io::{self, BufRead, BufReader};
use std::process::{Command, Stdio};

fn main() -> io::Result<()> {
    let mut child = Command::new("sh")
        .args(["-c", "for step in fetch build test; do echo \"running $step\"; sleep 0.05; done"])
        .stdout(Stdio::piped())
        .spawn()?;

    let stdout = child.stdout.take().expect("stdout was piped");
    for (i, line) in BufReader::new(stdout).lines().enumerate() {
        println!("[{}] {}", i + 1, line?);
    }
    let status = child.wait()?;
    println!("finished: {status}");
    Ok(())
}
Output
[1] running fetch
[2] running build
[3] running test
finished: exit status: 0

Part 5: Putting it together

16. Realistic example: a wc clone

A small but complete tool: clap for the arguments, files from the previous lessons, results on stdout, problems on stderr, and a non-zero exit code if any file couldn't be read. run takes the arguments and the two output streams as parameters, so the example can show both.

Rust
use clap::Parser;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use std::process::ExitCode;

/// Count lines, words, and bytes.
#[derive(Parser)]
#[command(name = "wc")]
struct Cli {
    /// Count lines only
    #[arg(short, long)]
    lines: bool,

    /// Files to count
    #[arg(required = true)]
    files: Vec<PathBuf>,
}

fn run(args: &[&str], out: &mut impl Write, err: &mut impl Write) -> io::Result<u8> {
    let cli = match Cli::try_parse_from(args) {
        Ok(cli) => cli,
        Err(e) => {
            write!(err, "{e}")?;
            return Ok(2);
        }
    };

    let mut exit_code = 0;
    let (mut total_lines, mut total_words, mut total_bytes) = (0, 0, 0);
    for path in &cli.files {
        let text = match fs::read_to_string(path) {
            Ok(text) => text,
            Err(e) => {
                writeln!(err, "wc: {}: {e}", path.display())?;
                exit_code = 1;
                continue;
            }
        };
        let (lines, words, bytes) = (text.lines().count(), text.split_whitespace().count(), text.len());
        total_lines += lines;
        total_words += words;
        total_bytes += bytes;
        if cli.lines {
            writeln!(out, "{lines:>5} {}", path.display())?;
        } else {
            writeln!(out, "{lines:>5} {words:>5} {bytes:>6} {}", path.display())?;
        }
    }
    if cli.files.len() > 1 && !cli.lines {
        writeln!(out, "{total_lines:>5} {total_words:>5} {total_bytes:>6} total")?;
    }
    Ok(exit_code)
}

fn main() -> io::Result<ExitCode> {
    fs::write("poem.txt", "Roses are red\nViolets are blue\n")?;
    fs::write("notes.txt", "buy milk\n")?;

    // The real program: run(&args, &mut io::stdout(), &mut io::stderr()), with args from env::args().
    let mut out = io::stdout();
    for args in [vec!["wc", "poem.txt", "notes.txt"], vec!["wc", "-l", "poem.txt", "missing.txt"], vec!["wc"]] {
        println!("$ {}", args.join(" "));
        let code = run(&args, &mut out, &mut io::stdout())?; // stderr shown on stdout for this page
        println!("(exit code {code})\n");
    }
    Ok(ExitCode::SUCCESS)
}
Output
$ wc poem.txt notes.txt
    2     6     31 poem.txt
    1     2      9 notes.txt
    3     8     40 total
(exit code 0)

$ wc -l poem.txt missing.txt
    2 poem.txt
wc: missing.txt: No such file or directory (os error 2)
(exit code 1)

$ wc
error: the following required arguments were not provided:
  <FILES>...

Usage: wc <FILES>...

For more information, try '--help'.
(exit code 2)

Running the same checks with cargo run -- -l poem.txt missing.txt in a terminal gives the same text, with the error on stderr and echo $? printing 1.

Summary

  • env::args() yields the arguments, starting with the program name. Take arguments as a parameter so parsing can be tested.
  • Return ExitCode from main to control the exit code; process::exit skips cleanup such as flushing buffers.
  • clap's derive API describes arguments as a struct: positional fields, #[arg(short, long)] options, defaults, Option and Vec fields, ValueEnum choices, and subcommand enums. It generates --help, --version, and error messages.
  • env::var reads environment variables. Read them in one function with an injectable lookup; set variables on child processes, not your own.
  • Command starts programs: .output() captures everything, .status() shares your terminal, and Stdio::piped() connects streams for writing input or reading output as it arrives. A failing program returns a non-success status, not an Err.

This completes the Files, I/O & the Command Line section. The grep-Style Search Tool project in the Practical Examples course uses everything from it to build a small grep.

Next: Box, the start of Smart Pointers.