20.5Project: a text-search tool, part 1

Last updated June 13, 2026

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

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

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}");
}
$ 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), 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):

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:

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, 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:

$ 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) instead of crashing:

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; 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, 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:

// 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);
    }
}
// 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 said to put logic in lib.rs and keep main.rs thin, and lesson 14.4 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:

$ 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]?

Show solution

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.

Question #2

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

Show solution

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.

Question #3

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

Show solution

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.

The tool runs but its search is an untested one-liner buried in run. The next lesson (20.6) 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.