Skip to main content

inspect_format/
color_choice.rs

1//! Terminal color choice and detection logic.
2
3use std::io::IsTerminal;
4
5/// User preference for emitting ANSI color escape codes.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum ColorChoice {
8    /// Automatically enable color if writing to a TTY and environment allows it.
9    #[default]
10    Auto,
11
12    /// Always emit ANSI color codes, even when redirected or piped.
13    Always,
14
15    /// Never emit ANSI color codes.
16    Never,
17}
18
19impl ColorChoice {
20    /// Determine whether color should be active given the terminal/TTY status of the output.
21    ///
22    /// Respects:
23    /// - `NO_COLOR` (<https://no-color.org>)
24    /// - `FORCE_COLOR` (<https://force-color.org>)
25    /// - `TERM=dumb`
26    /// - Stream TTY status
27    pub fn should_color(self, is_terminal: bool) -> bool {
28        match self {
29            Self::Always => true,
30            Self::Never => false,
31            Self::Auto => Self::detect_auto(is_terminal),
32        }
33    }
34
35    /// Automatically detect whether color is supported for the current environment.
36    pub fn detect_auto(is_terminal: bool) -> bool {
37        Self::detect_with(is_terminal, |k| std::env::var(k).ok())
38    }
39
40    /// Internal helper that tests environment variables using a mockable lookup closure.
41    pub fn detect_with(is_terminal: bool, get_var: impl Fn(&str) -> Option<String>) -> bool {
42        // 1. NO_COLOR takes precedence over automatic detection:
43        // Any non-empty string disables color.
44        if let Some(val) = get_var("NO_COLOR") {
45            if !val.is_empty() {
46                return false;
47            }
48        }
49
50        // 2. FORCE_COLOR forces color when set to non-empty and non-"0".
51        if let Some(val) = get_var("FORCE_COLOR") {
52            if !val.is_empty() && val != "0" {
53                return true;
54            }
55        }
56
57        // 3. TERM=dumb disables color.
58        if let Some(term) = get_var("TERM") {
59            if term == "dumb" {
60                return false;
61            }
62        }
63
64        // 4. Output stream must be an interactive terminal.
65        is_terminal
66    }
67
68    /// Resolve whether stdout should currently display colors.
69    pub fn resolve_stdout(self) -> bool {
70        self.should_color(std::io::stdout().is_terminal())
71    }
72
73    /// Resolve whether stderr should currently display colors.
74    pub fn resolve_stderr(self) -> bool {
75        self.should_color(std::io::stderr().is_terminal())
76    }
77}
78
79/// Color capability tier supported by the terminal.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
81pub enum ColorLevel {
82    /// Automatically detected from terminal capabilities and environment.
83    #[default]
84    Auto,
85
86    /// Full 24-bit Truecolor (16 million RGB colors).
87    Truecolor,
88
89    /// 8-bit ANSI 256-color palette.
90    Ansi256,
91
92    /// 4-bit standard ANSI 16-color palette.
93    Ansi16,
94
95    /// No colors supported or color disabled.
96    None,
97}
98
99impl ColorLevel {
100    /// Detect the terminal's color capability tier from environment variables.
101    pub fn detect() -> Self {
102        Self::detect_with(|k| std::env::var(k).ok())
103    }
104
105    /// Test color capability using a mockable environment lookup closure.
106    pub fn detect_with(get_var: impl Fn(&str) -> Option<String>) -> Self {
107        // Check if color is explicitly disabled
108        if let Some(val) = get_var("NO_COLOR") {
109            if !val.is_empty() {
110                return Self::None;
111            }
112        }
113
114        if let Some(term) = get_var("TERM") {
115            if term == "dumb" {
116                return Self::None;
117            }
118        }
119
120        // Truecolor check: COLORTERM=truecolor or 24bit
121        if let Some(colorterm) = get_var("COLORTERM") {
122            if colorterm == "truecolor" || colorterm == "24bit" {
123                return Self::Truecolor;
124            }
125        }
126
127        // Known truecolor / 256color terminal indicators in TERM
128        if let Some(term) = get_var("TERM") {
129            let term_lower = term.to_lowercase();
130            if term_lower.contains("direct")
131                || term_lower.contains("truecolor")
132                || term_lower.contains("kitty")
133                || term_lower.contains("alacritty")
134                || term_lower.contains("wezterm")
135                || term_lower.contains("ghostty")
136            {
137                return Self::Truecolor;
138            }
139            if term_lower.contains("256color") || term_lower.contains("xterm") {
140                return Self::Ansi256;
141            }
142        }
143
144        // Default baseline Unix terminal capability
145        Self::Ansi16
146    }
147}