20.6Project: a text-search tool, part 2

Last updated June 13, 2026

In part 1 (20.5) we built a working minigrep: arguments parsed into a Config, the file read, the logic in lib.rs, and a thin main.rs that reports errors to stderr. The search itself was a one-line lines().contains() loop buried inside run. This lesson finishes the tool the way a professional would: pull the search into its own function written test-first, add a case-insensitive option configured by an environment variable, and refactor with the iterators from chapter 19. Four earlier chapters cash in here at once.

Test-driven: write the test first

The search logic is pure, text in, matching lines out, which makes it perfect for a unit test (chapter 14). The discipline of test-driven development is to write the test before the code: state what the function should do, watch it fail, then make it pass. Add this to src/lib.rs:

#[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));
    }
}

The test says: searching contents for "duct" should return exactly the one line containing it. There's no search function yet, so this won't even compile, which is the point. We've specified the behavior precisely before writing a line of implementation.

Now write the simplest search that could satisfy it. It takes the query and the text, and returns the matching lines:

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 (chapter 17) ties the returned slices to contents: the result lines are borrowed from the text we searched, so they can't outlive it, and the signature says exactly that. Run the test:

$ cargo test
running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Green. Now wire search into run, replacing the inline loop:

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(())
}

run now reads the file and delegates the actual searching to the tested function. The behavior is identical to part 1, but the logic that matters is covered by a test that will catch any future regression.

A case-insensitive option

Real grep can ignore case. We'll add that, and here's where lesson 20.4 pays off: rather than a command-line flag, we'll control it with an environment variable, so a user who always wants case-insensitive search can set it once. First, a second search function, again test-first:

#[test]
fn case_insensitive() {
    let query = "rUsT";
    let contents = "\
Rust:
Trust me.";

    assert_eq!(
        vec!["Rust:", "Trust me."],
        search_case_insensitive(query, contents)
    );
}
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

to_lowercase (chapter 5/18) folds both the query and each line to lowercase before comparing, so rUsT matches both Rust and Trust. Note query became an owned String (because to_lowercase allocates), so the comparison uses &query.

Now add the toggle to Config. Read an IGNORE_CASE environment variable, present means on, and have run pick the search function:

use std::env;

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 ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query: args[1].clone(),
            file_path: args[2].clone(),
            ignore_case,
        })
    }
}

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(())
}

env::var("IGNORE_CASE").is_ok() is the present-or-absent check from lesson 20.4: set the variable to anything and ignore_case becomes true. Try both:

$ cargo run -- to poem.txt
Are you nobody, too?
$ IGNORE_CASE=1 cargo run -- to poem.txt
Are you nobody, too?
They'd banish us, you know.

With the variable set, the search also matches the capital-less too and know (which contains a lowercase "ow"... actually matches on to inside other words too). The feature works, and notice what adding it didn't require: no new command-line argument, no change to how the user invokes the tool for the common case.

The iterator refactor

The two search functions both follow the same shape: start an empty vector, loop, conditionally push, return the vector. That's precisely the map/filter pattern that chapter 19 said to prefer for transformations. Refactor search into a pipeline:

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

Four lines become three, and they read as a sentence: take the lines, keep the ones containing the query, collect them. No mutable results, no manual push, no chance of an indexing slip. And because we wrote the test first, we can refactor with total confidence, run cargo test, and if it's still green, the rewrite is correct:

$ cargo test
running 2 tests
test tests::case_insensitive ... ok
test tests::one_result ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

This is the whole argument for tests in one motion: they turn "I think this rewrite is equivalent" into "the rewrite is equivalent, proven." The case-insensitive version refactors the same way (.filter(|line| line.to_lowercase().contains(&query))), and the tool is done: a real, tested, composable command-line program built from arguments, files, streams, the environment, structs, error handling, modules, tests, and iterators, every major tool from the first twenty chapters, working together.

Key insight

Look back at what made this project pleasant rather than painful: the lib.rs split (chapter 13) made the logic testable, the tests (chapter 14) made the iterator refactor safe, the iterators (chapter 19) made the refactored code clear, error handling (chapter 12) kept failures clean, and the stream conventions (this chapter) made it composable. None of those chapters felt like the payoff when you learned them. This is the payoff. A real program is earlier lessons reinforcing each other.

Quiz time

Question #1

What's the advantage of writing the search test before the search function?

Show solution

It forces you to specify the function's exact behavior (inputs and expected output) before implementing it, and it gives you a failing test that proves the test itself works. Then, crucially, it makes later refactoring safe: when you rewrite search as an iterator pipeline, re-running the test confirms the new version is equivalent to the old one. The test turns "I think this is right" into "this is proven right."

Question #2

Why is the case-insensitive toggle a good fit for an environment variable rather than a command-line argument?

Show solution

Case-insensitivity is a persistent preference: a user who wants it usually wants it for every search, so setting IGNORE_CASE once in their shell is more convenient than typing a flag on every invocation (lesson 20.4: "set once and leave it" → environment variable). It also adds the feature without changing the tool's argument interface. (A real tool might offer both an env var and a -i flag; the env var alone illustrates the lesson.)

Question #3

The refactored search is contents.lines().filter(|line| line.contains(query)).collect(). Why is collect able to build the right Vec here without a turbofish?

Show solution

Because the function's return type is declared as Vec<&'a str>, so the compiler infers collect's target from that annotation, the binding-annotation form from lesson 19.5 (here the annotation is the function signature). If search returned an inferred or generic type instead, collect would need a turbofish (.collect::<Vec<_>>()) to know which collection to build.

That completes the project and the chapter. The summary and quiz (20.x) review the command-line toolkit and extend minigrep one more step.