blog

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.

  • Rust
  • CLI
  • Unix

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, running shout with no arguments would sit there silently waiting for input, which looks like a hang.
  • stdout is line-buffered, so wrapping it in a BufWriter cuts 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.