Skip to main content

mkit_cli/
term.rs

1//! Terminal helpers — ANSI color gating and POSIX getenv wrappers.
2//!
3//! Color policy: `NO_COLOR` (any value, including empty) disables
4//! color; `CLICOLOR_FORCE=1` forces it even when stdout is piped.
5//! `NO_COLOR` wins — see <https://no-color.org>.
6
7use std::env;
8use std::io::IsTerminal;
9
10/// Returns `true` when ANSI color should be rendered on stdout.
11#[must_use]
12pub fn use_color_stdout() -> bool {
13    use_color(std::io::stdout().is_terminal())
14}
15
16/// Returns `true` when ANSI color should be rendered on stderr.
17#[must_use]
18pub fn use_color_stderr() -> bool {
19    use_color(std::io::stderr().is_terminal())
20}
21
22fn use_color(is_tty: bool) -> bool {
23    use_color_with(
24        env::var_os("NO_COLOR").is_some(),
25        matches!(env::var("CLICOLOR_FORCE").ok().as_deref(), Some("1")),
26        is_tty,
27    )
28}
29
30/// Pure decision function, taking `NO_COLOR`/`CLICOLOR_FORCE` as
31/// explicit booleans instead of reading the ambient process env. Split
32/// out so tests can drive every combination deterministically (#505 PR
33/// 5/5) instead of branching on whatever `NO_COLOR`/`CLICOLOR_FORCE`
34/// happen to be set to in the current process.
35fn use_color_with(no_color: bool, force: bool, is_tty: bool) -> bool {
36    if no_color {
37        return false;
38    }
39    if force {
40        return true;
41    }
42    is_tty
43}
44
45/// A `--color=<when>` choice, mirroring git's.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ColorChoice {
48    /// Always colorize, even when piped.
49    Always,
50    /// Colorize only on a tty (respecting `NO_COLOR`/`CLICOLOR_FORCE`).
51    Auto,
52    /// Never colorize.
53    Never,
54}
55
56impl ColorChoice {
57    /// Parse a `--color=<when>` value; `None`/`""`/`auto` → `Auto`.
58    #[must_use]
59    pub fn parse(s: Option<&str>) -> Option<Self> {
60        match s {
61            None | Some("" | "auto") => Some(Self::Auto),
62            Some("always") => Some(Self::Always),
63            Some("never") => Some(Self::Never),
64            Some(_) => None,
65        }
66    }
67
68    /// Resolve to an on/off decision for the given tty-ness.
69    #[must_use]
70    pub fn resolve(self, is_tty: bool) -> bool {
71        match self {
72            Self::Always => true,
73            Self::Never => false,
74            Self::Auto => use_color(is_tty),
75        }
76    }
77}
78
79/// Convenience getenv — returns `None` for both unset and empty.
80#[must_use]
81pub fn getenv_nonempty(key: &str) -> Option<String> {
82    match env::var(key) {
83        Ok(v) if !v.is_empty() => Some(v),
84        _ => None,
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn use_color_with_matrix_honours_precedence_and_tty() {
94        // #505 PR 5/5: inject NO_COLOR/CLICOLOR_FORCE/tty explicitly
95        // instead of branching on the ambient process env — deterministic
96        // regardless of what NO_COLOR/CLICOLOR_FORCE happen to be set to
97        // wherever the test runs.
98        //
99        // NO_COLOR wins outright, tty or not.
100        assert!(!use_color_with(true, true, true));
101        assert!(!use_color_with(true, true, false));
102        assert!(!use_color_with(true, false, true));
103        assert!(!use_color_with(true, false, false));
104        // CLICOLOR_FORCE overrides a non-tty when NO_COLOR is absent.
105        assert!(use_color_with(false, true, true));
106        assert!(use_color_with(false, true, false));
107        // Neither set: falls through to tty-ness.
108        assert!(use_color_with(false, false, true));
109        assert!(!use_color_with(false, false, false));
110    }
111}