Rust Interview Mastery · Chapter 12 12

An I/O Project: Building a Command Line Program

The minigrep case study — args, files, lib/bin split, env vars, and stderr discipline.

7 questions 2 topics Intermediate

Args, Config & Separation

How do you read command-line arguments, and what's the caveat?
use std::env;
let args: Vec<String> = env::args().collect();

env::args() yields Strings — the program name first, then args. The caveat the book flags: args() panics on invalid Unicode; for arbitrary bytes use env::args_os() returning OsString.

(Production note: real CLIs use clap — but env::args + collect is the interview answer for what the stdlib gives you.)

Why does the book move from Config::new returning Self to Config::build returning Result?

Because Config::new was panicking on bad input — the wrong channel for a user error. build returns Result<Config, &'static str>:

let config = Config::build(&args).unwrap_or_else(|err| {
    eprintln!("Problem parsing arguments: {err}");
    process::exit(1);
});

Now the caller decides: print a usage error and exit 1 — a CLI convention — instead of an ugly panic stack. It's the chapter 9 lesson applied: construction failure of recoverable kind → Result, caller decides severity.

Why split logic into lib.rs and keep main.rs thin?

main.rs becomes orchestration: parse args → call run(config) → exit. lib.rs holds run, search, Config — everything testable:

if let Err(e) = minigrep::run(config) {
    eprintln!("Application error: {e}");
    process::exit(1);
}

You can't cargo test a binary's internals easily; you can test a library's public API from tests/. Thin main = all logic gets unit + integration coverage. It's the "binary is a thin shell over a library" idiom — the standard Rust app shape.

Files, Env & Output Streams

How does the project read files and propagate I/O errors?
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;
    for line in search(&config.query, &contents) {
        println!("{line}");
    }
    Ok(())
}

fs::read_to_string slurps a file into a String, returns io::Result. ? propagates, Box<dyn Error> erases the concrete error type. For big files you'd BufReader + lines lazily — read_to_string is right for the tutorial scale.

Why eprintln! for errors — what's the actual difference from println!?

Streams. println! → stdout; eprintln! → stderr. They matter when output is piped:

minigrep to poem.txt > results.txt   # errors should NOT land in results.txt

Errors on stderr keep the data stream clean — the Unix contract. The book shows the bug where error messages went to stdout and got silently written into the output file. CLI correctness means matching the platform's stream conventions.

How does the case-insensitive search get toggled, and why env vars?
let ignore_case = env::var("IGNORE_CASE").is_ok();

env::var returns Result<String, VarError> — is_ok() treats "set to anything" as on. The search vs search_case_insensitive split keeps the logic pure — the flag is read once at the boundary, then threaded into Config.

Why env vars: they configure the process without CLI plumbing, work across shells/containers, and the pattern (env::var at startup → typed config struct) is exactly how 12-factor apps work.

The iterator refactor in ch13's second half — what does it change about this project?

Index loops become iterator chains:

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents.lines()
        .filter(|line| line.contains(query))
        .collect()
}

Instead of: skip arg0, manual vec, lines()+contains+push. The iterator version is declarative — "keep lines containing query" — and often faster (no bounds checks, better optimization). args becomes env::args().skip(1) iterator too. It's the bridge showing chapter 13's tools paying off in real code.