Skip to main content

browser_automation_cli/
color.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Color output utilities.
3//!
4//! Colors are off by default (agent-friendly). Enable with
5//! `browser-automation-cli config set color true` (XDG config).
6//!
7//! Priority (first match wins):
8//! 1. process override via [`set_plain`](crate::color::set_plain) / `--plain`
9//! 2. `NO_COLOR` (any value) — <https://no-color.org/>
10//! 3. `CLICOLOR=0` or `CLICOLOR_FORCE=0`
11//! 4. `TERM=dumb`
12//! 5. XDG config `color = true`
13//! 6. default off
14
15use std::env;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::OnceLock;
18
19/// Process-level plain override (`--plain`).
20///
21/// # Concurrency
22///
23/// Single stable address shared by all threads. Uses `Ordering::Relaxed`
24/// because the flag is a pure boolean preference with no dependent data
25/// publication (no other memory is synchronized through this atomic).
26static PLAIN_OVERRIDE: AtomicBool = AtomicBool::new(false);
27
28/// Cached result of env/XDG color policy (computed once per process).
29///
30/// # Concurrency
31///
32/// `OnceLock` ensures a single init even under concurrent first calls.
33/// After init the value is immutable. `--plain` is checked *before* this
34/// cache so the process override always wins without needing a reset.
35static COLORS_ENABLED: OnceLock<bool> = OnceLock::new();
36
37/// Force plain (no ANSI) for this process. Called from CLI dispatch for `--plain`.
38pub fn set_plain(plain: bool) {
39    // Relaxed: no other memory depends on this store for correctness.
40    PLAIN_OVERRIDE.store(plain, Ordering::Relaxed);
41}
42
43/// Returns true if color output is enabled.
44pub fn is_enabled() -> bool {
45    // Relaxed: pure flag read; `--plain` must short-circuit before the cache.
46    if PLAIN_OVERRIDE.load(Ordering::Relaxed) {
47        return false;
48    }
49    *COLORS_ENABLED.get_or_init(|| {
50        if env::var_os("NO_COLOR").is_some() {
51            return false;
52        }
53        if env::var_os("CLICOLOR")
54            .map(|v| v == "0")
55            .unwrap_or(false)
56        {
57            return false;
58        }
59        if env::var_os("CLICOLOR_FORCE")
60            .map(|v| v == "0")
61            .unwrap_or(false)
62        {
63            return false;
64        }
65        if env::var_os("TERM").is_some_and(|t| t == "dumb") {
66            return false;
67        }
68        crate::xdg::load_config()
69            .ok()
70            .and_then(|c| c.color)
71            .unwrap_or(false)
72    })
73}
74
75/// Format text in red (errors)
76pub fn red(text: &str) -> String {
77    if is_enabled() {
78        format!("\x1b[31m{}\x1b[0m", text)
79    } else {
80        text.to_string()
81    }
82}
83
84/// Format text in green (success)
85pub fn green(text: &str) -> String {
86    if is_enabled() {
87        format!("\x1b[32m{}\x1b[0m", text)
88    } else {
89        text.to_string()
90    }
91}
92
93/// Format text in yellow (warnings)
94pub fn yellow(text: &str) -> String {
95    if is_enabled() {
96        format!("\x1b[33m{}\x1b[0m", text)
97    } else {
98        text.to_string()
99    }
100}
101
102/// Format text in cyan (info)
103pub fn cyan(text: &str) -> String {
104    if is_enabled() {
105        format!("\x1b[36m{}\x1b[0m", text)
106    } else {
107        text.to_string()
108    }
109}
110
111/// Format text in bold
112pub fn bold(text: &str) -> String {
113    if is_enabled() {
114        format!("\x1b[1m{}\x1b[0m", text)
115    } else {
116        text.to_string()
117    }
118}
119
120/// Format text dimmed
121pub fn dim(text: &str) -> String {
122    if is_enabled() {
123        format!("\x1b[2m{}\x1b[0m", text)
124    } else {
125        text.to_string()
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn plain_override_disables_color() {
135        set_plain(true);
136        assert!(!is_enabled());
137        set_plain(false);
138    }
139}