inksac/
env.rs

1//! Environment detection and color support functionality
2//!
3//! This module handles the detection of terminal capabilities and color support levels.
4//! It provides functions to check the current environment and determine what color
5//! features are available.
6
7use crate::error::ColorError;
8use std::env;
9
10/// Terminal color support levels
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum ColorSupport {
13    NoColor = 0,
14    Basic = 1,     // 16 colors
15    Color256 = 2,  // 256 colors
16    TrueColor = 3, // 16 million colors
17}
18
19impl ColorSupport {
20    /// Check if this support level can handle the requested level
21    pub fn supports(&self, required: ColorSupport) -> bool {
22        *self >= required
23    }
24}
25
26impl std::fmt::Display for ColorSupport {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            ColorSupport::NoColor => write!(f, "No Color"),
30            ColorSupport::Basic => write!(f, "Basic"),
31            ColorSupport::Color256 => write!(f, "Color256"),
32            ColorSupport::TrueColor => write!(f, "TrueColor"),
33        }
34    }
35}
36
37/// Check the level of color support in the current terminal
38///
39/// # Returns
40/// - `Ok(ColorSupport)` indicating the level of color support
41/// - `Err(ColorError)` if the terminal environment cannot be detected
42///
43/// # Examples
44/// ```
45/// use inksac::{check_color_support, ColorSupport};
46///
47/// match check_color_support() {
48///     Ok(support) => match support {
49///         ColorSupport::TrueColor => println!("Terminal supports true color"),
50///         ColorSupport::Color256 => println!("Terminal supports 256 colors"),
51///         ColorSupport::Basic => println!("Terminal supports basic colors"),
52///         ColorSupport::NoColor => println!("Terminal does not support colors"),
53///     },
54///     Err(e) => eprintln!("Failed to detect color support: {}", e),
55/// }
56/// ```
57pub fn check_color_support() -> Result<ColorSupport, ColorError> {
58    // Handle NO_COLOR first as it takes absolute precedence
59    if env::var("NO_COLOR").is_ok() {
60        return Ok(ColorSupport::NoColor);
61    }
62
63    let clicolor = env::var("CLICOLOR").unwrap_or_default();
64    if clicolor == "0" {
65        return Ok(ColorSupport::NoColor);
66    }
67
68    let mut support = ColorSupport::NoColor;
69
70    // Check COLORTERM for true color support
71    let colorterm = env::var("COLORTERM").unwrap_or_default();
72    if colorterm.contains("truecolor") || colorterm.contains("24bit") {
73        support = ColorSupport::TrueColor;
74    }
75
76    let term = env::var("TERM").unwrap_or_default().to_lowercase();
77
78    // List of terminals that support true color
79    let truecolor_terms = [
80        "xterm-truecolor",
81        "konsole",
82        "tmux",
83        "screen-truecolor",
84        "alacritty",
85        "kitty",
86        "terminator",
87        "terminology",
88        "eterm",
89        "rxvt-unicode",
90        "xterm-ghostty",
91        "vte",
92        "termious",
93    ];
94    if truecolor_terms.iter().any(|&t| term.contains(t)) {
95        support = ColorSupport::TrueColor;
96    }
97
98    // Check TERM_PROGRAM for specific terminals that support true color
99    let term_program = env::var("TERM_PROGRAM").unwrap_or_default();
100    if ["iTerm.app", "Apple_Terminal", "Hyper"].contains(&term_program.as_str()) {
101        support = ColorSupport::TrueColor;
102    }
103
104    // If no true color support was detected, check for 256 colors or basic colors
105    if support == ColorSupport::NoColor {
106        if term.contains("256color") || term.contains("256") {
107            support = ColorSupport::Color256;
108        } else if term.contains("color")
109            || term.contains("ansi")
110            || term.contains("xterm")
111            || term.contains("screen")
112        {
113            support = ColorSupport::Basic;
114        }
115    }
116
117    // Handle CLICOLOR_FORCE after determining actual support level
118    let clicolor_force = env::var("CLICOLOR_FORCE").unwrap_or_default();
119    if clicolor_force == "1" {
120        // If no support was detected but CLICOLOR_FORCE is set, use Basic
121        // Otherwise, keep the highest detected support level
122        if support == ColorSupport::NoColor {
123            support = ColorSupport::Basic;
124        }
125    }
126
127    Ok(support)
128}
129
130/// Check if the terminal supports ANSI colors
131///
132/// # Returns
133/// - `Ok(())` if the terminal supports ANSI colors
134/// - `Err(ColorError::NoTerminalSupport)` if the terminal does not support ANSI colors
135pub fn is_color_available() -> Result<(), ColorError> {
136    match check_color_support()? {
137        ColorSupport::NoColor => Err(ColorError::NoTerminalSupport),
138        _ => Ok(()),
139    }
140}
141
142#[cfg(test)]
143pub(crate) mod tests {
144    use super::*;
145    use crate::Color;
146
147    pub(crate) fn run_with_env_vars<F, T>(vars: &[(&str, Option<&str>)], test: F) -> T
148    where
149        F: FnOnce() -> T,
150    {
151        // Store original env vars
152        let original: Vec<(String, Option<String>)> = vars
153            .iter()
154            .map(|(k, _)| (k.to_string(), env::var(k).ok()))
155            .collect();
156
157        // Check if we're running in a CI environment
158        let is_ci = env::var("CI").is_ok()
159            || env::var("GITHUB_ACTIONS").is_ok()
160            || env::var("GITLAB_CI").is_ok()
161            || env::var("TRAVIS").is_ok()
162            || env::var("CIRCLECI").is_ok();
163
164        // If in CI, always set color support
165        if is_ci {
166            env::set_var("COLORTERM", "truecolor");
167            env::set_var("TERM", "xterm-256color");
168            env::remove_var("NO_COLOR");
169        }
170
171        // Set test-specific env vars
172        for (key, value) in vars {
173            match value {
174                Some(v) => env::set_var(key, v),
175                None => env::remove_var(key),
176            }
177        }
178
179        // Run the test
180        let result = test();
181
182        // Restore original env vars
183        for (key, value) in original {
184            match value {
185                Some(v) => env::set_var(&key, v),
186                None => env::remove_var(&key),
187            }
188        }
189
190        result
191    }
192
193    #[test]
194    fn test_all_color_scenarios() {
195        // Test color support
196        run_with_env_vars(
197            &[
198                ("NO_COLOR", None),
199                ("COLORTERM", Some("truecolor")),
200                ("TERM", Some("xterm-256color")),
201            ],
202            || {
203                let support = check_color_support();
204                assert!(support.is_ok());
205            },
206        );
207
208        // Test no color environment
209        run_with_env_vars(
210            &[("NO_COLOR", Some("")), ("TERM", None), ("COLORTERM", None)],
211            || {
212                let support = check_color_support().expect("Color support check failed");
213                assert_eq!(support, ColorSupport::NoColor);
214            },
215        );
216
217        // Test CLICOLOR_FORCE
218        run_with_env_vars(
219            &[
220                ("NO_COLOR", None),
221                ("COLORTERM", None),
222                ("TERM", None),
223                ("CLICOLOR_FORCE", Some("1")),
224                ("CLICOLOR", None),
225            ],
226            || {
227                let support = check_color_support().expect("Color support check failed");
228                assert_eq!(support, ColorSupport::Basic);
229            },
230        );
231
232        // Test CLICOLOR disable
233        run_with_env_vars(
234            &[
235                ("CLICOLOR", Some("0")),
236                ("NO_COLOR", None),
237                ("COLORTERM", None),
238                ("TERM", None),
239                ("CLICOLOR_FORCE", None),
240            ],
241            || {
242                let support = check_color_support().expect("Color support check failed");
243                assert_eq!(support, ColorSupport::NoColor);
244            },
245        );
246
247        // Test RGB color
248        run_with_env_vars(
249            &[
250                ("NO_COLOR", None),
251                ("COLORTERM", Some("truecolor")),
252                ("TERM", Some("xterm-256color")),
253            ],
254            || {
255                let rgb = Color::new_rgb(255, 128, 0);
256                assert!(rgb.is_ok());
257            },
258        );
259
260        // Test HEX color
261        run_with_env_vars(
262            &[
263                ("NO_COLOR", None),
264                ("COLORTERM", Some("truecolor")),
265                ("TERM", Some("xterm-256color")),
266            ],
267            || {
268                let hex = Color::new_hex("#FF8000");
269                assert!(hex.is_ok());
270            },
271        );
272    }
273}