How to detect whether output is a terminal
Short answer: Call io::stdout().is_terminal() — the method comes from the std::io::IsTerminal trait, stable since Rust 1.70 — and let the answer decide whether escape codes are printed at all.
From a fresh project — cargo new is-a-terminal — replace src/main.rs with each block below and run it with cargo run -q. Piping the output through cat is the interesting case: a pipe is not a terminal, and the program can tell.
The check
Section titled “The check”use std::io::{self, IsTerminal};
fn main() { println!("stdout is a terminal: {}", io::stdout().is_terminal()); println!("stderr is a terminal: {}", io::stderr().is_terminal()); println!("stdin is a terminal: {}", io::stdin().is_terminal());}$ cargo run -q | catstdout is a terminal: falsestderr is a terminal: falsestdin is a terminal: falseEvery stream answers for itself. In a real terminal all three read true, and a pseudo-terminal is enough to flip them. No interactive session is needed; python3 can open one on macOS and Linux alike:
$ python3 -c 'import pty; pty.spawn(["cargo", "run", "-q"])' | tr -d '\r'stdout is a terminal: truestderr is a terminal: truestdin is a terminal: trueA pty turns each newline into \r\n, and tr -d '\r' puts them back. On Windows the same call answers for consoles, and for msys and cygwin pseudo-terminals as well.
Why the colours vanish
Section titled “Why the colours vanish”Colour codes are terminal instructions. Written into a pipe they are raw escape bytes sitting in the middle of the text, which is what the previous article’s NO_COLOR-only check produces when the output is piped:
use std::env;
fn paint(code: &str, text: &str) -> String { format!("\x1b[{code}m{text}\x1b[0m")}
fn main() { let colour = !env::var("NO_COLOR").is_ok_and(|value| !value.is_empty()); let ok = if colour { paint("32", "ok") } else { "ok".to_string() }; println!("{ok} done");}$ cargo run -q | cat -v^[[32mok^[[0m donecat -v shows ESC as ^[. That line looks right in a terminal and wrong everywhere else, so the terminal check joins the environment check: one && is all it takes.
use std::env;use std::io::{self, IsTerminal};
fn paint(code: &str, text: &str) -> String { format!("\x1b[{code}m{text}\x1b[0m")}
fn no_colour() -> bool { env::var("NO_COLOR").is_ok_and(|value| !value.is_empty())}
fn main() { let colour = io::stdout().is_terminal() && !no_colour(); let ok = if colour { paint("32", "ok") } else { "ok".to_string() }; println!("{ok} done");}$ cargo run -q | cat -vok done
$ python3 -c 'import pty; pty.spawn(["cargo", "run", "-q"])' | tr -d '\r' | cat -v^[[32mok^[[0m done
$ NO_COLOR=1 python3 -c 'import pty; pty.spawn(["cargo", "run", "-q"])' | tr -d '\r' | cat -vok doneThree environments, three answers. Read NO_COLOR once at startup and pass the flag down, as the previous article puts it; the terminal check is one call at the top of main, not one per write.
Check the stream you write to
Section titled “Check the stream you write to”stdout and stderr answer separately, and a CLI sends them to different places: results to stdout, progress and diagnostics to stderr. Any handle answers, not just the standard three — a File you opened yourself reports false, which is what keeps a log file clean. Redirect stdout alone and the split shows:
use std::io::{self, IsTerminal};
fn main() { println!("stdout is a terminal: {}", io::stdout().is_terminal()); eprintln!("stderr is a terminal: {}", io::stderr().is_terminal());}$ python3 -c 'import pty; pty.spawn(["sh", "-c", "cargo run -q > out.txt"])' | tr -d '\r'stderr is a terminal: true$ cat out.txtstdout is a terminal: falseSo mycli | tee log.txt keeps stderr on the terminal while stdout goes to the file. A progress bar that checks stdout goes dark in exactly that case, while the user is still watching the terminal; check the stream the bar is drawn on. The next step in this chapter adds a progress bar, and that decision is what it turns on.
What it doesn’t tell you
Section titled “What it doesn’t tell you”true means a terminal is attached, not that the terminal can do colour: TERM=dumb, an eight-colour xterm and a truecolor terminal all answer true. The same trait answers the question for input, which is what the probe’s third line is for: a prompt behind io::stdin().is_terminal() stays quiet when input arrives from a pipe.
- How to colour terminal output — the previous step, and where the escape codes come from
- Colour, tables, and readable output — the same decision in a real CLI
- Back to Building a command-line tool