Project · after Level 3

rustle: a mini grep 🔍

grep has been finding text in files since 1973, and today the fastest one in the world (ripgrep) is written in Rust. You are going to build the humble family starter: rustle, with line numbers, a flag, and a search core you can prove correct.

📋 The spec

$ cargo run -- rust log.txt -i
   1: Rust is safe
   2: trust the compiler
2 matching line(s) in log.txt

Requirements:

  • Arguments: a pattern, a file path, and an optional -i flag (any position) for case-insensitive search.
  • Matching lines print with right-aligned line numbers, followed by a match count.
  • Missing arguments or an unreadable file: message on stderr, exit code 1.
  • The search logic is a pure function with at least three unit tests, including one proving the -i flag works.
📖 A rite of passage The Rust Book's chapter 12 builds a minigrep too, and this project makes a great companion to it: same spirit, different design decisions. Building yours first makes reading theirs twice as interesting.

The one new thing: a lifetime you actually need

Lesson 13 promised lifetimes would eventually matter in your own code. Today is the day. Your search function takes the text and returns slices of that same text, and the signature must say so:

fn search<'a>(pattern: &str, text: &'a str, ignore_case: bool) -> Vec<(usize, &'a str)>

Read it aloud: "the returned line slices live as long as text does, and notably not as long as pattern." Without the 'a, the compiler cannot tell which input the output borrows from. With it, the whole function returns matches without copying a single line. That is the zero-copy trick underlying ripgrep's speed, in one signature.

Hints, in order of desperation

Hint 1: How do I handle the -i flag anywhere in the args?

Separate flags from positional arguments in one pass, then check you got exactly two positionals:

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

fn parse_args(args: &[String]) -> Result<Config, String> {
    let mut positional = Vec::new();
    let mut ignore_case = false;
    for arg in &args[1..] {
        if arg == "-i" {
            ignore_case = true;
        } else {
            positional.push(arg.clone());
        }
    }
    match positional.len() {
        2 => Ok(Config {
            pattern: positional[0].clone(),
            path: positional[1].clone(),
            ignore_case,
        }),
        _ => Err(String::from("usage: rustle <pattern> <file> [-i]")),
    }
}

This is how every argument parser works underneath, and writing one by hand once makes clap feel earned instead of magical.

Hint 2: The search function

enumerate numbers the lines (from 0, so add 1), and one iterator chain does the rest:

fn search<'a>(pattern: &str, text: &'a str, ignore_case: bool) -> Vec<(usize, &'a str)> {
    let needle = if ignore_case { pattern.to_lowercase() } else { pattern.to_string() };
    text.lines()
        .enumerate()
        .filter(|(_, line)| {
            if ignore_case {
                line.to_lowercase().contains(&needle)
            } else {
                line.contains(&needle)
            }
        })
        .map(|(i, line)| (i + 1, line))
        .collect()
}

Lowercasing the needle once outside the loop instead of per line: a tiny Lesson 23 instinct already paying off.

Hint 3: The full reference solution
use std::env;
use std::fs;
use std::process;

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

fn parse_args(args: &[String]) -> Result<Config, String> {
    let mut positional = Vec::new();
    let mut ignore_case = false;
    for arg in &args[1..] {
        if arg == "-i" {
            ignore_case = true;
        } else {
            positional.push(arg.clone());
        }
    }
    match positional.len() {
        2 => Ok(Config {
            pattern: positional[0].clone(),
            path: positional[1].clone(),
            ignore_case,
        }),
        _ => Err(String::from("usage: rustle <pattern> <file> [-i]")),
    }
}

/// Return (line number, line) for every line containing the pattern.
fn search<'a>(pattern: &str, text: &'a str, ignore_case: bool) -> Vec<(usize, &'a str)> {
    let needle = if ignore_case { pattern.to_lowercase() } else { pattern.to_string() };
    text.lines()
        .enumerate()
        .filter(|(_, line)| {
            if ignore_case {
                line.to_lowercase().contains(&needle)
            } else {
                line.contains(&needle)
            }
        })
        .map(|(i, line)| (i + 1, line))
        .collect()
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let config = match parse_args(&args) {
        Ok(c) => c,
        Err(msg) => {
            eprintln!("{msg}");
            process::exit(1);
        }
    };

    let text = match fs::read_to_string(&config.path) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("could not read {}: {e}", config.path);
            process::exit(1);
        }
    };

    let matches = search(&config.pattern, &text, config.ignore_case);
    for (number, line) in &matches {
        println!("{number:>4}: {line}");
    }
    println!("{} matching line(s) in {}", matches.len(), config.path);
}

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

    #[test]
    fn finds_exact_matches() {
        let text = "safe fast\nproductive\nfast and safe";
        assert_eq!(search("fast", text, false), vec![(1, "safe fast"), (3, "fast and safe")]);
    }

    #[test]
    fn case_sensitive_by_default() {
        assert_eq!(search("Rust", "rust\ntrust", false), Vec::<(usize, &str)>::new());
    }

    #[test]
    fn ignore_case_flag_works() {
        let text = "Rust\ntrust\nRUSTY";
        assert_eq!(search("rust", text, true), vec![(1, "Rust"), (2, "trust"), (3, "RUSTY")]);
    }

    #[test]
    fn parse_args_rejects_missing() {
        let args = vec!["rustle".to_string(), "pattern".to_string()];
        assert!(parse_args(&args).is_err());
    }
}

Try it on real prey: cargo run -- fn src/main.rs searches its own source. A tool that can read itself always feels like a milestone.

Stretch goals

Make it yours

  • -n to count only. Print just the number, like grep -c.
  • -v to invert. Show lines that do NOT match. One !, if your design is clean.
  • Multiple files. Search every file given, prefixing matches with the filename.
  • Highlight the match. Wrap the matched part in ANSI color codes (\x1b[31m ... \x1b[0m) and feel like a real tool.
  • Then meet the champion. Install ripgrep and read its README with new eyes: everything it brags about, you now have vocabulary for.