The CLI pattern in Rust
You have a script that works. Now you want a proper tool: a binary that runs fast, handles errors gracefully, and can be distributed to anyone without installing a runtime. You start with cargo new, but main quickly becomes a dump for argument parsing, file reading, and error checks. The idiomatic Rust approach avoids this trap by splitting the work. You parse arguments into a configuration struct, then you run the logic based on that configuration. This separation keeps your code testable and maintainable.
Think of your CLI as a machine with a control panel. The command-line arguments are the knobs and switches. The main function is the operator reading the panel. The run function is the engine doing the actual work. You don't wire the knobs directly to the engine. You have a control board that translates the knob positions into settings, and then you hand those settings to the engine. In Rust, that control board is a Config struct.
The Config struct and argument parsing
The Config struct defines what your tool needs to run. It acts as a contract between the parsing code and the logic code. The parsing code promises to deliver a valid Config. The logic code promises to do work given a Config. Neither side needs to know about the other's implementation details. If you change how arguments are parsed, the logic doesn't break. If you change the logic, the parser doesn't break.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
/// Holds the parsed command-line arguments.
struct Config {
query: String,
file_path: String,
}
impl Config {
/// Parses arguments into a Config, or returns an error message.
fn build(args: &[String]) -> Result<Config, &'static str> {
// args[0] is the program name. We need at least query and file.
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
env::args() returns an iterator over the arguments. The first element, args[0], is always the name of the program. This is the first gotcha. If you expect two arguments from the user, args.len() must be at least 3. The compiler won't catch this logic error. You have to check the length before indexing. If you skip the check and the user runs the tool with no arguments, args[1] panics with an index out of bounds error. A panic prints a stack trace. Users want a clean error message, not a backtrace.
Using String for the fields is a deliberate choice. You could try &str to avoid allocation, but that introduces lifetime headaches. If Config holds &str, the struct must live shorter than the data it references. In main, args is a local variable. If build returns a Config containing references to args, the compiler rejects it with E0515 (cannot return value referencing local variable). The Config would outlive the args vector, leaving dangling references. Using String owns the data. The allocation cost is negligible compared to file I/O. Ergonomics win here.
Keep parsing separate from logic. When you add a --verbose flag tomorrow, you only touch build, not run.
Running logic and error propagation
The run function takes the Config and does the work. It returns a Result to handle errors explicitly. Rust forces you to acknowledge that operations like file reading can fail. The ? operator propagates errors up the call stack, keeping the success path clean.
fn run(config: Config) -> Result<(), Box<dyn Error>> {
// Read the file. The ? operator propagates errors up.
let contents = fs::read_to_string(config.file_path)?;
// Process the contents.
println!("Searching for '{}' in '{}'", config.query, config.file_path);
println!("Found {} occurrences", contents.matches(&config.query).count());
Ok(())
}
fn main() {
// Collect args into a Vec for indexing.
let args: Vec<String> = env::args().collect();
// Build config or exit with an error message.
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
// Run the logic. Handle errors gracefully.
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
run returns Result<(), Box<dyn Error>>. Box<dyn Error> is a trait object that can hold any error type implementing std::error::Error. This lets run return std::io::Error from read_to_string or a custom error without defining a complex error enum. It's a pragmatic choice for simple tools. The ? operator works because run returns a Result. If read_to_string fails, ? returns early from run with that error.
In main, unwrap_or_else handles the Config::build result. If parsing fails, the closure prints a message and calls process::exit(1). Exit code 1 signals failure to the shell. If you call a script that runs your tool, the script can check the exit code to determine success. The if let Err(e) pattern handles errors from run. It prints the error and exits with code 1. This gives you control over the error message format. main can also return Result<(), Box<dyn Error>> directly, which is a shorthand. If main returns an error, Rust prints it and exits with code 1 automatically. The explicit pattern is useful when you need custom formatting or logging.
Convention aside: use cargo run -- "query" "file" to test. The double dash tells Cargo to stop parsing its own flags and pass the rest to your binary. Without it, Cargo might interpret your arguments as Cargo flags, leading to confusing errors.
Never unwrap() user input. The user will find a way to break it, and a panic backtrace is not a help message.
Pitfalls and compiler signals
Rust's compiler catches many mistakes, but CLI apps have specific traps.
If you forget to handle the Result from run, you get E0308 (mismatched types) because main expects () but you produced a Result. Or you get a warning about unused Result. Rust forces you to acknowledge errors. You can't ignore a Result without unwrap, expect, or pattern matching.
If you return Ok(Config { ... }) but the function signature says Result<Config, String>, and you return Err("msg"), you get E0308 again. "msg" is &str, not String. You can fix this with Err("msg".to_string()) or change the signature to Result<Config, &'static str>. The latter is cheaper for simple error messages.
Off-by-one errors in argument counting are logic bugs. The compiler won't help. If you check args.len() == 2 expecting one argument, you'll reject valid input because args[0] is the program name. Always account for the program name.
Treat process::exit(1) as the red button. Use it to abort, but print the reason first.
When to use std versus crates
The standard library provides everything you need for a basic CLI. As your tool grows, you'll hit limits. The decision matrix below helps you choose the right tool for the job.
Use env::args() for learning or tiny scripts where adding a dependency feels like overkill. Use clap for production CLIs. It generates help text, handles subcommands, validates types, and supports configuration files. Use Result<(), Box<dyn Error>> for helper functions when you want to propagate errors without committing to a specific error type. Use anyhow when you want Box<dyn Error> ergonomics with better error context and chaining. Use process::exit(1) when you need to signal failure to the shell or a calling script. Use std::process::Command when your tool needs to spawn other processes.
Reach for plain std when you're building a learning project or a micro-tool. Reach for clap when users will run --help and expect a polished response. Reach for anyhow when error context matters more than custom error enums.
Trust the borrow checker. It usually has a point.