Why build a text search tool?

A text search tool is a useful first Rust project because it combines several core skills without requiring a large codebase:

  • parsing command-line arguments
  • reading files safely and efficiently
  • working with Result and error propagation
  • writing clear, testable functions
  • producing useful terminal output

Unlike a toy example, this project mirrors real developer workflows. You might use it to search logs, inspect source trees, or quickly locate configuration values in a large repository.

What we are building

The tool will:

  • accept a search pattern and one or more file paths
  • print matching lines with line numbers
  • support case-sensitive matching by default
  • optionally perform case-insensitive searches

A sample invocation might look like this:

cargo run -- --ignore-case "error" app.log

Expected output:

42: error connecting to database
87: ERROR retry limit reached

Create the project

Start a new binary crate:

cargo new text_search
cd text_search

Rust projects use Cargo.toml for dependencies and build metadata, and src/main.rs for the executable entry point.

For this tutorial, we will keep dependencies minimal and use only the standard library.

Design the command-line interface

A clean CLI makes the tool easier to use and easier to extend later. We will support this argument shape:

text_search [--ignore-case] <pattern> <file>

For a first version, manual argument parsing is enough. It keeps the example focused on Rust fundamentals and avoids introducing a CLI framework too early.

Argument parsing strategy

We will:

  1. collect the process arguments
  2. detect an optional --ignore-case flag
  3. read the pattern and file path
  4. validate the input before doing any file I/O

This approach keeps errors immediate and predictable.

Implement the search logic

The core of the tool is a function that checks whether a line matches a pattern. We will implement both case-sensitive and case-insensitive modes.

use std::env;
use std::fs::File;
use std::io::{self, BufRead, BufReader};

fn main() {
    if let Err(err) = run() {
        eprintln!("error: {err}");
        std::process::exit(1);
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let config = Config::from_args(env::args())?;
    search_file(&config)?;
    Ok(())
}

struct Config {
    pattern: String,
    file_path: String,
    ignore_case: bool,
}

impl Config {
    fn from_args<I>(args: I) -> Result<Self, &'static str>
    where
        I: IntoIterator<Item = String>,
    {
        let mut args = args.into_iter();
        let _program = args.next();

        let mut ignore_case = false;
        let mut remaining = Vec::new();

        for arg in args {
            if arg == "--ignore-case" {
                ignore_case = true;
            } else {
                remaining.push(arg);
            }
        }

        if remaining.len() != 2 {
            return Err("usage: text_search [--ignore-case] <pattern> <file>");
        }

        Ok(Self {
            pattern: remaining[0].clone(),
            file_path: remaining[1].clone(),
            ignore_case,
        })
    }
}

fn search_file(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
    let file = File::open(&config.file_path)?;
    let reader = BufReader::new(file);

    for (line_number, line_result) in reader.lines().enumerate() {
        let line = line_result?;
        if matches_pattern(&line, &config.pattern, config.ignore_case) {
            println!("{}: {}", line_number + 1, line);
        }
    }

    Ok(())
}

fn matches_pattern(line: &str, pattern: &str, ignore_case: bool) -> bool {
    if ignore_case {
        line.to_lowercase().contains(&pattern.to_lowercase())
    } else {
        line.contains(pattern)
    }
}

Understand the code structure

The example is intentionally small, but each piece has a clear responsibility.

ComponentResponsibilityWhy it matters
mainEntry point and top-level error reportingKeeps the user-facing behavior simple
runOrchestrates parsing and searchingMakes the program easier to test
ConfigStores validated inputSeparates input handling from business logic
search_fileReads the file and prints matchesEncapsulates I/O behavior
matches_patternDetermines whether a line matchesEasy to reuse and unit test

This separation is one of Rust’s strengths. Small functions with explicit inputs and outputs are easier to reason about, especially when error handling is involved.

Run the tool

Create a sample file:

cat > sample.txt <<'EOF'
Rust is fast
Error handling is explicit
The compiler is helpful
EOF

Search for a pattern:

cargo run -- "Rust" sample.txt

Output:

1: Rust is fast

Try case-insensitive matching:

cargo run -- --ignore-case "error" sample.txt

Output:

2: Error handling is explicit

Why BufReader is the right choice

When reading text files, BufReader is usually the correct default. It reduces the number of system calls by buffering input internally, which is especially useful for large files.

Using std::fs::read_to_string can be simpler, but it loads the entire file into memory. That is fine for small inputs, but a line-by-line approach is more scalable and more representative of production tooling.

When to choose each approach

ApproachBest forTrade-offs
read_to_stringSmall files, quick prototypesHigher memory usage
BufReader::lines()Logs, source trees, large text filesSlightly more code
Memory-mapped I/OVery large files, specialized toolsMore complexity, platform concerns

For a beginner-friendly Rust utility, BufReader::lines() is the best balance of simplicity and performance.

Error handling best practices

Rust encourages explicit error handling, and that is a major advantage in CLI tools. In this example, run() returns Result<(), Box<dyn std::error::Error>>, which allows different error types to propagate with ?.

This pattern is practical because:

  • file open failures are reported automatically
  • line-reading errors are not ignored
  • the user sees a clear error message
  • main remains small and readable

Improve the error message

The current version prints a generic error. For a production-quality tool, you can make errors more specific by matching on common failure cases.

For example:

fn main() {
    if let Err(err) = run() {
        eprintln!("text_search failed: {err}");
        std::process::exit(1);
    }
}

You can also distinguish usage errors from I/O errors. A usage error should show a short help message, while an I/O error should explain which file could not be opened.

Add unit tests

A search tool is easy to test because the matching logic is pure. You can verify behavior without touching the file system.

Add tests at the bottom of src/main.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn matches_case_sensitive_text() {
        assert!(matches_pattern("Rust is great", "Rust", false));
        assert!(!matches_pattern("Rust is great", "rust", false));
    }

    #[test]
    fn matches_case_insensitive_text() {
        assert!(matches_pattern("Rust is great", "rust", true));
        assert!(matches_pattern("ERROR: failed", "error", true));
    }
}

Run the tests with:

cargo test

Testing the pure matching function gives you confidence that the core logic works before you worry about file input or terminal output.

Practical improvements for real use

Once the basic version works, there are several useful enhancements you can add.

1. Highlight matches

You can make output easier to scan by coloring the matching portion. This is helpful in terminal workflows, but it should be optional because not all terminals handle ANSI colors the same way.

2. Search multiple files

Instead of accepting one file, accept a list of paths and print the file name before each match. This makes the tool much more useful in a repository.

Example output:

src/main.rs:12: fn main() {
README.md:4: Rust is fast and safe

3. Add context lines

Many search tools print a few lines before and after each match. This is useful when the surrounding text matters, such as in logs or configuration files.

4. Support regular expressions

A literal substring search is simple and fast, but regular expressions are more flexible. If you add regex support later, keep the current literal search path as a fast default.

Common mistakes to avoid

A few issues are common when building this kind of tool:

  • Loading huge files into memory when a streaming reader would be better
  • Ignoring I/O errors from lines()
  • Mixing parsing and searching logic in main
  • Using to_lowercase() on every line repeatedly in performance-sensitive code
  • Printing unstructured output that is hard to consume in scripts

A small amount of structure goes a long way. Even a simple utility benefits from predictable behavior and clean boundaries.

A note on performance

For most beginner projects, correctness and clarity matter more than micro-optimization. Still, it is worth understanding the cost of the current implementation.

The case-insensitive search uses to_lowercase() on both the line and the pattern for each comparison. That is fine for a tutorial, but it allocates new strings repeatedly. If you later need better performance, you can precompute the lowercase pattern once and use a more efficient comparison strategy.

The important lesson is that Rust lets you start with a straightforward implementation and refine it later without changing the overall design.

Next steps

This project is a solid introduction to Rust because it touches the language features you will use constantly in real applications:

  • ownership and borrowing through string and file handling
  • error propagation with Result
  • iterators with lines().enumerate()
  • modular design with small functions
  • testing pure logic separately from I/O

From here, you can evolve the tool into a more complete utility by adding flags, better output formatting, and recursive directory traversal.

Learn more with useful resources