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}