CLI apps
Intermediate · Runtime & ecosystem
What & why
A CLI (command-line interface) app is a program you run in the terminal by typing its name, maybe
with some options, like git commit -m "hi". Rust is a great fit for these: they start instantly,
ship as a single file, and never crash from a missing runtime. This lesson covers the three things
every CLI needs — reading arguments, printing to the right place, and exiting with the right code.
The idea, slowly
What is an “argument,” really?
When you type myapp hello --loud in a terminal, the shell hands your program a list of words:
["myapp", "hello", "--loud"]. The first word is always the program’s own name. Everything after
is input you have to make sense of. Rust gives you that list through std::env::args():
fn main() {
// Collect the arguments into a vector of Strings.
let args: Vec<String> = std::env::args().collect();
println!("{args:?}");
println!("You passed {} argument(s) (including the program name).", args.len());
}
Run this on the Playground and you’ll see just the program name, because the Playground runs with
no extra arguments. In a real project, cargo run -- hello --loud would show all three. (The --
tells cargo “everything after this belongs to my program, not to cargo.”)
Reading a specific argument
args[0] is the program name, so the first real argument is args[1]. But what if the user
forgot to pass it? Indexing args[1] when it doesn’t exist would panic. The safe way is
.get(1), which returns an Option:
fn main() {
let args: Vec<String> = std::env::args().collect();
// .get(1) is safe: Some(value) if it exists, None if it doesn't.
match args.get(1) {
Some(name) => println!("Hello, {name}!"),
None => println!("Usage: greet <name>"),
}
}
This runs on the Playground (it just prints the usage line, since there’s no argument). The lesson:
never assume the user gave you input. .get() + match turns “missing argument” into a polite
message instead of a crash.
stdout vs stderr: two separate pipes
Your terminal actually has two output streams:
- stdout (“standard out”) — the program’s real result. The data. The answer.
- stderr (“standard error”) — messages about the run: progress, warnings, errors.
Why two? Because people pipe programs together. If someone runs myapp > results.txt, only stdout
goes into the file; stderr still shows on screen. If you print your error messages to stdout, they
get mixed into results.txt and ruin the data. So the rule is: real output to stdout, everything
else to stderr.
fn main() {
// println! writes to stdout — the actual result.
println!("42");
// eprintln! writes to stderr — status and errors.
eprintln!("done computing");
}
println! = stdout. eprintln! (note the extra e) = stderr. That one letter is the whole
difference.
Exit codes: telling the shell if you succeeded
When a program finishes, it returns a small number to the shell. 0 means success; anything else
means failure. Other tools and scripts rely on this — myapp && echo ok only prints ok if myapp
exited 0. You set it with std::process::exit:
fn main() {
let ok = false;
if !ok {
eprintln!("error: something went wrong");
std::process::exit(1); // non-zero = failure
}
println!("all good");
}
A tidier alternative: make main return Result<(), E>. If it returns Ok, Rust exits 0; if it
returns Err, Rust prints the error to stderr and exits non-zero for you.
When to reach for clap
Parsing --flags and --options=values by hand gets painful fast. For anything beyond a couple of
arguments, the ecosystem standard is clap. You describe your arguments as a struct with
attributes, and clap generates the parser, the --help text, and the error messages:
use clap::Parser;
#[derive(Parser)]
#[command(about = "Greets a person")]
struct Cli {
/// Who to greet
name: String,
/// Say it loudly
#[arg(long)]
loud: bool,
}
fn main() {
let cli = Cli::parse();
let greeting = format!("Hello, {}!", cli.name);
if cli.loud {
println!("{}", greeting.to_uppercase());
} else {
println!("{greeting}");
}
}
clap is an external crate, so this won’t run on the Playground. In a real project, add it and run it:
cargo add clap --features derive
cargo run -- Shamirul --loud
You get --help, --version, and friendly “missing argument” errors for free — that’s the whole
reason clap exists.
Common mistakes
- Indexing
args[1]directly. If the user didn’t pass that argument, the program panics with an ugly backtrace. Use.get(1)and handle theNonecase with a usage message. - Printing errors to stdout. They get mixed into piped/redirected output and corrupt the real
result. Send status and errors to stderr with
eprintln!. - Always exiting
0. If your program fails but returns0, scripts think it succeeded and keep going. Exit non-zero on failure (or returnErrfrommain). - Hand-parsing complex flags. Rolling your own
--optionparser is bug-prone and gives users no--help. Use clap once you have more than one or two arguments. - Forgetting the
--withcargo run.cargo run hellopasseshelloto cargo; you needcargo run -- helloto pass it to your program.
More examples
Checking every file a linter was pointed at
A linter that accepts any number of filenames on the command line just needs everything in args after the program name — &args[1..] turns the rest of the list into the files to check.
fn main() {
let args: Vec<String> = std::env::args().collect();
let files = &args[1..]; // everything after the program name
println!("checking {} file(s)", files.len());
for f in files {
println!(" - {f}");
}
}
Keeping backup progress separate from its result
A backup tool that prints progress to stdout ruins itself the moment someone captures its output with $(...) — status goes to stderr, and the one line that matters goes to stdout.
fn main() {
eprintln!("backing up 3 files...");
eprintln!("backing up 12 files...");
// the actual result: a path a caller could capture with `$(myapp backup)`
println!("/backups/2026-08-31.tar.gz");
}
Rejecting a bad port number with the right exit code
A port-checker CLI needs scripts to be able to tell “you gave me garbage” apart from “the port is closed” — exiting 2 for a usage error keeps that distinction visible to anything calling it.
fn main() {
let args: Vec<String> = std::env::args().collect();
let port: u16 = match args.get(1) {
Some(p) => match p.parse() {
Ok(n) => n,
Err(_) => {
eprintln!("error: '{p}' is not a valid port number");
std::process::exit(2); // usage error
}
},
None => {
eprintln!("usage: portcheck <port>");
std::process::exit(2);
}
};
println!("checking port {port}...");
}
A clap-powered to-do CLI
clap turns a to-do tool’s task argument and --urgent flag into a real struct — no hand-written parsing, and --help for free.
use clap::Parser;
#[derive(Parser)]
#[command(about = "Adds a task to your to-do list")]
struct Cli {
/// What needs doing
task: String,
/// Mark it urgent
#[arg(long)]
urgent: bool,
}
fn main() {
let cli = Cli::parse();
if cli.urgent {
println!("[URGENT] {}", cli.task);
} else {
println!("added: {}", cli.task);
}
}
Your turn
This program should greet the argument the user passed, but it crashes when run with no argument.
Fix it so that with no argument it prints Usage: greet <name> instead of panicking.
fn main() {
let args: Vec<String> = std::env::args().collect();
let name = &args[1]; // panics if there is no args[1]
println!("Hello, {name}!");
}
Show solution
Use .get(1) so a missing argument becomes None instead of a panic, and print the usage line to
stderr:
fn main() {
let args: Vec<String> = std::env::args().collect();
match args.get(1) {
Some(name) => println!("Hello, {name}!"),
None => {
eprintln!("Usage: greet <name>");
std::process::exit(1); // non-zero: we failed to do the job
}
}
}
args[1] panics the instant the index is out of range. .get(1) returns an Option, so “no
argument” is just None — a case you handle calmly. Sending the usage message to stderr and
exiting 1 also tells any calling script that this run didn’t succeed.
Quick check
Remember this
std::env::args()gives the argument list;args[0]is the program name, real arguments start atargs[1].- Use
.get(1)(notargs[1]) so a missing argument isNone, not a panic. - stdout (
println!) is for real output; stderr (eprintln!) is for status and errors — keep them separate. - Exit
0for success, non-zero for failure; or returnResultfrommainand let Rust do it. - For anything beyond a couple of arguments, use clap — it generates the parser and
--helpfor you.
Go deeper
- Rust Book - Command Line Programs — CLI project example.
Next: