---
title: "20.5 Project: a text-search tool, part 1"
description: "Building a real grep-like tool: parsing arguments into a Config, reading the file, and splitting logic into lib.rs and a thin main with Result everywhere."
url: "https://learnrust.net/chapter-20/text-search-tool-part-1/"
last_updated: "2026-06-13"
---

# 20.5 Project: a text-search tool, part 1

Time to build something real. We're going to write a small command-line tool that searches a file for a string and prints every line that contains it, a stripped-down `grep` (the name comes from the old editor command *g/re/p*). This is the capstone for the whole "core Rust" half of the course, and it deliberately uses pieces from many earlier chapters: arguments and files and streams from this one, error handling from chapter 12, the `lib.rs`-plus-thin-`main.rs` structure from chapter 13, and in part 2, tests from chapter 14 and iterators from chapter 19. Each was introduced with a promise that it'd matter later. Later is now.

This part gets the tool working: arguments in, file read, the structure right. Part 2 ([20.6](https://learnrust.net/chapter-20/text-search-tool-part-2/)) adds the search itself (written test-first), a case-insensitive option via an environment variable, and an iterator refactor.

## Setting up

Make a new project:

```bash
$ cargo new minigrep
$ cd minigrep
```

We'll need a file to search. Create `poem.txt` in the project root:

```
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
```

The tool will take two arguments, a query and a filename, and print lines containing the query.

## A first, rough version

Start with everything in `main`, just to see the shape, then we'll refactor. This reads the two arguments and the file:

```rust
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();
    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for '{query}' in {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("should have been able to read the file");

    println!("With text:\n{contents}");
}
```

```bash
$ cargo run -- nobody poem.txt
```

```
Searching for 'nobody' in poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
```

It runs. It's also fragile in two ways we already know how to fix. Indexing `args[1]` and `args[2]` panics with an ugly message if the user forgets an argument (lesson [20.1](https://learnrust.net/chapter-20/command-line-arguments/)), and `expect` on the file read crashes with a developer-facing message if the file is missing. Both failures deserve clean, user-facing handling. Let's structure the code so that handling has a natural home.

## Grouping the configuration

Those two arguments belong together, they're the program's configuration, so group them into a struct (chapter 10) instead of leaving them as loose variables. And parsing them is the kind of thing that can fail (too few arguments), so the constructor returns a `Result` (chapter 12):

```rust
struct Config {
    query: String,
    file_path: String,
}

impl Config {
    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();

        Ok(Config { query, file_path })
    }
}
```

`Config::build` checks the argument count *before* indexing, so it never panics; if there aren't enough arguments it returns a descriptive `Err` instead. The error type is `&'static str`, a simple fixed message (the `'static` lifetime from chapter 17, here just "a string literal that lives forever"). The `.clone()`s copy the strings out of `args` so `Config` owns them; for a tool that runs once and exits, that small cost buys simplicity, and part 2 shows how iterators remove even that.

## A thin main that handles errors

Now `main` can read cleanly: build the config, report a bad-arguments error to *stderr* and exit non-zero if that fails, otherwise run. This is where chapter 12's `unwrap_or_else` and chapter 20's stderr convention combine:

```rust
use std::env;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&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);
    }
}
```

`unwrap_or_else` gives us the `Config` on success, or runs the closure on failure, which prints the problem to stderr (lesson [20.3](https://learnrust.net/chapter-20/stdin-stdout-stderr/), diagnostics belong on stderr) and exits with code `1` (failure). The `run` function does the actual work and also returns a `Result`, so its errors get the same treatment. Try it with a missing argument:

```bash
$ cargo run -- nobody
```

```
Problem parsing arguments: not enough arguments
```

and the process exits with code 1. No panic, no backtrace, just a clear message a user can act on.

## Extracting run, and the library split

The work, read the file, do the search, belongs in its own function, `run`, that returns `Result` so file errors propagate with `?` (lesson [12.3](https://learnrust.net/chapter-12/propagating-errors-question-mark-operator/)) instead of crashing:

```rust
use std::error::Error;
use std::fs;

fn run(config: &Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(&config.file_path)?;

    for line in contents.lines() {
        if line.contains(&config.query) {
            println!("{line}");
        }
    }

    Ok(())
}
```

The `?` on `read_to_string` propagates a file-read failure up to `main`, which reports it on stderr. `Box<dyn Error>` is the accept-any-error type you met shown-not-explained in lesson [12.6](https://learnrust.net/chapter-12/custom-error-types/); now it earns its keep, letting `run` propagate file errors and any future error type through one return type. The search itself is, for now, a simple `lines().contains()` loop, part 2 makes it a tested, standalone function.

Here's the structural payoff. Right now everything is in `main.rs`. Per lesson [13.5](https://learnrust.net/chapter-13/binary-and-library-crates/), the logic, `Config`, `run`, and the search, should move to `src/lib.rs`, leaving `src/main.rs` a thin shell that only does argument collection and error reporting. Move `Config`, its `impl`, and `run` into `src/lib.rs`, marking them `pub`, and have `main.rs` bring them in:

```rust
// src/main.rs
use std::env;
use std::process;
use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(&config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}
```

```rust
// src/lib.rs  (Config, its impl, and run, all marked pub)
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }
        Ok(Config {
            query: args[1].clone(),
            file_path: args[2].clone(),
        })
    }
}

pub fn run(config: &Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(&config.file_path)?;
    for line in contents.lines() {
        if line.contains(&config.query) {
            println!("{line}");
        }
    }
    Ok(())
}
```

> **Why split it this way, the chapter-13 promise paid**
>
> Lesson [13.5](https://learnrust.net/chapter-13/binary-and-library-crates/) said to put logic in `lib.rs` and keep `main.rs` thin, and lesson [14.4](https://learnrust.net/chapter-14/integration-tests/) said the reason was testability. Here it is, concrete. `main.rs` now contains only the glue that's hard to test, reading real arguments, calling `process::exit`, while every piece of *logic* (`Config::build`, the search) lives in the library, where part 2's tests can call it directly. The binary became a shell so small there's almost nothing in it left to break. That's not bureaucracy; it's what makes the next lesson's test-first search possible.

The tool works, fails gracefully, and is structured for testing. Run it once more to confirm:

```bash
$ cargo run -- nobody poem.txt
```

```
I'm nobody! Who are you?
Are you nobody, too?
```

## Quiz time

**Question #1**

Why does `Config::build` check `args.len()` and return a `Result` instead of just indexing `args[1]` and `args[2]`?

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

If the user passes too few arguments, indexing `args[1]`/`args[2]` panics with an ugly out-of-bounds message (lesson 20.1). Checking the length first and returning `Err("not enough arguments")` turns that into a clean, recoverable error that `main` can report on stderr with a usage-style message and a non-zero exit code. It's the chapter-12 "handle the expected failure" principle applied to argument parsing.

</details>

**Question #2**

Why does the error reporting use `eprintln!` and `process::exit(1)` rather than `println!` and a normal return?

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

`eprintln!` sends the diagnostic to stderr, so it doesn't contaminate the tool's real output on stdout when someone redirects it (lesson 20.3). `process::exit(1)` sets a non-zero exit code, the convention for "this run failed," which scripts and other programs check. Together they make the tool behave correctly when composed with other commands: clean stdout, errors visible on stderr, and a failure signaled in the exit code.

</details>

**Question #3**

What does moving `Config` and `run` into `src/lib.rs` (leaving `main.rs` thin) buy you?

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

Testability (lessons 13.5 and 14.4). The logic in `lib.rs` can be exercised directly by unit and integration tests, while `main.rs` keeps only the hard-to-test glue, reading real command-line arguments and calling `process::exit`. The binary becomes a thin shell with almost nothing to break, and all the meaningful behavior lives where part 2's test-first search can reach it.

</details>

The tool runs but its search is an untested one-liner buried in `run`. The next lesson ([20.6](https://learnrust.net/chapter-20/text-search-tool-part-2/)) extracts it into a real function written test-first, adds a case-insensitive mode controlled by an environment variable, and refactors the whole thing with iterators.

## Sitemap

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