Make Your Rust CLI Pipe-Friendly: stdin, stdout and Broken Pipes
Read from a file or from stdin, write efficiently, and exit quietly when someone runs your tool through head.
The best command-line tools compose. They read from a file or from a pipe, and they behave when their output is piped into something else. Getting that right in Rust takes only a few lines, but there's one classic trap: the broken pipe.
Read from a file or stdin
Here's a small tool, shout, that uppercases its input. With a path argument it reads that file; without one it reads stdin.
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, IsTerminal, Write};
fn run() -> io::Result<()> {
let path = std::env::args().nth(1);
let reader: Box<dyn BufRead> = match path {
Some(p) => Box::new(BufReader::new(File::open(p)?)),
None if io::stdin().is_terminal() => {
eprintln!("usage: shout <file> (or pipe text into stdin)");
std::process::exit(2);
}
None => Box::new(io::stdin().lock()),
};
let mut out = BufWriter::new(io::stdout().lock());
for line in reader.lines() {
writeln!(out, "{}", line?.to_uppercase())?;
}
out.flush()
}
A few details are worth knowing:
Box<dyn BufRead>lets both branches share one loop, whether the source is a file or stdin.IsTerminal(stable since Rust 1.70) tells you whether stdin is an interactive terminal. Without that check, runningshoutwith no arguments would sit there silently waiting for input, which looks like a hang.stdoutis line-buffered, so wrapping it in aBufWritercuts down on system calls when you print many lines.
The broken pipe trap
Now try shout big.log | head -n 3. head prints three lines and exits, closing the pipe, and your next write fails. Rust's runtime ignores SIGPIPE, so instead of being killed silently like a typical Unix tool, your program gets a BrokenPipe error. With println!, that error becomes a panic. With writeln! and ?, it bubbles up as an Err.
That Err isn't a real failure. The reader simply got what it needed. So treat it as a normal exit in main:
fn main() {
if let Err(e) = run() {
if e.kind() == io::ErrorKind::BrokenPipe {
return; // downstream (e.g. `head`) closed the pipe, which is fine
}
eprintln!("shout: {e}");
std::process::exit(1);
}
}
Now the tool works in all three modes:
shout notes.txt
printf 'hello\nworld\n' | shout
shout big.log | head -n 3
Reading from stdin, writing through a buffer, and treating BrokenPipe as a clean exit are three small habits that make a tool feel native on the command line.