Skip to main content

atlassian_cli_output/
colors.rs

1use colored::Colorize;
2use std::env;
3use std::io::{self, IsTerminal};
4
5/// Detects whether colors should be used based on environment variables and TTY detection
6pub fn should_use_colors() -> bool {
7    // NO_COLOR takes precedence (https://no-color.org/)
8    if env::var("NO_COLOR").is_ok() {
9        return false;
10    }
11
12    // TERM=dumb means no color support
13    if env::var("TERM")
14        .map(|t| t.eq_ignore_ascii_case("dumb"))
15        .unwrap_or(false)
16    {
17        return false;
18    }
19
20    // CLICOLOR_FORCE overrides TTY detection
21    if env::var("CLICOLOR_FORCE").is_ok() {
22        return true;
23    }
24
25    // Auto-detect TTY
26    io::stdout().is_terminal()
27}
28
29/// Status formatter with color support
30pub struct StatusFormatter {
31    use_colors: bool,
32}
33
34impl StatusFormatter {
35    /// Create a new status formatter with color detection
36    pub fn new() -> Self {
37        Self {
38            use_colors: should_use_colors(),
39        }
40    }
41
42    /// Create a status formatter with explicit color setting (useful for testing)
43    pub fn with_colors(use_colors: bool) -> Self {
44        Self { use_colors }
45    }
46
47    /// Format a status string with appropriate color and icon
48    pub fn format(&self, status: &str, icon: &str) -> String {
49        if !self.use_colors {
50            return format!("{} {}", status, icon);
51        }
52
53        let status_upper = status.to_uppercase();
54        match status_upper.as_str() {
55            "SUCCESSFUL" | "COMPLETED" => {
56                format!("{} {}", status.green().bold(), icon)
57            }
58            "FAILED" | "ERROR" => {
59                format!("{} {}", status.red().bold(), icon)
60            }
61            "IN_PROGRESS" | "RUNNING" => {
62                format!("{} {}", status.yellow().bold(), icon)
63            }
64            "STOPPED" => {
65                format!("{} {}", status.bright_red().bold(), icon)
66            }
67            "PENDING" | "NOT_RUN" => {
68                format!("{} {}", status.cyan(), icon)
69            }
70            "PAUSED" => {
71                format!("{} {}", status.magenta(), icon)
72            }
73            _ => {
74                format!("{} {}", status.white(), icon)
75            }
76        }
77    }
78
79    /// Format just the status text without an icon
80    pub fn format_status(&self, status: &str) -> String {
81        if !self.use_colors {
82            return status.to_string();
83        }
84
85        let status_upper = status.to_uppercase();
86        match status_upper.as_str() {
87            "SUCCESSFUL" | "COMPLETED" => status.green().bold().to_string(),
88            "FAILED" | "ERROR" => status.red().bold().to_string(),
89            "IN_PROGRESS" | "RUNNING" => status.yellow().bold().to_string(),
90            "STOPPED" => status.bright_red().bold().to_string(),
91            "PENDING" | "NOT_RUN" => status.cyan().to_string(),
92            "PAUSED" => status.magenta().to_string(),
93            _ => status.white().to_string(),
94        }
95    }
96}
97
98impl Default for StatusFormatter {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::sync::Mutex;
108
109    // Environment variables are process-global, so the tests below cannot run
110    // concurrently: EnvGuard restores state when a test finishes, but it cannot stop a
111    // sibling test from mutating the same variable mid-assertion. Two vectors existed,
112    // both hitting test_clicolor_force_enables_colors, which needs NO_COLOR unset and
113    // TERM not "dumb":
114    //   - test_no_color_env_disables_colors and
115    //     test_no_color_takes_precedence_over_clicolor_force set NO_COLOR=1
116    //   - test_term_dumb_disables_colors sets TERM=dumb, which should_use_colors()
117    //     checks before CLICOLOR_FORCE
118    // Every test that touches the environment must take this lock first.
119    static ENV_LOCK: Mutex<()> = Mutex::new(());
120
121    // Poison-tolerant on purpose: if one env test fails while holding the lock, the
122    // other three should still report their own result rather than cascade.
123    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
124        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
125    }
126
127    // Helper to save and restore environment variables
128    struct EnvGuard {
129        vars: Vec<(String, Option<String>)>,
130    }
131
132    impl EnvGuard {
133        fn new() -> Self {
134            Self { vars: Vec::new() }
135        }
136
137        fn set(&mut self, key: &str, value: &str) {
138            let old_value = env::var(key).ok();
139            self.vars.push((key.to_string(), old_value));
140            env::set_var(key, value);
141        }
142
143        fn remove(&mut self, key: &str) {
144            let old_value = env::var(key).ok();
145            self.vars.push((key.to_string(), old_value));
146            env::remove_var(key);
147        }
148    }
149
150    impl Drop for EnvGuard {
151        fn drop(&mut self) {
152            for (key, value) in self.vars.iter().rev() {
153                match value {
154                    Some(v) => env::set_var(key, v),
155                    None => env::remove_var(key),
156                }
157            }
158        }
159    }
160
161    #[test]
162    fn test_no_color_env_disables_colors() {
163        let _env = env_lock();
164        let mut guard = EnvGuard::new();
165        guard.set("NO_COLOR", "1");
166        assert!(!should_use_colors());
167    }
168
169    #[test]
170    fn test_term_dumb_disables_colors() {
171        let _env = env_lock();
172        let mut guard = EnvGuard::new();
173        guard.remove("NO_COLOR");
174        guard.remove("CLICOLOR_FORCE");
175        guard.set("TERM", "dumb");
176        assert!(!should_use_colors());
177    }
178
179    #[test]
180    fn test_clicolor_force_enables_colors() {
181        let _env = env_lock();
182        let mut guard = EnvGuard::new();
183        guard.remove("NO_COLOR");
184        guard.set("CLICOLOR_FORCE", "1");
185        assert!(should_use_colors());
186    }
187
188    #[test]
189    fn test_no_color_takes_precedence_over_clicolor_force() {
190        let _env = env_lock();
191        let mut guard = EnvGuard::new();
192        guard.set("NO_COLOR", "1");
193        guard.set("CLICOLOR_FORCE", "1");
194        assert!(!should_use_colors());
195    }
196
197    #[test]
198    fn test_formatter_successful_status() {
199        let formatter = StatusFormatter::with_colors(false);
200        let result = formatter.format("SUCCESSFUL", "✅");
201        assert!(result.contains("SUCCESSFUL"));
202        assert!(result.contains("✅"));
203    }
204
205    #[test]
206    fn test_formatter_failed_status() {
207        let formatter = StatusFormatter::with_colors(false);
208        let result = formatter.format("FAILED", "❌");
209        assert!(result.contains("FAILED"));
210        assert!(result.contains("❌"));
211    }
212
213    #[test]
214    fn test_formatter_in_progress_status() {
215        let formatter = StatusFormatter::with_colors(false);
216        let result = formatter.format("IN_PROGRESS", "🔄");
217        assert!(result.contains("IN_PROGRESS"));
218        assert!(result.contains("🔄"));
219    }
220
221    #[test]
222    fn test_formatter_pending_status() {
223        let formatter = StatusFormatter::with_colors(false);
224        let result = formatter.format("PENDING", "⏳");
225        assert!(result.contains("PENDING"));
226        assert!(result.contains("⏳"));
227    }
228
229    #[test]
230    fn test_formatter_stopped_status() {
231        let formatter = StatusFormatter::with_colors(false);
232        let result = formatter.format("STOPPED", "⏹");
233        assert!(result.contains("STOPPED"));
234        assert!(result.contains("⏹"));
235    }
236
237    #[test]
238    fn test_formatter_paused_status() {
239        let formatter = StatusFormatter::with_colors(false);
240        let result = formatter.format("PAUSED", "⏸");
241        assert!(result.contains("PAUSED"));
242        assert!(result.contains("⏸"));
243    }
244
245    #[test]
246    fn test_formatter_unknown_status() {
247        let formatter = StatusFormatter::with_colors(false);
248        let result = formatter.format("UNKNOWN", "❓");
249        assert!(result.contains("UNKNOWN"));
250        assert!(result.contains("❓"));
251    }
252
253    #[test]
254    fn test_formatter_with_colors_enabled() {
255        let formatter = StatusFormatter::with_colors(true);
256        let result = formatter.format("SUCCESSFUL", "✅");
257        // When colors are enabled, the result contains ANSI codes
258        // We can't easily test the exact ANSI codes, but we can verify content is present
259        assert!(result.contains("✅"));
260    }
261
262    #[test]
263    fn test_format_status_without_icon() {
264        let formatter = StatusFormatter::with_colors(false);
265        let result = formatter.format_status("FAILED");
266        assert_eq!(result, "FAILED");
267    }
268
269    #[test]
270    fn test_formatter_case_insensitive() {
271        let formatter = StatusFormatter::with_colors(false);
272        let result1 = formatter.format("successful", "✅");
273        let result2 = formatter.format("SUCCESSFUL", "✅");
274        let result3 = formatter.format("Successful", "✅");
275
276        assert!(result1.contains("successful"));
277        assert!(result2.contains("SUCCESSFUL"));
278        assert!(result3.contains("Successful"));
279    }
280}