Detecting a Terminal and Handling Pipes in Rust CLIs
Know whether stdout is a terminal or a pipe, and change behavior: color, progress bars, paging, width and line buffering. Use IsTerminal and NO_COLOR correctly.

A command line tool is used by people at a prompt and by scripts in a pipeline, and it should behave differently for each. The decision is one function call. Everything compiles on Rust 1.90 with edition 2024 and the standard library only.
IsTerminal
use std::io::{self, IsTerminal};
fn main() {
let out_tty = io::stdout().is_terminal();
let err_tty = io::stderr().is_terminal();
let in_tty = io::stdin().is_terminal();
eprintln!("stdin={in_tty} stdout={out_tty} stderr={err_tty}");
}Run in a terminal it prints all three as true. With ./demo | cat the stdout one is false. With ./demo < /dev/null, stdin is false. The trait is implemented for the standard streams and for File.
Color policy
use std::io::{self, IsTerminal};
#[derive(Clone, Copy, PartialEq)]
enum ColorChoice {
Auto,
Always,
Never,
}
fn use_color(choice: ColorChoice) -> bool {
match choice {
ColorChoice::Always => true,
ColorChoice::Never => false,
ColorChoice::Auto => {
io::stdout().is_terminal()
&& std::env::var_os("NO_COLOR").is_none_or(|v| v.is_empty())
&& std::env::var("TERM").map(|t| t != "dumb").unwrap_or(true)
}
}
}A --color flag with auto as the default is standard. Crates like anstream and owo-colors implement this policy for you, including Windows consoles (colored output). Escape sequences in a pipe corrupt grep and awk input, which is the reason for the check.
Progress and prompts
Progress bars write to stderr and only when it is a terminal, so stdout stays machine readable:
use std::io::{self, IsTerminal};
fn main() {
let show_progress = io::stderr().is_terminal();
for i in 0..3 {
if show_progress {
eprint!("\rprocessing {}/3", i + 1);
}
println!("record {i}");
}
if show_progress {
eprintln!();
}
}indicatif hides itself automatically when its target is not a terminal (progress bars). Prompts follow the same rule (prompts).
Reading input
use std::io::{self, IsTerminal, Read};
fn main() -> io::Result<()> {
let mut input = String::new();
if io::stdin().is_terminal() {
eprintln!("usage: tool < file (or give a path)");
std::process::exit(2);
}
io::stdin().read_to_string(&mut input)?;
println!("{} bytes", input.len());
Ok(())
}A tool that expects piped input and gets a terminal would otherwise sit waiting for the user to type, which looks like a hang. Complete stdin handling is in reading stdin and pipes.
Width and format
For wrapping, ask for the terminal width only when stdout is a terminal (the terminal_size crate), and otherwise do not wrap. Machine readable formats (--json, one object per line) should not depend on the terminal at all.
Broken pipe
./demo | head -1 closes the pipe after one line. The next println! panics with failed printing to stdout: Broken pipe. Handle it by writing through writeln! and exiting quietly on ErrorKind::BrokenPipe, as in broken pipe on stdout. Exit codes for each outcome are in exit codes.


