An I/O project (minigrep)
Build a small grep clone with args, file I/O, Result main, lib/bin split, and tests.
This chapter builds a working CLI, end to end. The shape is borrowed from The Rust Book: a tiny grep clone called minigrep. It is small enough to read in one sitting and big enough to exercise every Chapter 1 to 11 idea: ownership, error handling, lib/bin split, tests, environment variables.
For an agent orchestrator, this is the prototype project. It is exactly the size an agent can produce end to end from a prompt sequence. The skill is decomposing the prompt sequence well enough that each step is reviewable.
What we're building
$ minigrep frog poem.txt
The frog had hopped away
A frog of green and gold
$ IGNORE_CASE=1 minigrep FROG poem.txt
The frog had hopped away
A frog of green and goldTwo arguments: a search pattern and a file path. Optionally case-insensitive via an environment variable.
Project shape
minigrep/
├── Cargo.toml
└── src/
├── lib.rs # All the logic. Testable.
└── main.rs # Thin entry point. Wires args + lib + I/O.Putting the logic in lib.rs means integration tests in tests/ can exercise it without spawning a process. The binary's job is parsing arguments and printing results. That is it.
Reading command-line arguments
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
println!("args: {:?}", args);
}env::args() returns an iterator. The first element is the program name. collect::<Vec<String>>() gathers the rest into a Vec. For non-UTF-8 argument support use env::args_os() (rare in practice).
A typed Config
Don't pass &[String] around. Parse arguments into a struct with named fields. Use Result to report bad input.
pub struct Config {
pub query: String,
pub path: String,
pub ignore_case: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("not enough arguments")]
NotEnoughArgs,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, ConfigError> {
if args.len() < 3 {
return Err(ConfigError::NotEnoughArgs);
}
let query = args[1].clone();
let path = args[2].clone();
let ignore_case = std::env::var("IGNORE_CASE").is_ok();
Ok(Config { query, path, ignore_case })
}
}The .clone() calls are intentional. The binary owns args for the lifetime of the program, so we could borrow, but cloning two short strings at startup is the right tradeoff for a clean API.
The search function, test-first
Write the test before the function.
#[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));
}
}cargo test fails because search does not exist. Now make it pass.
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 'a lifetime says: the returned &str slices borrow from contents. The compiler will refuse to let you free contents while the result is in use.
Add a case-insensitive variant and its test:
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let query = query.to_lowercase();
contents
.lines()
.filter(|line| line.to_lowercase().contains(&query))
.collect()
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
Trust me.";
assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents),
);
}A run function and main with ?
pub fn run(config: Config) -> Result<(), Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(&config.path)?;
let lines = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in lines {
println!("{line}");
}
Ok(())
}use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
eprintln!("argument error: {err}");
process::exit(2);
});
if let Err(err) = minigrep::run(config) {
eprintln!("application error: {err}");
process::exit(1);
}
}eprintln! writes to stderr. Useful matches go to stdout via println! so the user can pipe them into other tools. Errors go to stderr so they show up even when stdout is piped. Run with minigrep frog poem.txt > matches.txt and stderr still surfaces.
The prompt sequence for an agent
The thing that makes this project agent-shaped is the structure of the prompts. Drive in narrow steps, each verifiable.
Each step compiles. Each step has a verifiable artifact. Each step is small enough that a diff review fits in one screen. If a step fails, the failure is local to one prompt's worth of code.