---
title: "20.6 Project: a text-search tool, part 2"
description: "Finishing minigrep: a search function written test-first, a case-insensitive flag from an environment variable, errors to stderr, and an iterator refactor."
url: "https://learnrust.net/chapter-20/text-search-tool-part-2/"
last_updated: "2026-06-13"
---

# 20.6 Project: a text-search tool, part 2

In part 1 ([20.5](https://learnrust.net/chapter-20/text-search-tool-part-1/)) 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`:

```rust
#[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:

```rust
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:

```bash
$ 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:

```rust
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](https://learnrust.net/chapter-20/environment-variables/) 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:

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

    assert_eq!(
        vec!["Rust:", "Trust me."],
        search_case_insensitive(query, contents)
    );
}
```

```rust
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:

```rust
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](https://learnrust.net/chapter-20/environment-variables/): set the variable to anything and `ignore_case` becomes `true`. Try both:

```bash
$ 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:

```rust
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:

```bash
$ 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?

<details class="solution">
<summary>Show solution</summary>

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."

</details>

**Question #2**

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

<details class="solution">
<summary>Show solution</summary>

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.)

</details>

**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?

<details class="solution">
<summary>Show solution</summary>

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.

</details>

That completes the project and the chapter. The summary and quiz ([20.x](https://learnrust.net/chapter-20/chapter-20-summary-and-quiz/)) review the command-line toolkit and extend `minigrep` one more step.

## Sitemap

See the full [sitemap](https://learnrust.net/sitemap.md) for all pages.
