Skip to main content

cardanowall_cli/util/
color.rs

1//! Color / TTY policy for human-facing output.
2//!
3//! The CLI colorizes ONLY human diagnostics (never `--json`). The decision
4//! follows a documented order so a CI log, a piped consumer, and an interactive
5//! terminal each get the right behaviour without per-command logic:
6//!
7//! 1. the explicit `--color <auto|always|never>` / `--no-color` flag,
8//! 2. `NO_COLOR` (any non-empty value → off; the de-facto cross-tool standard),
9//! 3. `CLICOLOR_FORCE` (any non-empty value → on),
10//! 4. the stream's own `is_terminal()`.
11//!
12//! `--json` short-circuits to "no color" before any of this runs.
13
14use std::io::IsTerminal;
15
16/// The user's color intent from `--color` / `--no-color`.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum ColorChoice {
19    /// Decide from env + TTY (the default).
20    #[default]
21    Auto,
22    /// Always colorize (subject only to the `--json` short-circuit).
23    Always,
24    /// Never colorize.
25    Never,
26}
27
28/// The two output streams a command may colorize.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Stream {
31    /// Standard output.
32    Stdout,
33    /// Standard error.
34    Stderr,
35}
36
37/// The env reads the policy needs, injected so tests need no real env/TTY.
38pub trait ColorEnv {
39    /// Read an environment variable (empty string counts as unset here).
40    fn var(&self, key: &str) -> Option<String>;
41    /// Whether the given stream is a terminal.
42    fn is_terminal(&self, stream: Stream) -> bool;
43}
44
45/// The production color environment: real env + real `is_terminal()`.
46pub struct SystemColorEnv;
47
48impl ColorEnv for SystemColorEnv {
49    fn var(&self, key: &str) -> Option<String> {
50        std::env::var(key).ok().filter(|v| !v.is_empty())
51    }
52    fn is_terminal(&self, stream: Stream) -> bool {
53        match stream {
54            Stream::Stdout => std::io::stdout().is_terminal(),
55            Stream::Stderr => std::io::stderr().is_terminal(),
56        }
57    }
58}
59
60/// Decide whether to colorize `stream`, given the flag choice, `--json` mode, and
61/// the environment. Pure over its inputs so it is exhaustively testable.
62#[must_use]
63pub fn should_color(
64    choice: ColorChoice,
65    json_mode: bool,
66    stream: Stream,
67    env: &dyn ColorEnv,
68) -> bool {
69    // Machine output is never colorized.
70    if json_mode {
71        return false;
72    }
73    match choice {
74        ColorChoice::Never => false,
75        ColorChoice::Always => true,
76        ColorChoice::Auto => {
77            if env.var("NO_COLOR").is_some() {
78                return false;
79            }
80            if env.var("CLICOLOR_FORCE").is_some() {
81                return true;
82            }
83            env.is_terminal(stream)
84        }
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use std::collections::HashMap;
92
93    #[derive(Default)]
94    struct FakeEnv {
95        vars: HashMap<String, String>,
96        stdout_tty: bool,
97        stderr_tty: bool,
98    }
99    impl ColorEnv for FakeEnv {
100        fn var(&self, key: &str) -> Option<String> {
101            self.vars.get(key).cloned().filter(|v| !v.is_empty())
102        }
103        fn is_terminal(&self, stream: Stream) -> bool {
104            match stream {
105                Stream::Stdout => self.stdout_tty,
106                Stream::Stderr => self.stderr_tty,
107            }
108        }
109    }
110
111    #[test]
112    fn json_mode_is_always_off() {
113        let env = FakeEnv {
114            stderr_tty: true,
115            ..FakeEnv::default()
116        };
117        assert!(!should_color(
118            ColorChoice::Always,
119            true,
120            Stream::Stderr,
121            &env
122        ));
123    }
124
125    #[test]
126    fn never_is_off_always_is_on() {
127        let env = FakeEnv::default();
128        assert!(!should_color(
129            ColorChoice::Never,
130            false,
131            Stream::Stderr,
132            &env
133        ));
134        assert!(should_color(
135            ColorChoice::Always,
136            false,
137            Stream::Stderr,
138            &env
139        ));
140    }
141
142    #[test]
143    fn no_color_env_forces_off_in_auto() {
144        let env = FakeEnv {
145            vars: HashMap::from([("NO_COLOR".to_string(), "1".to_string())]),
146            stderr_tty: true,
147            ..FakeEnv::default()
148        };
149        assert!(!should_color(
150            ColorChoice::Auto,
151            false,
152            Stream::Stderr,
153            &env
154        ));
155    }
156
157    #[test]
158    fn clicolor_force_turns_on_without_tty() {
159        let env = FakeEnv {
160            vars: HashMap::from([("CLICOLOR_FORCE".to_string(), "1".to_string())]),
161            ..FakeEnv::default()
162        };
163        assert!(should_color(ColorChoice::Auto, false, Stream::Stderr, &env));
164    }
165
166    #[test]
167    fn no_color_beats_clicolor_force() {
168        let env = FakeEnv {
169            vars: HashMap::from([
170                ("NO_COLOR".to_string(), "1".to_string()),
171                ("CLICOLOR_FORCE".to_string(), "1".to_string()),
172            ]),
173            ..FakeEnv::default()
174        };
175        assert!(!should_color(
176            ColorChoice::Auto,
177            false,
178            Stream::Stderr,
179            &env
180        ));
181    }
182
183    #[test]
184    fn auto_follows_tty_when_env_silent() {
185        let on = FakeEnv {
186            stderr_tty: true,
187            ..FakeEnv::default()
188        };
189        let off = FakeEnv::default();
190        assert!(should_color(ColorChoice::Auto, false, Stream::Stderr, &on));
191        assert!(!should_color(
192            ColorChoice::Auto,
193            false,
194            Stream::Stderr,
195            &off
196        ));
197    }
198}