RustArena

Step 5 of 6

Reading stdin when no file is given

Unix tools follow a convention that makes them combine like building blocks: if no file is given (or the file is -), read standard input. That's what lets you write pipelines:

$ cat app.log | grep ERROR | wc -l
$ grep ERROR app.log | sort | uniq -c

Each | connects one program's stdout to the next one's stdin. Your tool joins the party by accepting both. A clean way: a function that returns a reader for either source, as a trait object (Box<dyn BufRead>), since a file reader and stdin are different types:

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

fn open(path: Option<&str>) -> io::Result<Box<dyn BufRead>> {
    match path {
        None | Some("-") => Ok(Box::new(io::stdin().lock())),
        Some(p) => Ok(Box::new(BufReader::new(File::open(p)?))),
    }
}

fn main() -> io::Result<()> {
    let path = std::env::args().nth(1);
    let reader = open(path.as_deref())?;
    let mut lines = 0;
    let mut longest = 0;
    for line in reader.lines() {
        let line = line?;
        lines += 1;
        longest = longest.max(line.chars().count());
    }
    println!("{lines} lines, longest {longest} chars");
    Ok(())
}
one
three
fifteen
3 lines, longest 7 chars

Piece by piece:

Keep the logic away from the I/O

The most useful habit for testable tools: functions that decide things take &str and return values; only main (or run) touches files, stdin, args and exit codes.

fn matching_lines<'a>(text: &'a str, pattern: &str) -> Vec<(usize, &'a str)> {
    text.lines()
        .enumerate()
        .filter(|(_, line)| line.contains(pattern))
        .map(|(i, line)| (i + 1, line))
        .collect()
}

fn main() {
    let text = "INFO boot\nERROR disk\nINFO ok\nERROR net";
    for (n, line) in matching_lines(text, "ERROR") {
        println!("{n}:{line}");
    }
}
2:ERROR disk
4:ERROR net

matching_lines can be tested with a string literal in a unit test: no files, no processes.

Common mistakes

Your task

Write find [-i] [-n] <pattern> [file], a small grep. Print every line containing the pattern (-i: ignore case, compare the lowercase versions; -n: prefix each line with its line number and :). Read the file if one is given (and isn't -), otherwise standard input. Exit codes like grep: 0 if at least one line matched, 1 if none did, 2 for errors (no pattern, an unknown flag, a file that can't be read), with a message on stderr.

Challenge 2

Write uniq [-c] [file]: print the input with consecutive duplicate lines collapsed into one. With -c, prefix each line with how many times it repeated and a space (3 ERROR). Read the file if given, otherwise standard input. A file that can't be read prints uniq: <name>: <error> to stderr and exits with code 1.

Challenge 3

Keep logic away from I/O: write fn search<'a>(text: &'a str, pattern: &str, ignore_case: bool) -> Vec<(usize, &'a str)> that returns the matching lines with their 1-based line numbers. With ignore_case, compare to_lowercase() versions (which also handles non-ASCII letters like É).

Previous: Reading files Next: Putting it together: Config, run() and clap