20.1Command-line arguments
Every program you've written so far has been a closed box: it runs, maybe asks the user a question with read_line, and prints. Real command-line tools are configured from the outside, by the words you type after the program's name. grep needle file.txt, cargo build --release, ls -la: the needle, the file.txt, the --release, the -la are command-line arguments, and a program reads them to know what to do. This chapter builds a real tool, and the first thing any tool needs is its arguments.
Getting the arguments
The arguments live in the standard library's env module, behind the function std::env::args. It returns an iterator (chapter 19, now immediately useful) over the arguments as Strings. The usual move is to collect them into a Vec:
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
println!("{args:?}");
}
Build it and run it with some arguments. If the compiled program is called args_demo:
$ cargo run -- apple banana cherry["target/debug/args_demo", "apple", "banana", "cherry"]
Two things to notice. First, collect needs the Vec<String> annotation, the exact missing-type situation from lesson 19.5. Second, and this surprises everyone the first time: the first element isn't apple. It's the path to the program itself. Argument zero is, by long-standing convention across operating systems, the name the program was invoked as. The arguments you care about start at index 1.
The -- in cargo run
cargo run -- apple banana has a -- in it. That separator tells cargo "everything after this is for my program, not for you." Without it, cargo would try to interpret apple as one of its own options. When you run the compiled binary directly (./target/debug/args_demo apple banana), there's no cargo in the way and no -- needed. The -- is a cargo-ism, not part of your program's argument handling.
Skipping the program name
Because argument zero is the program name, real code usually skips it. Since args() is an iterator, the cleanest way is the skip adapter from chapter 19, or to pull the first item off and ignore it. Most often you just index past it:
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
println!("usage: greet <name>");
return;
}
let name = &args[1];
println!("Hello, {name}!");
}$ cargo run -- AdaHello, Ada!
The args.len() < 2 check matters. If the user runs the program with no arguments, args holds only the program name (length 1), and reaching for args[1] would panic with an out-of-bounds index, the chapter-18 lesson that [] panics, here triggered by user behavior rather than a bug. Checking the length first and printing a usage message is the polite, non-panicking response, and it's what every well-behaved tool does. (Chapter 12's get returning Option is the other way to handle a possibly-missing index; for arguments, a length check reads clearly.)
Arguments are always text
One more thing to internalize early, because it shapes the whole chapter. Every argument arrives as a String, always, even when it looks like a number. If your tool takes a count or a port number, you get the text "8080", and turning it into a number is your job, with parse (lesson 5.6):
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
println!("usage: double <number>");
return;
}
match args[1].parse::<i64>() {
Ok(n) => println!("{}", n * 2),
Err(_) => println!("'{}' is not a number", args[1]),
}
}$ cargo run -- 21
42
$ cargo run -- banana
'banana' is not a number
This is the same text-to-number border you crossed back in chapter 5, now arriving from the command line instead of from read_line. The user can type anything, so parse returns a Result, and a real tool reports the bad input instead of crashing. The whole pattern of chapter 12, handle the error path, applies directly to argument parsing, and the project at the end of this chapter leans on it.
Non-UTF-8 arguments
env::args() panics if an argument isn't valid UTF-8 (rare, but possible with odd filenames on some systems). For the everyday tools in this chapter that's fine and args() is the right choice. If you ever need to accept any bytes a shell can pass, there's a sibling env::args_os() that yields OsString values instead, sidestepping the UTF-8 requirement. You'll almost never need it; reach for it only if a real filename ever makes args() panic.
Quiz time
Question #1
What does the first element of env::args().collect::<Vec<String>>() contain, and where do the user's arguments begin?
Show solution
The first element (index 0) is the path/name the program was invoked as, by operating-system convention, not the first real argument. The arguments the user actually passed begin at index 1. That's why argument-reading code typically skips index 0 (with .skip(1) or by indexing from 1).
Question #2
Why is it important to check args.len() before indexing args[1]?
Show solution
If the user runs the program with no arguments, args contains only the program name (length 1), so args[1] is out of bounds and [] indexing panics (chapter 18). Checking the length first lets you print a usage message and exit cleanly instead of crashing. It's the difference between a bug and a handled "you forgot an argument" case.
Question #3
A tool takes a port number as its first argument. The user runs it with 8080. What type is args[1], and what must you do to use it as a number?
Show solution
args[1] is a String containing the text "8080", command-line arguments always arrive as text, never as numbers. To use it numerically you must parse it (e.g. args[1].parse::<u16>()), which returns a Result because the user might have typed something that isn't a number. Handle the Err case (print a message) rather than unwraping, since the input is user-controlled.
Arguments tell a tool what to work on, and most often what they name is a file. The next lesson (20.2) covers reading and writing files: the one-line conveniences for small jobs and the buffered approach for big ones.