Rust Mini-Grep Project
Mini-grep is a tiny search program like grep. Mini-grep finds every line in a text that contains a word you choose.
The Config struct and its builder
A query and a file path belong together, and one struct says so. Build it with Config::build returning Result<Config, &'static str>. Use build, not new. New functions are expected never to fail. build returns Err when an argument is missing. It never panics on a missing index. This cleaner build takes the iterator itself: args.next() once skips the program name, then the next two next() calls are matched, returning an Err on None. The Strings move out of the iterator, so there is no clone and no indexing:
struct Config { query: String, file_path: String }
impl Config {
fn build(mut args: impl Iterator<Item = String>) -> Result<Config, &'static str> {
args.next();
let query = match args.next() {
Some(arg) => arg,
None => return Err("no query"),
};
let file_path = match args.next() {
Some(arg) => arg,
None => return Err("no file path"),
};
Ok(Config { query, file_path })
}
}Write the test first
Test-driven development flips the order. This test asks search for duct in a three-line text. It expects only the line with productive in it. It fails before search exists, then passes once it does:
#[test]
fn one_result() {
let query = "duct";
let contents = "safe, fast, productive.\nPick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}The search, as one chain
The search step is small, and one chain does it with the same result: contents.lines().filter(...).collect() — no mutable results Vec to manage. The lifetime 'a ties the returned lines to contents, not to query, because the lines are slices of contents. You can even drop the collect and return the iterator itself. Matching lines then print as they are found. They do not wait for the whole search.
fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents.lines().filter(|line| line.contains(query)).collect()
}A twin that ignores case
search_case_insensitive lowercases the query and each line with to_lowercase() before contains. A bool in Config chooses which twin run calls. The bool is often set from an IGNORE_CASE environment variable. That variable is the settings dial from the Environment Variables lesson.
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()
}run does the real work
run(config) -> Result<(), Box<dyn Error>> reads the file with ?, searches, prints each matching line, and ends with Ok(()). Keep results and errors apart. Matching lines go to normal output with println!. So cargo run -- to poem.txt > output.txt saves them to a file. Error messages go to the error output with eprintln!. They stay on screen:
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(())
}main stays thin
In main, handle the build Result with unwrap_or_else: print the problem with eprintln! and leave with std::process::exit(1) — a non-zero exit code is how a tool tells the terminal it failed:
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}Example
struct Config { query: String, file_path: String }
impl Config {
fn build(mut args: impl Iterator<Item = String>) -> Result<Config, &'static str> {
args.next();
let query = match args.next() { Some(arg) => arg, None => return Err("no query") };
let file_path = match args.next() { Some(arg) => arg, None => return Err("no file path") };
Ok(Config { query, file_path })
}
}
fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents.lines().filter(|line| line.contains(query)).collect()
}
fn main() {
let args = vec!["minigrep".to_string(), "cat".to_string(), "story.txt".to_string()];
let config = Config::build(args.into_iter()).expect("enough arguments");
let contents = "The cat sat\nA dog ran fast\nThe cat slept";
println!("Searching for {} in {}", config.query, config.file_path);
for line in search(&config.query, contents) { println!("{line}"); }
}Key points
- Split the program in two.
- src/main.rs stays thin: read the settings, call run, report any error.
- src/lib.rs holds the real logic.
- So the logic can be tested like a library.
- Mini-grep is a small grep-like program that prints the lines containing a chosen word.
In this example, main builds a pretend argument list. It looks exactly as env::args() would give it. It holds the program name, then cat, then story.txt. Config::build now takes that list as an iterator. The first next() skips the program name. The next two next() calls move cat and story.txt straight into the Config. No clones, no indexes. The text to search is written directly in the code here, so the example runs anywhere. search is now one chain: lines() walks the three lines, filter keeps the two containing cat, and collect gathers them. main prints the same result as the loop version.
Note
Most real Rust command-line tools share this shape, not just this project.
They have a thin main and a Config built from arguments.
Their run returns Result.
Their tested logic sits in the library.
Try it Yourself
Edit the code, then press Run.
Running is simulated · Rust Playground
Exercise
Why is the constructor called Config::build and not Config::new?