Hedronite Lesson · Polyglot-Dev / Rust · Fri 2026-09-18

An I/O Project

Build a small grep. Args in, file read, matches on stdout, errors on stderr.

Lesson Class: Duha (Rust language track)
Focus: env::args · fs::read_to_string · Config/run · lib+bin · search lifetime · IGNORE_CASE · eprintln!
Code Blocks: clean blocks, explanation in prose
Done-criteria: accept args, read a file, library+binary split
Grounding: TRPL stable Ch.12 HTML ch12-00 through ch12-06 · Blandy not cited
The binary
main builds Config, calls run, exits on Err — testable logic lives in lib.
The search
Returned slices borrow contents; name the lifetime so Rust does not guess query.
The streams
println! for matches; eprintln! for failures when stdout is redirected.
Build a small grep. Args in, file read, matches on stdout, errors on stderr.

<!-- hal:authoritative:yaml -->

Build a small grep. Take a query and a file path from the command line, read the file, print matching lines, and keep errors on stderr when success goes to a redirect.

§I — Frame

Duha session 13. TRPL Chapter 12 is one syllabus row, so this fire takes the whole chapter at chapter pace. Session 12 left you writing unit, integration, and doc tests. Keep that toolkit; today you assemble modules, Result, lifetimes, and tests into one binary people can run.

The book’s project is minigrep: a simple greplike tool. Real ripgrep is the community ceiling; this chapter is the floor that makes that ceiling readable. You will accept arguments, read a file, split library from binary, drive search with a failing test, honor IGNORE_CASE, and print failures with eprintln!.

Three moves land by the end:

  1. Wire the binary — env::args, fs::read_to_string, Config, run.
  2. Own the library — search / search_case_insensitive with an explicit lifetime on contents.
  3. Behave like a CLI — env-driven case fold; matches on stdout; errors on stderr.

Syllabus Done-criteria: Can accept args, read a file, and write a library+binary split.

§II — Arguments and the file

cargo new minigrep starts a binary. Collect arguments with std::env::args().collect() into a Vec<String>. Index 0 is the binary path. The query is args[1]; the file path is args[2]. Under Cargo, put a -- before program args so Cargo does not eat them:

cargo run -- needle poem.txt

env::args panics on invalid Unicode. env::args_os returns OsString when you must accept that. The book stays on String for clarity.

Read the file with fs::read_to_string(file_path). That returns std::io::Result<String>. Early drafts use .expect(...); that is temporary. The poem fixture in the book (poem.txt) is enough to prove the read before search exists.

At this stage main already does too much: parse, read, and (soon) search. That is the signal to refactor.

§III — Config, run, and the binary/library cut

The book’s binary split is deliberate:

Group config into a struct:

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }
        let query = args[1].clone();
        let file_path = args[2].clone();
        let ignore_case = env::var("IGNORE_CASE").is_ok();
        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

build returns Result so missing args become a clear message instead of an index panic. main uses unwrap_or_else (or equivalent) and process::exit(1) when build fails.

run owns the work:

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;
    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };
    for line in results {
        println!("{line}");
    }
    Ok(())
}

Box<dyn Error> lets ? propagate different error types without naming each one in the signature. Session 9’s Result discipline shows up here as structure, not as a new lecture.

§IV — Search by TDD and the lifetime on contents

Move search into the library. Write the test first (session 11 habits):

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";
        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Stub search to return vec![], watch the test fail for the right reason, then implement:

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();
    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }
    results
}

The lifetime 'a ties returned slices to contents, not to query. Without it, Rust cannot tell which argument the &str pieces borrow. Session 10’s named lifetimes are the reason this compiles.

§V — IGNORE_CASE and stderr

Case-insensitive search is an environment switch, not another CLI flag in the book’s design. env::var("IGNORE_CASE").is_ok() turns any set value into true. Pair it with search_case_insensitive, which lowercases the query and each line before contains. Keep a case-sensitive test that includes a line like "Duct tape." so a capital D does not false-match "duct".

Matches stay on println! (stdout). Errors move to eprintln!:

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

Redirect check: cargo run > output.txt with missing args must show the error on the terminal and leave output.txt empty. That is the CLI contract Chapter 12 closes on.

§VI — One complete proof

  1. cargo new minigrep, collect env::args, read poem.txt with fs::read_to_string.
  2. Extract Config::build and run; move search into lib.rs.
  3. TDD search with a failing test, then implement with 'a on contents.
  4. Wire IGNORE_CASE and search_case_insensitive.
  5. Print errors with eprintln! and confirm a stdout redirect keeps failures on screen.

When those five hold, Chapter 12’s selected depth is done.

§VII — Closing

A CLI is modules plus I/O plus honest error streams. Parse into Config, run logic you can test, borrow lines from the file you read, and keep stderr for failure when stdout is redirected. Session 12’s tests prove search; session 14 will return to closures on this same project.

Done-criteria: Can accept args, read a file, and write a library+binary split.

Related