haz_cli/output/color_choice.rs
1//! Auto-detection of whether `haz run` should emit ANSI colour
2//! escape sequences.
3//!
4//! Two signals combine into the [`ColorChoice`] decision:
5//!
6//! - The two writable streams the run touches (`stdout`, `stderr`)
7//! MUST both be terminals. A pipe / redirected file on either side
8//! silences colours: writing escapes into log files or into
9//! pagers that lack `-R` produces noise no consumer can read.
10//! - The `NO_COLOR` environment variable
11//! (<https://no-color.org/>) MUST be unset or empty. A non-empty
12//! value (any non-empty value, per the convention) disables
13//! colours unconditionally; accessibility tools, screen readers,
14//! and colour-blind users rely on this knob.
15//!
16//! The detection is read-only and pure: it inspects file
17//! descriptors and the process environment, never mutates either.
18
19use std::io::IsTerminal;
20
21/// Whether `haz run`'s presenter renders ANSI colour sequences.
22///
23/// The decision is binary: colour-render bytes go out, or they
24/// don't. The mid-state ("auto") is resolved at construction time
25/// by [`Self::detect`], which probes the live process environment
26/// and the live stdout / stderr handles. Once a `ColorChoice` is
27/// built it is a fixed runtime value the presenter consults on
28/// every byte.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ColorChoice {
31 /// Colour escape sequences are emitted on every styled span.
32 Always,
33 /// No escape sequence reaches either sink; the presenter emits
34 /// plain text only.
35 Never,
36}
37
38impl ColorChoice {
39 /// Probe the live process for the colour decision: both
40 /// streams MUST be terminals AND `NO_COLOR` MUST be unset or
41 /// empty for colours to be on.
42 #[must_use]
43 pub fn detect() -> Self {
44 if no_color_env_set() {
45 return Self::Never;
46 }
47 if std::io::stdout().is_terminal() && std::io::stderr().is_terminal() {
48 Self::Always
49 } else {
50 Self::Never
51 }
52 }
53
54 /// Convenience predicate: `true` iff [`Self::Always`].
55 #[must_use]
56 pub fn is_enabled(self) -> bool {
57 matches!(self, Self::Always)
58 }
59}
60
61/// Read `NO_COLOR` per the spec at <https://no-color.org/>: any
62/// non-empty value disables colours; absence or the empty string
63/// leaves them on.
64fn no_color_env_set() -> bool {
65 std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 #[test]
73 fn is_enabled_matches_variant() {
74 assert!(ColorChoice::Always.is_enabled());
75 assert!(!ColorChoice::Never.is_enabled());
76 }
77}