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}