TL;DR
Build command-line tools in Rust by parsing arguments, handling flags, and structuring real-world CLI applications
Key concepts
- Rust CLI tutorial
- Rust command line arguments
- Rust CLI application
- Rust argument parsing
- Rust terminal tools
CLI and Args
Rust is one of the best languages for building command-line tools. The ecosystem is mature, binaries are small and fast, and the type system helps you handle edge cases before your users ever encounter them. In this lesson, you'll learn how to read arguments from the command line, validate input, and structure a real CLI application using the standard library.
Reading Arguments with std::env::args
Every Rust program has access to its command-line arguments through std::env::args(). This returns an iterator of String values — the first being the program name, followed by any arguments the user passed.
fn main() {
let args: Vec<String> = std::env::args().collect();
println!("Total arguments: {}", args.len());
for (index, arg) in args.iter().enumerate() {
println!(" args[{}] = {:?}", index, arg);
}
}
In the playground above, args[0] will be the binary path. When you run a real CLI tool like ./mytool hello world, args[1] is "hello" and args[2] is "world".
That one-slot offset is small enough to nod along to and large enough to break three separate things in the same program. The browser runner starts every program with no arguments, so the block below does what the rest of this lesson's exercises do: it hands the parsing code a simulated argument list — a Vec<&str> shaped exactly like what the shell would pass for ./report --verbose data.csv, program name and all. Work out all three printed lines before you run it.
Predict
This slice stands in for the command ./report --verbose data.csv — the same three entries std::env::args() would yield. Decide what each of the three println! lines prints, in order, before running.
fn main() {
// Stands in for the command line ./report --verbose data.csv.
// std::env::args() would yield exactly these three entries.
let args: Vec<&str> = vec!["./report", "--verbose", "data.csv"];
println!("args.len() = {}", args.len());
let first_positional = args.iter().find(|a| !a.starts_with("--"));
println!("first non-flag = {:?}", first_positional);
let file = args.get(1).copied().unwrap_or("<none>");
println!("file = {}", file);
}All three lines print something other than the obvious answer, and it is the same off-by-one each time: args.len() = 3, first non-flag = Some("./report"), file = --verbose. The program name is an ordinary element of the list — len() counts it, iterator adapters visit it, and it pushes every real argument one slot right. Skipping it is your job: args.iter().skip(1) and nothing else. The third line adds the sharper half of the lesson: even after skipping index 0, a fixed index only finds the argument you meant on a command line with no flags in front of it. Because flags and positionals interleave freely, the position of data.csv is not something an index can know — only a scan can.
Handling Missing Arguments Gracefully
A robust CLI tool never panics when arguments are missing — it falls back to defaults or prints a helpful message. The get method on slices returns an Option, making this pattern clean and safe.
fn main() {
let args: Vec<String> = std::env::args().collect();
// Use a default value when no argument is provided
let name = args.get(1).map(String::as_str).unwrap_or("World");
let times: u32 = args
.get(2)
.and_then(|s| s.parse().ok())
.unwrap_or(1);
for _ in 0..times {
println!("Hello, {}!", name);
}
}
Notice the use of .and_then(|s| s.parse().ok()) — this chains Option operations cleanly. If the argument doesn't exist or can't be parsed as a number, we fall back to 1. No unwrap panics, no verbose if let chains.
That chain is convenient, and convenience is exactly why it deserves a second look. Close the page and answer this from memory before reading on:
Recall
Without scrolling up: in Option and Result you learned what .ok() does to a Result, and in Error Handling you learned why swallowing an error is a decision rather than a convenience. In the chain args.get(2).and_then(|s| s.parse().ok()).unwrap_or(1), what happens when the user runs the tool as mytool greet three — passing the word 'three' where a count belongs — and how does that differ from passing nothing at all?
Both cases end at 1, and the program cannot tell them apart. .ok() converts Result<T, E> into Option<T> by throwing the error away, so a ParseIntError from "three" becomes the same None that a missing argument produces. For a genuinely optional value that is the right trade. For a value the user visibly tried to supply it is a silent wrong answer — they typed something, the tool ignored it, and nothing on screen admits it. When the distinction matters, keep the Result rather than flattening it, which is exactly what the build task at the end of this lesson asks you to do for --limit.
Parsing Flags and Subcommands
Many CLI tools accept flags like --verbose or subcommands like git commit. You can implement basic flag parsing by scanning arguments for known patterns.
fn main() {
let args: Vec<String> = std::env::args().collect();
let verbose = args.contains(&String::from("--verbose"));
let subcommand = args.get(1).map(String::as_str);
match subcommand {
Some("--verbose") | None => {
println!("No subcommand given. Usage: tool <command> [--verbose]");
}
Some("greet") => {
let name = args.get(2).map(String::as_str).unwrap_or("stranger");
if verbose {
println!("[DEBUG] Running greet command with name={:?}", name);
}
println!("Hey there, {}!", name);
}
Some("count") => {
let n: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
if verbose {
println!("[DEBUG] Counting to {}", n);
}
for i in 1..=n {
println!("{}", i);
}
}
Some(unknown) => {
eprintln!("Unknown command: {:?}", unknown);
std::process::exit(1);
}
}
}
A few things worth noting here:
eprintln!writes to stderr, which is the correct stream for error messages.std::process::exit(1)terminates with a non-zero exit code, signalling failure to the shell.- The
--verboseflag can appear anywhere in the args list because we usecontainsrather than positional indexing.
That last point has a sting in its tail. If a flag may appear anywhere, then the positional arguments do not live at fixed indices — and code that reads args[1] and args[2] is quietly assuming the user put the flag last. The program below makes that assumption. It compiles, it runs, it exits zero, and it prints three lines that look plausible and are wrong. Commit a hypothesis about which line is wrong and why before you change anything:
Debug
This parser is given the simulated command line ./copy --verbose in.txt out.txt and should report verbose true, source in.txt, dest out.txt. It runs clean with no panic and no error — and reports the wrong source and dest. Work out what the indices actually point at before you change a line, then fix parse so a flag anywhere in the list cannot displace a positional argument.
/// A parsed command line: one flag and two positional arguments.
struct Parsed<'a> {
verbose: bool,
source: &'a str,
dest: &'a str,
}
/// args arrives exactly as the shell hands it over, program name included.
fn parse<'a>(args: &[&'a str]) -> Parsed<'a> {
// The flag may appear anywhere, so scan the whole list for it.
let verbose = args.contains(&"--verbose");
// The two positional arguments come after the program name at args[0].
let source = args.get(1).copied().unwrap_or("<missing>");
let dest = args.get(2).copied().unwrap_or("<missing>");
Parsed { verbose, source, dest }
}
fn main() {
// The browser runner passes no real arguments, so this slice stands in for
// the command ./copy --verbose in.txt out.txt.
let args: Vec<&str> = vec!["./copy", "--verbose", "in.txt", "out.txt"];
let parsed = parse(&args);
println!("verbose = {}", parsed.verbose);
println!("source = {}", parsed.source);
println!("dest = {}", parsed.dest);
}Expected output: verbose = true
source = in.txt
dest = out.txt
The indices are the bug. The four entries are ./copy, --verbose, in.txt, out.txt, so args.get(1) fetches the flag and args.get(2) fetches the source — every positional argument shifted one slot right by a flag that happened to be typed first. Nothing panics, because --verbose is a perfectly ordinary &str to find at index 1; the program simply goes off to copy a file by that name. Note which line survived: verbose is right because contains scans, while source and dest are wrong because they index. The fix is to make the positionals scan as well — collect the non-flag arguments once, then take from that list by position:
fn main() {
let args: Vec<&str> = vec!["./copy", "--verbose", "in.txt", "out.txt"];
let positional: Vec<&str> = args
.iter()
.skip(1) // drop the program name
.filter(|a| !a.starts_with("--"))
.copied()
.collect();
println!("verbose = {}", args.contains(&"--verbose"));
println!("source = {}", positional.first().copied().unwrap_or("<missing>"));
println!("dest = {}", positional.get(1).copied().unwrap_or("<missing>"));
}
The .skip(1) is doing the args[0] job from the Predict above: without it the program name, which does not start with --, becomes the first positional and you have traded one off-by-one for another.
Building a Mini Calculator
Let's put everything together in a realistic example — a small calculator that takes an operation and two numbers as arguments. The browser runner passes no arguments to your program, so this example falls back to a demo argument set (add 10 3) when it is started with none; on a real terminal that fallback never triggers and you get the usage message instead.
fn print_usage() {
eprintln!("Usage: calc <add|sub|mul|div> <number> <number>");
eprintln!("Example: calc add 10 3");
}
fn parse_number(s: &str, label: &str) -> Result<f64, String> {
s.parse::<f64>()
.map_err(|_| format!("'{}' is not a valid number for {}", s, label))
}
fn main() {
let mut args: Vec<String> = std::env::args().collect();
// The browser runner starts this program with no arguments, so fall back to
// a demo set to show the calculator working. A real CLI would drop this
// block and keep only the usage error below.
if args.len() == 1 {
println!("(no arguments given — running the demo: calc add 10 3)");
args.extend(["add", "10", "3"].map(String::from));
}
if args.len() < 4 {
print_usage();
std::process::exit(1);
}
let operation = &args[1];
let a = match parse_number(&args[2], "first argument") {
Ok(n) => n,
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
};
let b = match parse_number(&args[3], "second argument") {
Ok(n) => n,
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
};
let result = match operation.as_str() {
"add" => Ok(a + b),
"sub" => Ok(a - b),
"mul" => Ok(a * b),
"div" => {
if b == 0.0 {
Err(String::from("Division by zero"))
} else {
Ok(a / b)
}
}
op => Err(format!("Unknown operation: '{}'", op)),
};
match result {
Ok(value) => println!("{} {} {} = {}", a, operation, b, value),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
This example shows a complete CLI pattern: validate argument count early, parse each argument with a helper that returns Result, handle errors at each step, and use exit codes to communicate success or failure.
Try It Yourself
Reading about argument parsing is not the same as getting the indices right under your own name. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out, each already compiling and returning a value of the right type, so the program builds as shipped and fails on its checks rather than in the compiler. Run it as-is to see which check fails first, then implement each function until it prints All checks passed.
The checks feed in simulated argument lists rather than real ones, for the reason every exercise in this lesson has: the browser runner starts your program with no arguments, so std::env::args() would yield nothing to parse. A Vec<&str> beginning with the program name is the same shape the shell would hand you, and every trap in this lesson survives the substitution intact.
The three functions are the lesson's three ideas in order. flag_value finds a flag's value — the token after it. positional_args produces the real arguments, which means dropping args[0], dropping the flags, and dropping the token that --limit consumes. build_config assembles the result and reports problems as Err rather than panicking, keeping the distinction the Retrieval block said .ok() throws away.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so work down from the first failure you see. Do not change the signatures or the checks.
// Three functions to finish. Each stub already compiles and returns a value of
// the right type, so the program builds as shipped and fails its CHECKS rather
// than in the compiler. Replace each placeholder body.
//
// There is no real command line here: the browser runner starts every program
// with no arguments, so the checks below feed in simulated argument lists that
// look exactly like what the shell would hand you — program name first.
#[derive(Debug, PartialEq)]
struct Config {
verbose: bool,
limit: usize,
files: Vec<String>,
}
// TODO 1: return the token that FOLLOWS name in args, or None when the flag
// is absent or is the very last token.
// flag_value(&["./r", "--limit", "3"], "--limit") -> Some("3")
// Hint: iter().position(...) finds the flag's index; get(index + 1) is the
// value, and .copied() turns Option<&&str> into Option<&str>.
fn flag_value<'a>(args: &[&'a str], name: &str) -> Option<&'a str> {
let _ = (args, name);
None
}
// TODO 2: return every POSITIONAL argument as a String — that is, everything
// except args[0] (the program name), any token starting with "--", and the
// one token that "--limit" consumes as its value.
// positional_args(&["./r", "--limit", "5", "a.csv"]) -> ["a.csv"]
// Hint: a while loop over an index lets you advance by 2 when you meet
// "--limit", which is the step a .filter() cannot express.
fn positional_args(args: &[&str]) -> Vec<String> {
let _ = args;
Vec::new()
}
// TODO 3: assemble a Config, reporting problems as Err instead of panicking.
// - no positional arguments -> Err("usage: report [--verbose] [--limit N] <file>...")
// - "--limit" present but unparseable -> Err("--limit needs a number, got '<raw>'")
// - "--limit" absent -> limit defaults to 10
// - verbose is true when "--verbose" appears ANYWHERE in the list
fn build_config(args: &[&str]) -> Result<Config, String> {
let _ = args;
Ok(Config { verbose: false, limit: 0, files: Vec::new() })
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
let full = vec!["./report", "--verbose", "--limit", "3", "a.csv", "b.csv"];
assert_eq!(
flag_value(&full, "--limit"),
Some("3"),
"flag_value should return the token AFTER the flag"
);
assert_eq!(
flag_value(&full, "--missing"),
None,
"an absent flag has no value"
);
assert_eq!(
flag_value(&["./report", "--limit"], "--limit"),
None,
"a trailing flag with nothing after it has no value"
);
assert_eq!(
positional_args(&full),
vec!["a.csv".to_string(), "b.csv".to_string()],
"positional_args must drop args[0], the flags, AND the value --limit consumes"
);
assert_eq!(
positional_args(&["./report"]),
Vec::<String>::new(),
"a bare program name has no positional arguments"
);
assert_eq!(
positional_args(&["./report", "--limit", "5"]),
Vec::<String>::new(),
"the 5 belongs to --limit, so it is not a file"
);
let config = build_config(&full).expect("the full command line should parse");
assert!(config.verbose, "--verbose anywhere in the list sets verbose");
assert_eq!(config.limit, 3, "--limit 3 should parse to 3");
assert_eq!(
config.files,
vec!["a.csv".to_string(), "b.csv".to_string()],
"only the two real file names survive into the config"
);
let defaulted = build_config(&["./report", "log.txt"]).expect("one file is enough");
assert_eq!(defaulted.limit, 10, "a missing --limit should default to 10");
assert!(!defaulted.verbose, "no --verbose means verbose stays false");
assert_eq!(
build_config(&["./report", "--verbose"]),
Err(String::from("usage: report [--verbose] [--limit N] <file>...")),
"no positional arguments is a usage error, not a panic"
);
assert_eq!(
build_config(&["./report", "--limit", "wide", "a.csv"]),
Err(String::from("--limit needs a number, got 'wide'")),
"an unparseable --limit value should be reported, not silently defaulted"
);
println!("All checks passed.");
println!("config = {:?}", config);
println!("default = limit {}", defaulted.limit);
println!("usage = {:?}", build_config(&["./report"]));
}Expected output: All checks passed.
config = Config { verbose: true, limit: 3, files: ["a.csv", "b.csv"] }
default = limit 10
usage = Err("usage: report [--verbose] [--limit N] <file>...")
Once it passes, try two variations and predict each before running:
- Start the loop at zero. In
positional_args, changelet mut index = 1;tolet mut index = 0;. Predict which check breaks before running. The fourth one does:positional_args(&full)comes back as["./report", "a.csv", "b.csv"]because the program name does not start with--and so passes the flag filter untouched. This is the Predict block's trap in your own code — index 0 is skipped by an explicit decision or it is not skipped at all. - Default the bad
--limitinstead of reporting it. Inbuild_config, replace theparse().map_err(...)?withflag_value(args, "--limit").and_then(|raw| raw.parse().ok()).unwrap_or(10). Decide which check breaks before running. The last one:build_config(&["./report", "--limit", "wide", "a.csv"])now returnsOkwithlimit: 10instead of anErr, because.ok()throws theParseIntErroraway and makes a typo indistinguishable from an absent flag. The user asked for something impossible and the tool silently did something else.
Key Takeaways
std::env::args()returns an iterator — collect it into aVec<String>to work with arguments by index.args[0]is conventionally the program name — the standard library does not guarantee it, so treat it as a slot to skip rather than a value to trust; user-provided arguments start at index1.- Use
.get(n)instead of direct indexing to avoid panics when arguments are missing. - Chain
Optionmethods like.map,.and_then, and.unwrap_orfor clean, safe argument parsing. - Write errors to
stderrwitheprintln!and exit with a non-zero code usingstd::process::exit(1). - Validate argument count early and show usage instructions before attempting any parsing.
- For production CLI tools, consider the
clapcrate — it handles flags, subcommands, help generation, and shell completions automatically.
Pro Tip: Always test your CLI tool with zero arguments, one argument, and invalid types for numeric arguments. These three cases catch the vast majority of real-world user errors, and Rust's
OptionandResulttypes make handling them explicit rather than leaving them as silent panics.
Next Steps
You've built a solid CLI tool! Now it's time to put everything together in the capstone project — a complete application that combines structs, enums, error handling, traits, collections, and more.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.