Skip to content
rusthacks.dev
CLI

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.

3 min read
Summarize in AI
Flat illustration for the post “Detecting a Terminal and Handling Pipes in Rust CLIs”, composed around cli.

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

rust
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

rust
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:

rust
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

rust
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.

Also inDevOps
Taggedtty
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.