Skip to main content

browser_automation_cli/
color.rs

1//! Color output utilities.
2//!
3//! Colors are off by default (agent-friendly). Enable with
4//! `BROWSER_AUTOMATION_CLI_COLOR=1`. Setting `NO_COLOR` to any value disables
5//! colors per <https://no-color.org/>.
6
7use std::env;
8use std::sync::OnceLock;
9
10fn env_is_truthy(name: &str) -> Option<bool> {
11    env::var(name)
12        .ok()
13        .map(|val| !matches!(val.to_lowercase().as_str(), "0" | "false" | "no"))
14}
15
16/// Returns true if color output is enabled.
17///
18/// Priority: `NO_COLOR` (presence disables, per spec) >
19/// `BROWSER_AUTOMATION_CLI_COLOR` (truthy enables) > default (off).
20pub fn is_enabled() -> bool {
21    static COLORS_ENABLED: OnceLock<bool> = OnceLock::new();
22    *COLORS_ENABLED.get_or_init(|| {
23        if env::var_os("NO_COLOR").is_some() {
24            return false;
25        }
26        env_is_truthy("BROWSER_AUTOMATION_CLI_COLOR").unwrap_or(false)
27    })
28}
29
30/// Format text in red (errors)
31pub fn red(text: &str) -> String {
32    if is_enabled() {
33        format!("\x1b[31m{}\x1b[0m", text)
34    } else {
35        text.to_string()
36    }
37}
38
39/// Format text in green (success)
40pub fn green(text: &str) -> String {
41    if is_enabled() {
42        format!("\x1b[32m{}\x1b[0m", text)
43    } else {
44        text.to_string()
45    }
46}
47
48/// Format text in yellow (warnings)
49pub fn yellow(text: &str) -> String {
50    if is_enabled() {
51        format!("\x1b[33m{}\x1b[0m", text)
52    } else {
53        text.to_string()
54    }
55}
56
57/// Format text in cyan (info/progress)
58pub fn cyan(text: &str) -> String {
59    if is_enabled() {
60        format!("\x1b[36m{}\x1b[0m", text)
61    } else {
62        text.to_string()
63    }
64}
65
66/// Format text in bold
67pub fn bold(text: &str) -> String {
68    if is_enabled() {
69        format!("\x1b[1m{}\x1b[0m", text)
70    } else {
71        text.to_string()
72    }
73}
74
75/// Format text in dim
76pub fn dim(text: &str) -> String {
77    if is_enabled() {
78        format!("\x1b[2m{}\x1b[0m", text)
79    } else {
80        text.to_string()
81    }
82}
83
84/// Red X error indicator
85pub fn error_indicator() -> &'static str {
86    static INDICATOR: OnceLock<String> = OnceLock::new();
87    INDICATOR.get_or_init(|| {
88        if is_enabled() {
89            "\x1b[31m✗\x1b[0m".to_string()
90        } else {
91            "✗".to_string()
92        }
93    })
94}
95
96/// Green checkmark success indicator
97pub fn success_indicator() -> &'static str {
98    static INDICATOR: OnceLock<String> = OnceLock::new();
99    INDICATOR.get_or_init(|| {
100        if is_enabled() {
101            "\x1b[32m✓\x1b[0m".to_string()
102        } else {
103            "✓".to_string()
104        }
105    })
106}
107
108/// Yellow warning indicator
109pub fn warning_indicator() -> &'static str {
110    static INDICATOR: OnceLock<String> = OnceLock::new();
111    INDICATOR.get_or_init(|| {
112        if is_enabled() {
113            "\x1b[33m⚠\x1b[0m".to_string()
114        } else {
115            "⚠".to_string()
116        }
117    })
118}
119
120/// Get console log color prefix by level
121pub fn console_level_prefix(level: &str) -> String {
122    if !is_enabled() {
123        return format!("[{}]", level);
124    }
125
126    let color = match level {
127        "error" => "\x1b[31m",
128        "warning" => "\x1b[33m",
129        "info" => "\x1b[36m",
130        _ => "",
131    };
132    if color.is_empty() {
133        format!("[{}]", level)
134    } else {
135        format!("{}[{}]\x1b[0m", color, level)
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_red_contains_ansi_codes() {
145        // Test the format structure (actual color depends on NO_COLOR env)
146        let formatted = format!("\x1b[31m{}\x1b[0m", "error");
147        assert!(formatted.contains("\x1b[31m"));
148        assert!(formatted.contains("\x1b[0m"));
149    }
150
151    #[test]
152    fn test_green_contains_ansi_codes() {
153        let formatted = format!("\x1b[32m{}\x1b[0m", "success");
154        assert!(formatted.contains("\x1b[32m"));
155    }
156
157    #[test]
158    fn test_console_level_prefix_contains_level() {
159        // Regardless of color state, the level text should be present
160        assert!(console_level_prefix("error").contains("error"));
161        assert!(console_level_prefix("warning").contains("warning"));
162        assert!(console_level_prefix("info").contains("info"));
163        assert!(console_level_prefix("log").contains("log"));
164    }
165
166    #[test]
167    fn test_indicators_contain_symbols() {
168        // Regardless of color state, symbols should be present
169        assert!(error_indicator().contains('✗'));
170        assert!(success_indicator().contains('✓'));
171        assert!(warning_indicator().contains('⚠'));
172    }
173}