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
-iflag (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
-iflag works.
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.
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.