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:
io::stdin().lock()gives a locked handle to standard input that implementsBufRead, just like aBufReader<File>does. Both fit in aBox<dyn BufRead>, so the rest of the program doesn't care where the text comes from.path.as_deref()turns anOption<String>into anOption<&str>, which is whatmatchwith string literals wants.- If reading line by line isn't needed, the simpler version is
io::stdin().read_to_string(&mut text)(fromstd::io::Read) orio::read_to_string(io::stdin()).
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
- Waiting forever. A tool that reads stdin when no file is given just sits there if a human runs it in a terminal without a pipe; it's waiting for typed input (end it with Ctrl+D). Some tools print a hint when stdin is a terminal:
std::io::IsTerminalhasio::stdin().is_terminal(). - Locking stdin inside a loop (
io::stdin().lock()per line): lock once and read through that handle. - Slow output in big loops.
println!locks stdout on every call. For tools that print millions of lines, lock it once (let mut out = io::stdout().lock();) and usewriteln!(out, ...), or wrap it in aBufWriter.
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