Guide contents

Lesson 2 of 12

grep-Style Search Tool

We build seek, a small version of grep: it searches files, directories, or standard input for lines matching a regular expression.

Text
$ seek -i -n todo src
src/main.rs:2:    // TODO: parse arguments
src/net.rs:1:// todo: retry on timeout

$ seek -c -i todo src
src/main.rs:1
src/net.rs:1

What it supports:

Option Meaning
PATTERN a regular expression (with the regex crate)
PATHS... files and directories to search; directories are searched recursively; none means stdin
-F, --fixed-strings treat the pattern as plain text
-i, --ignore-case ignore upper/lower case
-v, --invert-match show lines that don't match
-n, --line-number show line numbers
-c, --count show only the number of matching lines per file

Like grep, it skips hidden files and binary files, keeps going when a file can't be read, and exits with 0 if something matched, 1 if nothing matched, and 2 if there was an error.

The design follows the advice from Files, I/O & the Command Line: each piece takes its inputs as parameters (impl BufRead, impl Write, paths) so it can be tested without a terminal, and main only connects the pieces to the real world.

The project layout

Terminal
cargo new seek
cd seek
cargo add clap --features derive
cargo add regex
TOMLCargo.toml
[package]
name = "seek"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4", features = ["derive"] }
regex = "1"

The logic goes into a library crate, src/lib.rs, and src/main.rs stays a few lines long. That split, from Crates, Packages & Workspaces, lets integration tests call the library directly.

Text
seek/
├── Cargo.toml
└── src/
    ├── main.rs      # parse arguments, call seek::run, set the exit code
    ├── lib.rs       # run(): ties the pieces together
    ├── matcher.rs   # step 1
    ├── search.rs    # step 2
    └── files.rs     # steps 3 and 4

While developing, the steps below are self-contained so each can be compiled and tested on its own. Step 6 assembles them into one runnable program.

Step 1: The matcher

The matcher decides whether a line matches. It compiles the pattern once with regex::RegexBuilder; for -F, regex::escape turns the text into a pattern that matches it literally. --invert-match flips the answer, and != with a bool is exactly "flip if invert is true".

Rustcargo test
use regex::{Regex, RegexBuilder};

pub struct MatchOptions {
    pub fixed_string: bool,
    pub ignore_case: bool,
    pub invert: bool,
}

pub struct Matcher {
    regex: Regex,
    invert: bool,
}

impl Matcher {
    pub fn new(pattern: &str, opts: &MatchOptions) -> Result<Matcher, regex::Error> {
        let pattern = if opts.fixed_string { regex::escape(pattern) } else { pattern.to_string() };
        let regex = RegexBuilder::new(&pattern).case_insensitive(opts.ignore_case).build()?;
        Ok(Matcher { regex, invert: opts.invert })
    }

    pub fn is_match(&self, line: &str) -> bool {
        self.regex.is_match(line) != self.invert
    }
}

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

    fn opts(fixed_string: bool, ignore_case: bool, invert: bool) -> MatchOptions {
        MatchOptions { fixed_string, ignore_case, invert }
    }

    #[test]
    fn patterns_are_regexes_by_default() {
        let m = Matcher::new(r"err(or)?\b", &opts(false, false, false)).unwrap();
        assert!(m.is_match("error: disk full"));
        assert!(m.is_match("err 42"));
        assert!(!m.is_match("errand"));
    }

    #[test]
    fn fixed_strings_match_literally() {
        let m = Matcher::new("a.b", &opts(true, false, false)).unwrap();
        assert!(m.is_match("see a.b here"));
        assert!(!m.is_match("axb"));
    }

    #[test]
    fn ignore_case_and_invert() {
        let m = Matcher::new("todo", &opts(false, true, true)).unwrap();
        assert!(!m.is_match("// TODO: fix"));
        assert!(m.is_match("let x = 1;"));
    }

    #[test]
    fn invalid_patterns_are_errors() {
        assert!(Matcher::new("(unclosed", &opts(false, false, false)).is_err());
    }
}
Output
running 4 tests
test tests::fixed_strings_match_literally ... ok
test tests::ignore_case_and_invert ... ok
test tests::invalid_patterns_are_errors ... ok
test tests::patterns_are_regexes_by_default ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Step 2: Searching one input

search reads lines from any BufRead, writes the matches to any Write, and returns how many lines matched. It takes the matching rule as a closure rather than a Matcher, so it doesn't depend on step 1 at all, and the tests can use a simple contains.

Rustcargo test
use std::io::{self, BufRead, Write};

pub struct OutputOptions {
    pub line_numbers: bool,
    pub count_only: bool,
}

/// Writes the lines of `input` that match to `out`, and returns how many matched.
pub fn search(
    input: impl BufRead,
    label: Option<&str>,
    is_match: impl Fn(&str) -> bool,
    opts: &OutputOptions,
    out: &mut impl Write,
) -> io::Result<usize> {
    let mut matches = 0;
    for (index, line) in input.lines().enumerate() {
        let line = line?;
        if !is_match(&line) {
            continue;
        }
        matches += 1;
        if opts.count_only {
            continue;
        }
        if let Some(label) = label {
            write!(out, "{label}:")?;
        }
        if opts.line_numbers {
            write!(out, "{}:", index + 1)?;
        }
        writeln!(out, "{line}")?;
    }
    if opts.count_only {
        match label {
            Some(label) => writeln!(out, "{label}:{matches}")?,
            None => writeln!(out, "{matches}")?,
        }
    }
    Ok(matches)
}

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

    const CODE: &str = "fn main() {\n    // TODO: parse args\n    run();\n    // TODO: exit code\n}\n";

    fn run_search(label: Option<&str>, opts: OutputOptions) -> (usize, String) {
        let mut out = Vec::new();
        let n = search(CODE.as_bytes(), label, |line| line.contains("TODO"), &opts, &mut out).unwrap();
        (n, String::from_utf8(out).unwrap())
    }

    #[test]
    fn prints_matching_lines() {
        let (n, out) = run_search(None, OutputOptions { line_numbers: false, count_only: false });
        assert_eq!(n, 2);
        assert_eq!(out, "    // TODO: parse args\n    // TODO: exit code\n");
    }

    #[test]
    fn adds_labels_and_line_numbers() {
        let (_, out) = run_search(Some("src/main.rs"), OutputOptions { line_numbers: true, count_only: false });
        assert_eq!(out, "src/main.rs:2:    // TODO: parse args\nsrc/main.rs:4:    // TODO: exit code\n");
    }

    #[test]
    fn counts_instead_of_printing() {
        let (n, out) = run_search(Some("main.rs"), OutputOptions { line_numbers: true, count_only: true });
        assert_eq!((n, out.as_str()), (2, "main.rs:2\n"));
    }
}
Output
running 3 tests
test tests::adds_labels_and_line_numbers ... ok
test tests::counts_instead_of_printing ... ok
test tests::prints_matching_lines ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Step 3: Collecting the files

The arguments can be files or directories. collect_files expands directories recursively into the files inside them, sorted so the output is stable, and skips hidden entries like .git. A path that can't be read doesn't stop the search: the problem is recorded and reported at the end, as grep does.

Rustcargo test
use std::fs;
use std::path::{Path, PathBuf};

fn is_hidden(path: &Path) -> bool {
    path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))
}

/// Expands directories into the files inside them, skipping hidden entries.
pub fn collect_files(paths: &[PathBuf], files: &mut Vec<PathBuf>, problems: &mut Vec<String>) {
    for path in paths {
        match fs::metadata(path) {
            Ok(meta) if meta.is_dir() => walk(path, files, problems),
            Ok(_) => files.push(path.clone()),
            Err(e) => problems.push(format!("{}: {e}", path.display())),
        }
    }
}

fn walk(dir: &Path, files: &mut Vec<PathBuf>, problems: &mut Vec<String>) {
    let entries = match fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(e) => {
            problems.push(format!("{}: {e}", dir.display()));
            return;
        }
    };
    let mut children: Vec<(PathBuf, bool)> = Vec::new();
    for entry in entries.flatten() {
        let is_dir = entry.file_type().is_ok_and(|t| t.is_dir()); // doesn't follow symlinks
        if !is_hidden(&entry.path()) {
            children.push((entry.path(), is_dir));
        }
    }
    children.sort();
    for (path, is_dir) in children {
        if is_dir {
            walk(&path, files, problems);
        } else {
            files.push(path);
        }
    }
}

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

    #[test]
    fn walks_directories_and_skips_hidden_entries() {
        let root = std::env::temp_dir().join(format!("seek-walk-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root);
        for file in ["src/main.rs", "src/net/http.rs", "src/.env", ".git/config", "README.md"] {
            let path = root.join(file);
            fs::create_dir_all(path.parent().unwrap()).unwrap();
            fs::write(path, "x").unwrap();
        }

        let (mut files, mut problems) = (Vec::new(), Vec::new());
        collect_files(&[root.clone(), root.join("missing.txt")], &mut files, &mut problems);

        let relative: Vec<String> = files.iter().map(|f| f.strip_prefix(&root).unwrap().display().to_string()).collect();
        assert_eq!(relative, ["README.md", "src/main.rs", "src/net/http.rs"]);
        assert_eq!(problems.len(), 1);
        assert!(problems[0].contains("missing.txt"));
        fs::remove_dir_all(&root).unwrap();
    }
}
Output
running 1 test
test tests::walks_directories_and_skips_hidden_entries ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

entries.flatten() skips entries that fail to read. file_type() reports a symbolic link as a link rather than following it, so a link pointing back up the tree can't make the walk run forever.

Step 4: Skipping binary files

Searching an image or a compiled binary prints garbage. grep treats a file as binary if its beginning contains a zero byte, which text files practically never do. The same check is a one-liner:

Rustcargo test
/// Treats data as binary if its first 8 KB contain a zero byte, as grep does.
pub fn looks_binary(bytes: &[u8]) -> bool {
    bytes.iter().take(8 * 1024).any(|&b| b == 0)
}

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

    #[test]
    fn text_is_not_binary() {
        assert!(!looks_binary(b"fn main() {}\n"));
        assert!(!looks_binary("naïve café 🦀".as_bytes()));
        assert!(!looks_binary(b""));
    }

    #[test]
    fn a_zero_byte_means_binary() {
        assert!(looks_binary(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"));
    }
}
Output
running 2 tests
test tests::a_zero_byte_means_binary ... ok
test tests::text_is_not_binary ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Files that aren't valid UTF-8 but also aren't binary, such as old Latin-1 text, are searched after String::from_utf8_lossy, which replaces the invalid bytes instead of failing.

Step 5: The command-line interface

The options become a clap struct. Short flags can be combined, as in -in, and --help is generated from the doc comments.

Rust
use clap::Parser;
use std::path::PathBuf;

/// Search files for lines that match a pattern.
#[derive(Parser, Debug)]
#[command(name = "seek")]
pub struct Args {
    /// The regular expression to search for
    pub pattern: String,

    /// Files or directories to search (default: standard input)
    pub paths: Vec<PathBuf>,

    /// Treat the pattern as plain text, not a regular expression
    #[arg(short = 'F', long)]
    pub fixed_strings: bool,

    /// Ignore the difference between upper and lower case
    #[arg(short, long)]
    pub ignore_case: bool,

    /// Show the lines that do not match
    #[arg(short = 'v', long)]
    pub invert_match: bool,

    /// Show line numbers
    #[arg(short = 'n', long)]
    pub line_number: bool,

    /// Only print how many lines matched in each file
    #[arg(short, long)]
    pub count: bool,
}

fn main() {
    println!("{:?}\n", Args::parse_from(["seek", "-in", "todo", "src", "tests"]));
    if let Err(e) = Args::try_parse_from(["seek", "--help"]) {
        print!("{e}");
    }
}
Output
Args { pattern: "todo", paths: ["src", "tests"], fixed_strings: false, ignore_case: true, invert_match: false, line_number: true, count: false }

Search files for lines that match a pattern

Usage: seek [OPTIONS] <PATTERN> [PATHS]...

Arguments:
  <PATTERN>   The regular expression to search for
  [PATHS]...  Files or directories to search (default: standard input)

Options:
  -F, --fixed-strings  Treat the pattern as plain text, not a regular expression
  -i, --ignore-case    Ignore the difference between upper and lower case
  -v, --invert-match   Show the lines that do not match
  -n, --line-number    Show line numbers
  -c, --count          Only print how many lines matched in each file
  -h, --help           Print help

Step 6: The complete program

run connects the pieces: it builds the matcher, searches standard input when no paths are given, and otherwise collects the files, skips binary ones, and searches each. File names are shown when the command names more than one path or a directory, as grep does. At the end it reports problems to the error stream and picks the exit code.

The main below sets up a small demo project and runs several commands against it, with the error stream sent to stdout so this page can show everything.

Rust
use clap::Parser;
use regex::{Regex, RegexBuilder};
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};

// ---- matcher.rs (step 1) ----

pub struct MatchOptions {
    pub fixed_string: bool,
    pub ignore_case: bool,
    pub invert: bool,
}

pub struct Matcher {
    regex: Regex,
    invert: bool,
}

impl Matcher {
    pub fn new(pattern: &str, opts: &MatchOptions) -> Result<Matcher, regex::Error> {
        let pattern = if opts.fixed_string { regex::escape(pattern) } else { pattern.to_string() };
        let regex = RegexBuilder::new(&pattern).case_insensitive(opts.ignore_case).build()?;
        Ok(Matcher { regex, invert: opts.invert })
    }

    pub fn is_match(&self, line: &str) -> bool {
        self.regex.is_match(line) != self.invert
    }
}

// ---- search.rs (step 2) ----

pub struct OutputOptions {
    pub line_numbers: bool,
    pub count_only: bool,
}

pub fn search(
    input: impl BufRead,
    label: Option<&str>,
    is_match: impl Fn(&str) -> bool,
    opts: &OutputOptions,
    out: &mut impl Write,
) -> io::Result<usize> {
    let mut matches = 0;
    for (index, line) in input.lines().enumerate() {
        let line = line?;
        if !is_match(&line) {
            continue;
        }
        matches += 1;
        if opts.count_only {
            continue;
        }
        if let Some(label) = label {
            write!(out, "{label}:")?;
        }
        if opts.line_numbers {
            write!(out, "{}:", index + 1)?;
        }
        writeln!(out, "{line}")?;
    }
    if opts.count_only {
        match label {
            Some(label) => writeln!(out, "{label}:{matches}")?,
            None => writeln!(out, "{matches}")?,
        }
    }
    Ok(matches)
}

// ---- files.rs (steps 3 and 4) ----

fn is_hidden(path: &Path) -> bool {
    path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))
}

pub fn collect_files(paths: &[PathBuf], files: &mut Vec<PathBuf>, problems: &mut Vec<String>) {
    for path in paths {
        match fs::metadata(path) {
            Ok(meta) if meta.is_dir() => walk(path, files, problems),
            Ok(_) => files.push(path.clone()),
            Err(e) => problems.push(format!("{}: {e}", path.display())),
        }
    }
}

fn walk(dir: &Path, files: &mut Vec<PathBuf>, problems: &mut Vec<String>) {
    let entries = match fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(e) => {
            problems.push(format!("{}: {e}", dir.display()));
            return;
        }
    };
    let mut children: Vec<(PathBuf, bool)> = Vec::new();
    for entry in entries.flatten() {
        let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
        if !is_hidden(&entry.path()) {
            children.push((entry.path(), is_dir));
        }
    }
    children.sort();
    for (path, is_dir) in children {
        if is_dir {
            walk(&path, files, problems);
        } else {
            files.push(path);
        }
    }
}

pub fn looks_binary(bytes: &[u8]) -> bool {
    bytes.iter().take(8 * 1024).any(|&b| b == 0)
}

// ---- lib.rs: the command-line interface (step 5) and run ----

/// Search files for lines that match a pattern.
#[derive(Parser, Debug)]
#[command(name = "seek")]
pub struct Args {
    /// The regular expression to search for
    pub pattern: String,
    /// Files or directories to search (default: standard input)
    pub paths: Vec<PathBuf>,
    /// Treat the pattern as plain text, not a regular expression
    #[arg(short = 'F', long)]
    pub fixed_strings: bool,
    /// Ignore the difference between upper and lower case
    #[arg(short, long)]
    pub ignore_case: bool,
    /// Show the lines that do not match
    #[arg(short = 'v', long)]
    pub invert_match: bool,
    /// Show line numbers
    #[arg(short = 'n', long)]
    pub line_number: bool,
    /// Only print how many lines matched in each file
    #[arg(short, long)]
    pub count: bool,
}

/// Runs a search and returns the exit code: 0 = found, 1 = not found, 2 = error.
pub fn run(args: &Args, out: &mut impl Write, err: &mut impl Write) -> u8 {
    let match_opts = MatchOptions { fixed_string: args.fixed_strings, ignore_case: args.ignore_case, invert: args.invert_match };
    let matcher = match Matcher::new(&args.pattern, &match_opts) {
        Ok(m) => m,
        Err(e) => {
            let _ = writeln!(err, "seek: invalid pattern: {e}");
            return 2;
        }
    };
    let is_match = |line: &str| matcher.is_match(line);
    let output = OutputOptions { line_numbers: args.line_number, count_only: args.count };

    // No paths: search standard input, like grep.
    if args.paths.is_empty() {
        return match search(io::stdin().lock(), None, &is_match, &output, out) {
            Ok(0) => 1,
            Ok(_) => 0,
            Err(e) => {
                let _ = writeln!(err, "seek: {e}");
                2
            }
        };
    }

    let (mut files, mut problems) = (Vec::new(), Vec::new());
    collect_files(&args.paths, &mut files, &mut problems);
    let show_names = args.paths.len() > 1 || args.paths.iter().any(|p| p.is_dir());

    let mut total = 0;
    for path in &files {
        let bytes = match fs::read(path) {
            Ok(bytes) => bytes,
            Err(e) => {
                problems.push(format!("{}: {e}", path.display()));
                continue;
            }
        };
        if looks_binary(&bytes) {
            continue;
        }
        let text = String::from_utf8_lossy(&bytes);
        let label = path.display().to_string();
        match search(text.as_bytes(), show_names.then_some(label.as_str()), &is_match, &output, out) {
            Ok(n) => total += n,
            Err(e) => problems.push(format!("{label}: {e}")),
        }
    }

    for problem in &problems {
        let _ = writeln!(err, "seek: {problem}");
    }
    match (problems.is_empty(), total) {
        (false, _) => 2,
        (true, 0) => 1,
        (true, _) => 0,
    }
}

// ---- main.rs, set up for this page ----

fn main() -> io::Result<()> {
    let _ = fs::remove_dir_all("demo");
    for (file, text) in [
        ("demo/src/main.rs", "fn main() {\n    // TODO: parse arguments\n    run();\n}\n"),
        ("demo/src/net.rs", "// todo: retry on timeout\npub fn fetch() {}\n"),
        ("demo/README.md", "# Demo\nBuild with `cargo build`.\n"),
        ("demo/.git/notes", "TODO: hidden, so never searched\n"),
    ] {
        fs::create_dir_all(Path::new(file).parent().unwrap())?;
        fs::write(file, text)?;
    }
    fs::write("demo/logo.png", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR TODO")?;

    let mut stdout = io::stdout();
    for command in [
        "seek TODO demo",
        "seek -in todo demo/src",
        "seek -ci todo demo",
        "seek -F `cargo demo/README.md",
        "seek -v fn demo/src/main.rs",
        "seek xyz demo/README.md",
        "seek -i todo demo/missing.rs demo/src/net.rs",
        "seek (unclosed demo",
    ] {
        println!("$ {command}");
        // A shell would also handle quotes; splitting on spaces is enough here.
        let code = match Args::try_parse_from(command.split_whitespace()) {
            Ok(args) => run(&args, &mut stdout, &mut io::stdout()),
            Err(e) => {
                print!("{e}");
                2
            }
        };
        println!("(exit code {code})\n");
    }
    Ok(())
}
Output
$ seek TODO demo
demo/src/main.rs:    // TODO: parse arguments
(exit code 0)

$ seek -in todo demo/src
demo/src/main.rs:2:    // TODO: parse arguments
demo/src/net.rs:1:// todo: retry on timeout
(exit code 0)

$ seek -ci todo demo
demo/README.md:0
demo/src/main.rs:1
demo/src/net.rs:1
(exit code 0)

$ seek -F `cargo demo/README.md
Build with `cargo build`.
(exit code 0)

$ seek -v fn demo/src/main.rs
    // TODO: parse arguments
    run();
}
(exit code 0)

$ seek xyz demo/README.md
(exit code 1)

$ seek -i todo demo/missing.rs demo/src/net.rs
demo/src/net.rs:// todo: retry on timeout
seek: demo/missing.rs: No such file or directory (os error 2)
(exit code 2)

$ seek (unclosed demo
seek: invalid pattern: regex parse error:
    (unclosed
    ^
error: unclosed group
(exit code 2)

The first command finds TODO in main.rs only: net.rs has a lowercase todo, the hidden .git directory is skipped, and so is logo.png, which contains TODO but is binary. With a missing file, the other file is still searched, and the exit code is 2 because of the error.

Step 7: The real main

In the actual project, main.rs is the only part that touches the real process: it parses env::args(), passes the real streams, and turns the code into an ExitCode. Everything it calls is in the library and already tested.

Rustsrc/main.rs
use clap::Parser;
use std::io;
use std::process::ExitCode;

fn main() -> ExitCode {
    let args = seek::Args::parse(); // prints help or an error and exits with code 2 on bad arguments
    let code = seek::run(&args, &mut io::stdout().lock(), &mut io::stderr().lock());
    ExitCode::from(code)
}
Terminal
cargo run -- -in todo src
cargo install --path .   # installs `seek` into ~/.cargo/bin
seek -c fn src

An integration test in tests/cli.rs can call seek::run with a Vec<u8> for each stream, exactly as the demo main above does, and check both the output and the exit code.

Ideas for extending it

  • Colors: highlight the match with ANSI colors when io::stdout().is_terminal(), using a crate such as anstream or owo-colors.
  • Context lines: -A 2 and -B 2 to print lines around each match, keeping the previous lines in a VecDeque.
  • .gitignore support: replace collect_files with the ignore crate, which ripgrep uses.
  • Speed: search several files at once with rayon, and search bytes instead of Strings with regex::bytes.

Summary

  • The logic lives in small functions that take impl BufRead, impl Write, closures, and paths as parameters, each with its own unit tests.
  • Errors on individual files are collected and reported without stopping the run; the exit code tells scripts what happened.
  • main does nothing but connect the library to env::args(), the real streams, and the exit code.

Next: Expression Calculator.