Skip to main content

rskit_cli/theme/
color.rs

1//! Semantic terminal color with standard opt-out.
2//!
3//! A small, dependency-free color layer for CLI reporters:
4//! a [`ColorChoice`] (`auto`/`always`/`never`) resolves — together with the [`NO_COLOR`] standard
5//! and TTY detection — into an effective on/off [`Palette`].
6//! The palette exposes semantic styles (success/error/warn/info/dim/bold) that emit ANSI SGR escapes when enabled
7//! and return the plain string when not, so the same rendering code stays byte-clean on pipes
8//! and honors user preference.
9//!
10//! [`NO_COLOR`]: https://no-color.org
11
12use std::borrow::Cow;
13use std::io::IsTerminal;
14
15/// The environment variable that, when present (regardless of value),
16/// disables color regardless of any explicit choice ([the `NO_COLOR` standard](https://no-color.org)).
17pub const NO_COLOR_ENV: &str = "NO_COLOR";
18
19/// A user's requested color policy, before environment/TTY resolution.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21#[non_exhaustive]
22pub enum ColorChoice {
23    /// Enable color only when writing to a terminal and `NO_COLOR` is unset.
24    #[default]
25    Auto,
26    /// Force color on (still overridden by `NO_COLOR`).
27    Always,
28    /// Force color off.
29    Never,
30}
31
32impl ColorChoice {
33    /// Parse a choice from its lowercase name (`auto`/`always`/`never`).
34    ///
35    /// Returns `None` for any other value
36    /// so the caller can raise its own typed usage error naming the accepted values.
37    #[must_use]
38    pub fn from_name(name: &str) -> Option<Self> {
39        match name {
40            "auto" => Some(Self::Auto),
41            "always" => Some(Self::Always),
42            "never" => Some(Self::Never),
43            _ => None,
44        }
45    }
46
47    /// The canonical lowercase name of this choice.
48    #[must_use]
49    pub const fn as_str(self) -> &'static str {
50        match self {
51            Self::Auto => "auto",
52            Self::Always => "always",
53            Self::Never => "never",
54        }
55    }
56}
57
58/// Resolve a [`ColorChoice`] into an effective on/off decision.
59///
60/// Resolution order (the `NO_COLOR` standard takes precedence over an explicit request):
61/// if `NO_COLOR` is set → off; else `Always` → on, `Never` → off, and `Auto` follows `is_terminal`.
62/// Reads the process environment for `NO_COLOR`; use [`resolve_color_with`] for a fully injected,
63/// env-free decision.
64#[must_use]
65pub fn resolve_color(choice: ColorChoice, is_terminal: bool) -> bool {
66    resolve_color_with(choice, no_color_env_set(), is_terminal)
67}
68
69/// Pure resolver core: fold an explicit `NO_COLOR` presence
70/// and TTY detection into an effective on/off decision (env-free, so it is unit-testable).
71///
72/// `NO_COLOR` wins over every choice; otherwise `Always`/`Never` are absolute
73/// and `Auto` follows `is_terminal`.
74#[must_use]
75pub const fn resolve_color_with(choice: ColorChoice, no_color: bool, is_terminal: bool) -> bool {
76    if no_color {
77        return false;
78    }
79    match choice {
80        ColorChoice::Always => true,
81        ColorChoice::Never => false,
82        ColorChoice::Auto => is_terminal,
83    }
84}
85
86/// Whether the `NO_COLOR` environment variable is present.
87///
88/// Per [the `NO_COLOR` standard](https://no-color.org) any presence disables color,
89/// so an empty value (`NO_COLOR=`) still counts.
90#[must_use]
91pub fn no_color_env_set() -> bool {
92    std::env::var_os(NO_COLOR_ENV).is_some()
93}
94
95/// A resolved, semantic color palette.
96///
97/// When disabled every style is the identity function,
98/// so callers render the same way regardless of terminal capability.
99/// Construct it from a resolved boolean via [`Palette::new`],
100/// or resolve a [`ColorChoice`] against a stream with [`Palette::for_stream`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct Palette {
103    enabled: bool,
104}
105
106impl Palette {
107    /// A palette with color explicitly enabled or disabled.
108    #[must_use]
109    pub const fn new(enabled: bool) -> Self {
110        Self { enabled }
111    }
112
113    /// Resolve a palette for a specific output stream (e.g. `&std::io::stderr()`).
114    ///
115    /// Folds `NO_COLOR`, the `choice`, and the stream's TTY status via [`resolve_color`].
116    #[must_use]
117    pub fn for_stream(choice: ColorChoice, stream: &impl IsTerminal) -> Self {
118        Self::new(resolve_color(choice, stream.is_terminal()))
119    }
120
121    /// Whether this palette emits color.
122    #[must_use]
123    pub const fn enabled(self) -> bool {
124        self.enabled
125    }
126
127    /// Green — successful/complete status.
128    #[must_use]
129    pub fn success(self, text: &str) -> Cow<'_, str> {
130        self.paint("32", text)
131    }
132
133    /// Red — failure/error status.
134    #[must_use]
135    pub fn error(self, text: &str) -> Cow<'_, str> {
136        self.paint("31", text)
137    }
138
139    /// Yellow — warnings and attention.
140    #[must_use]
141    pub fn warn(self, text: &str) -> Cow<'_, str> {
142        self.paint("33", text)
143    }
144
145    /// Cyan — informational/neutral highlight.
146    #[must_use]
147    pub fn info(self, text: &str) -> Cow<'_, str> {
148        self.paint("36", text)
149    }
150
151    /// Dimmed — secondary detail (cache/skip).
152    #[must_use]
153    pub fn dim(self, text: &str) -> Cow<'_, str> {
154        self.paint("2", text)
155    }
156
157    /// Bold — emphasis (headings, totals).
158    #[must_use]
159    pub fn bold(self, text: &str) -> Cow<'_, str> {
160        self.paint("1", text)
161    }
162
163    /// Wrap `text` in an SGR code + reset when enabled, else borrow it verbatim.
164    ///
165    /// The disabled path returns a [`Cow::Borrowed`], so callers on pipes
166    /// or redirects render without any allocation.
167    fn paint<'a>(self, code: &str, text: &'a str) -> Cow<'a, str> {
168        if self.enabled {
169            Cow::Owned(format!("\u{1b}[{code}m{text}\u{1b}[0m"))
170        } else {
171            Cow::Borrowed(text)
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::{ColorChoice, Palette, no_color_env_set, resolve_color, resolve_color_with};
179    use std::borrow::Cow;
180
181    #[test]
182    fn no_color_overrides_always() {
183        assert!(!resolve_color_with(ColorChoice::Always, true, true));
184        assert!(!resolve_color_with(ColorChoice::Always, true, false));
185    }
186
187    #[test]
188    fn always_and_never_are_absolute_without_no_color() {
189        assert!(resolve_color_with(ColorChoice::Always, false, false));
190        assert!(!resolve_color_with(ColorChoice::Never, false, true));
191    }
192
193    #[test]
194    fn auto_follows_terminal() {
195        assert!(resolve_color_with(ColorChoice::Auto, false, true));
196        assert!(!resolve_color_with(ColorChoice::Auto, false, false));
197    }
198
199    #[test]
200    fn choice_round_trips_by_name() {
201        for choice in [ColorChoice::Auto, ColorChoice::Always, ColorChoice::Never] {
202            assert_eq!(ColorChoice::from_name(choice.as_str()), Some(choice));
203        }
204        assert_eq!(ColorChoice::from_name("bogus"), None);
205    }
206
207    #[test]
208    fn disabled_palette_is_the_identity() {
209        let plain = Palette::new(false);
210        assert_eq!(plain.success("ok"), Cow::Borrowed("ok"));
211        assert_eq!(plain.error("boom"), Cow::Borrowed("boom"));
212        assert!(matches!(plain.success("ok"), Cow::Borrowed(_)));
213        assert!(!plain.enabled());
214    }
215
216    #[test]
217    fn enabled_palette_wraps_in_sgr_and_resets() {
218        let color = Palette::new(true);
219        assert_eq!(color.success("ok"), "\u{1b}[32mok\u{1b}[0m");
220        assert_eq!(color.error("boom"), "\u{1b}[31mboom\u{1b}[0m");
221        assert!(color.enabled());
222    }
223
224    #[test]
225    fn never_choice_stays_disabled_through_the_env_aware_resolver() {
226        // `Never` is absolute regardless of `NO_COLOR` or TTY,
227        // so the env-reading resolver is deterministic here while still exercising the env probe.
228        assert!(!resolve_color(ColorChoice::Never, true));
229        let _ = no_color_env_set();
230    }
231
232    #[test]
233    fn for_stream_resolves_a_palette_against_a_real_stream() {
234        // stderr under a test harness is not a TTY; `Never` keeps it disabled.
235        let palette = Palette::for_stream(ColorChoice::Never, &std::io::stderr());
236        assert!(!palette.enabled());
237    }
238}