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.
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}");
}
}$ cargo run -- report.csv --verbose
program: target/debug/demo
argument 1: report.csv
argument 2: --verboseWith 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.
#[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}"),
}
}
}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.
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
}
}
}error: no port configuredIn 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:
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.
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:?}");
}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.
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}"),
}
}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 version7. 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.
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()),
}
}
}["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.
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(""));
}
}
}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..=3659. 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.
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}"),
}
}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 helpPart 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.
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));
}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.
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(())
}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.
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(())
}success: false, code: Some(3)
stdout: "partial result\n"
stderr: "disk full\n"
could not start: NotFoundcode() 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().
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(())
}hello from workspace
child finished: exit status: 0Arguments 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.
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(())
}apple
banana
fig
pearWriting 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.
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(())
}[1] running fetch
[2] running build
[3] running test
finished: exit status: 0Part 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.
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)
}$ 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
ExitCodefrommainto control the exit code;process::exitskips cleanup such as flushing buffers. - clap's derive API describes arguments as a struct: positional fields,
#[arg(short, long)]options, defaults,OptionandVecfields,ValueEnumchoices, and subcommand enums. It generates--help,--version, and error messages. env::varreads environment variables. Read them in one function with an injectable lookup; set variables on child processes, not your own.Commandstarts programs:.output()captures everything,.status()shares your terminal, andStdio::piped()connects streams for writing input or reading output as it arrives. A failing program returns a non-success status, not anErr.
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.
