Skip to main content

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}