26. Terminal project: rgrep, a tested grep clone
A small but real CLI: library and binary split, typed config, errors with context, streaming I/O, and tests.
// src/bin/rgrep.rs usage: rgrep [-i] [-n] [-v] [-c] PATTERN [FILE...]
use anyhow::{bail, Context, Result};
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
#[derive(Debug, Default, PartialEq)]
struct Config {
pattern: String,
files: Vec<String>,
ignore_case: bool,
line_numbers: bool,
invert: bool,
count_only: bool,
}
fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Config> {
let mut cfg = Config::default();
let mut positional = Vec::new();
for arg in args {
match arg.as_str() {
"-i" => cfg.ignore_case = true,
"-n" => cfg.line_numbers = true,
"-v" => cfg.invert = true,
"-c" => cfg.count_only = true,
s if s.starts_with('-') && s.len() > 1 => bail!("unknown flag {s}"),
_ => positional.push(arg),
}
}
let mut it = positional.into_iter();
cfg.pattern = it.next().context("missing PATTERN")?;
cfg.files = it.collect();
Ok(cfg)
}
/// Core logic, generic over any reader and writer, so tests need no files.
fn search<R: BufRead, W: Write>(cfg: &Config, label: Option<&str>, input: R, out: &mut W) -> Result<usize> {
let needle = if cfg.ignore_case { cfg.pattern.to_lowercase() } else { cfg.pattern.clone() };
let mut matches = 0;
for (i, line) in input.lines().enumerate() {
let line = line?;
let hay = if cfg.ignore_case { line.to_lowercase() } else { line.clone() };
if hay.contains(&needle) != cfg.invert {
matches += 1;
if !cfg.count_only {
if let Some(l) = label { write!(out, "{l}:")?; }
if cfg.line_numbers { write!(out, "{}:", i + 1)?; }
writeln!(out, "{line}")?;
}
}
}
Ok(matches)
}
fn main() -> Result<()> {
let cfg = parse_args(std::env::args().skip(1))?;
let stdout = io::stdout();
let mut out = BufWriter::new(stdout.lock());
let mut total = 0;
if cfg.files.is_empty() {
total += search(&cfg, None, io::stdin().lock(), &mut out)?;
} else {
let label_files = cfg.files.len() > 1;
for path in &cfg.files {
let f = File::open(path).with_context(|| format!("opening {path}"))?;
total += search(&cfg, label_files.then_some(path.as_str()), BufReader::new(f), &mut out)?;
}
}
if cfg.count_only { writeln!(out, "{total}")?; }
out.flush()?;
std::process::exit(if total > 0 { 0 } else { 1 }); // grep's convention: 1 = no match
}
#[cfg(test)]
mod tests {
use super::*;
fn run(args: &[&str], input: &str) -> (String, usize) {
let cfg = parse_args(args.iter().map(|s| s.to_string())).unwrap();
let mut out = Vec::new();
let n = search(&cfg, None, input.as_bytes(), &mut out).unwrap();
(String::from_utf8(out).unwrap(), n)
}
#[test]
fn basic_and_flags() {
let text = "Error: disk\ninfo: ok\nERROR: net\n";
assert_eq!(run(&["Error"], text), ("Error: disk\n".into(), 1));
assert_eq!(run(&["-i", "error"], text).1, 2);
assert_eq!(run(&["-n", "-v", "-i", "error"], text).0, "2:info: ok\n");
assert_eq!(run(&["-c", "o"], text), (String::new(), 2)); // case-sensitive: "ERROR" has no 'o'
}
#[test]
fn arg_errors() {
assert!(parse_args(Vec::<String>::new()).is_err());
assert!(parse_args(["-z".to_string(), "x".into()]).is_err());
}
}
Stretch goals: add regex support with the regex crate. It guarantees
linear-time matching, so it is safe against ReDoS (see
Security in Depth Ch 40A).
Add recursive directory search with walkdir. Then compare your tool with
ripgrep, which is the same idea taken to production.