An I/O Project
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:
- Wire the binary —
env::args,fs::read_to_string,Config,run. - Own the library —
search/search_case_insensitivewith an explicit lifetime oncontents. - 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:
main.rsparses args, builds config, callsrun, and exits on error.lib.rsholds searchable logic you can unit-test without spawning the binary.
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
cargo new minigrep, collectenv::args, readpoem.txtwithfs::read_to_string.- Extract
Config::buildandrun; move search intolib.rs. - TDD
searchwith a failing test, then implement with'aoncontents. - Wire
IGNORE_CASEandsearch_case_insensitive. - 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.