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: a [`ColorChoice`]
4//! (`auto`/`always`/`never`) resolves — together with the [`NO_COLOR`] standard
5//! and TTY detection — into an effective on/off [`Palette`]. The palette exposes
6//! semantic styles (success/error/warn/info/dim/bold) that emit ANSI SGR escapes
7//! when enabled and return the plain string when not, so the same rendering code
8//! stays byte-clean on pipes 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), disables
16/// 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 so the caller can raise its own typed
36    /// 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
61/// request): if `NO_COLOR` is set → off; else `Always` → on, `Never` → off, and
62/// `Auto` follows `is_terminal`. Reads the process environment for `NO_COLOR`;
63/// use [`resolve_color_with`] for a fully injected, 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 and TTY detection
70/// 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
89/// color, 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, so callers render the
98/// same way regardless of terminal capability. Construct it from a resolved
99/// boolean via [`Palette::new`], or resolve a [`ColorChoice`] against a stream
100/// 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
116    /// [`resolve_color`].
117    #[must_use]
118    pub fn for_stream(choice: ColorChoice, stream: &impl IsTerminal) -> Self {
119        Self::new(resolve_color(choice, stream.is_terminal()))
120    }
121
122    /// Whether this palette emits color.
123    #[must_use]
124    pub const fn enabled(self) -> bool {
125        self.enabled
126    }
127
128    /// Green — successful/complete status.
129    #[must_use]
130    pub fn success(self, text: &str) -> Cow<'_, str> {
131        self.paint("32", text)
132    }
133
134    /// Red — failure/error status.
135    #[must_use]
136    pub fn error(self, text: &str) -> Cow<'_, str> {
137        self.paint("31", text)
138    }
139
140    /// Yellow — warnings and attention.
141    #[must_use]
142    pub fn warn(self, text: &str) -> Cow<'_, str> {
143        self.paint("33", text)
144    }
145
146    /// Cyan — informational/neutral highlight.
147    #[must_use]
148    pub fn info(self, text: &str) -> Cow<'_, str> {
149        self.paint("36", text)
150    }
151
152    /// Dimmed — secondary detail (cache/skip).
153    #[must_use]
154    pub fn dim(self, text: &str) -> Cow<'_, str> {
155        self.paint("2", text)
156    }
157
158    /// Bold — emphasis (headings, totals).
159    #[must_use]
160    pub fn bold(self, text: &str) -> Cow<'_, str> {
161        self.paint("1", text)
162    }
163
164    /// Wrap `text` in an SGR code + reset when enabled, else borrow it verbatim.
165    ///
166    /// The disabled path returns a [`Cow::Borrowed`], so callers on pipes or
167    /// redirects render without any allocation.
168    fn paint<'a>(self, code: &str, text: &'a str) -> Cow<'a, str> {
169        if self.enabled {
170            Cow::Owned(format!("\u{1b}[{code}m{text}\u{1b}[0m"))
171        } else {
172            Cow::Borrowed(text)
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::{ColorChoice, Palette, no_color_env_set, resolve_color, resolve_color_with};
180    use std::borrow::Cow;
181
182    #[test]
183    fn no_color_overrides_always() {
184        assert!(!resolve_color_with(ColorChoice::Always, true, true));
185        assert!(!resolve_color_with(ColorChoice::Always, true, false));
186    }
187
188    #[test]
189    fn always_and_never_are_absolute_without_no_color() {
190        assert!(resolve_color_with(ColorChoice::Always, false, false));
191        assert!(!resolve_color_with(ColorChoice::Never, false, true));
192    }
193
194    #[test]
195    fn auto_follows_terminal() {
196        assert!(resolve_color_with(ColorChoice::Auto, false, true));
197        assert!(!resolve_color_with(ColorChoice::Auto, false, false));
198    }
199
200    #[test]
201    fn choice_round_trips_by_name() {
202        for choice in [ColorChoice::Auto, ColorChoice::Always, ColorChoice::Never] {
203            assert_eq!(ColorChoice::from_name(choice.as_str()), Some(choice));
204        }
205        assert_eq!(ColorChoice::from_name("bogus"), None);
206    }
207
208    #[test]
209    fn disabled_palette_is_the_identity() {
210        let plain = Palette::new(false);
211        assert_eq!(plain.success("ok"), Cow::Borrowed("ok"));
212        assert_eq!(plain.error("boom"), Cow::Borrowed("boom"));
213        assert!(matches!(plain.success("ok"), Cow::Borrowed(_)));
214        assert!(!plain.enabled());
215    }
216
217    #[test]
218    fn enabled_palette_wraps_in_sgr_and_resets() {
219        let color = Palette::new(true);
220        assert_eq!(color.success("ok"), "\u{1b}[32mok\u{1b}[0m");
221        assert_eq!(color.error("boom"), "\u{1b}[31mboom\u{1b}[0m");
222        assert!(color.enabled());
223    }
224
225    #[test]
226    fn never_choice_stays_disabled_through_the_env_aware_resolver() {
227        // `Never` is absolute regardless of `NO_COLOR` or TTY, so the env-reading
228        // resolver is deterministic here while still exercising the env probe.
229        assert!(!resolve_color(ColorChoice::Never, true));
230        let _ = no_color_env_set();
231    }
232
233    #[test]
234    fn for_stream_resolves_a_palette_against_a_real_stream() {
235        // stderr under a test harness is not a TTY; `Never` keeps it disabled.
236        let palette = Palette::for_stream(ColorChoice::Never, &std::io::stderr());
237        assert!(!palette.enabled());
238    }
239}