Errors That Don't Panic
Replace `.expect` with `Result` and `?`, handle many files, and exit with a sane status code.
.expect("failed to read file") panics with a stack-trace-ish message and exit code 101.
Real tools print one clear line and exit 1. Let’s fix that.
main can return Result
use std::{env, fs, process};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = env::args().nth(1).ok_or("usage: wordcount <file>")?;
let contents = fs::read_to_string(&path)?;
println!("{}", contents.split_whitespace().count());
Ok(())
}
The ? operator does the work: on Ok(v) it evaluates to v; on Err(e) it returns early,
converting the error into the function’s error type. Box<dyn Error> accepts anything that
implements Error, which is why an io::Error and a &str can both flow through the same
?.
env::args().nth(1) avoids allocating the whole Vec<String> from part 1.
But the output is ugly
Returning Err from main prints the Debug form:
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
Nobody wants that. Keep main thin and do the reporting yourself:
fn main() {
if let Err(err) = run() {
eprintln!("wordcount: {err}");
process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
// ...real work here
Ok(())
}
Now failures read as wordcount: No such file or directory (os error 2).
This main + run split is a standard Rust CLI shape. run is testable; main only
decides how to present a failure.
Per-file errors shouldn’t kill the run
Real wc keeps going when one file is missing. That means the error is per file, not
fatal:
fn run() -> Result<(), Box<dyn std::error::Error>> {
let paths: Vec<String> = env::args().skip(1).collect();
if paths.is_empty() {
return Err("usage: wordcount <file>...".into());
}
let mut failed = false;
let mut totals = (0usize, 0usize, 0usize);
for path in &paths {
match fs::read_to_string(path) {
Ok(contents) => {
let c = count(&contents);
totals = (totals.0 + c.0, totals.1 + c.1, totals.2 + c.2);
println!("{:>8} {:>8} {:>8} {path}", c.0, c.1, c.2);
}
Err(err) => {
eprintln!("wordcount: {path}: {err}");
failed = true;
}
}
}
if paths.len() > 1 {
println!("{:>8} {:>8} {:>8} total", totals.0, totals.1, totals.2);
}
if failed {
return Err("some files could not be read".into());
}
Ok(())
}
The match on Result is the escape hatch when ? is too blunt. Use ? when the error
should stop the function; use match when you want to recover and continue.
Add a test
#[cfg(test)]
mod tests {
use super::count;
#[test]
fn counts_words_across_lines() {
assert_eq!(count("one two\nthree\n"), (2, 3, 14));
}
#[test]
fn empty_input_is_all_zeros() {
assert_eq!(count(""), (0, 0, 0));
}
}
cargo test
#[cfg(test)] means the module is compiled only for test builds — it costs nothing in your
shipped binary. Keeping the counting logic in a pure &str -> (usize, usize, usize) function
is what made this easy to test at all.
Where to go next
You now have a real, tested, error-handling CLI. Natural extensions:
- Read from stdin when no path is given (
std::io::stdin().read_to_string) - Add flags like
-l/-w/-c— tryclapwith thederivefeature - Stream with
BufReaderinstead of loading whole files into memory
Each of those is a small change to code you already understand — which was the whole point.