Errors That Don't Panic

Replace `.expect` with `Result` and `?`, handle many files, and exit with a sane status code.

Part 3 of 3Updated

.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 — try clap with the derive feature
  • Stream with BufReader instead of loading whole files into memory

Each of those is a small change to code you already understand — which was the whole point.