ralph_workflow/logger/
mod.rs1#[cfg(any(test, feature = "test-utils"))]
13pub mod output;
14#[cfg(not(any(test, feature = "test-utils")))]
15mod output;
16
17mod progress;
18
19pub use output::{argv_requests_json, format_generic_json_for_display, Loggable, Logger};
20pub use progress::print_progress;
21
22use std::io::IsTerminal;
25
26pub trait ColorEnvironment {
31 fn get_var(&self, name: &str) -> Option<String>;
33 fn is_terminal(&self) -> bool;
35}
36
37pub struct RealColorEnvironment;
39
40impl ColorEnvironment for RealColorEnvironment {
41 fn get_var(&self, name: &str) -> Option<String> {
42 std::env::var(name).ok()
43 }
44
45 fn is_terminal(&self) -> bool {
46 std::io::stdout().is_terminal()
47 }
48}
49
50pub fn colors_enabled_with_env(env: &dyn ColorEnvironment) -> bool {
54 if env.get_var("NO_COLOR").is_some() {
57 return false;
58 }
59
60 if let Some(val) = env.get_var("CLICOLOR_FORCE") {
63 if !val.is_empty() && val != "0" {
64 return true;
65 }
66 }
67
68 if let Some(val) = env.get_var("CLICOLOR") {
70 if val == "0" {
71 return false;
72 }
73 }
74
75 if let Some(term) = env.get_var("TERM") {
77 if term.to_lowercase() == "dumb" {
78 return false;
79 }
80 }
81
82 env.is_terminal()
84}
85
86pub fn colors_enabled() -> bool {
94 colors_enabled_with_env(&RealColorEnvironment)
95}
96
97#[derive(Clone, Copy)]
99pub struct Colors {
100 pub(crate) enabled: bool,
101}
102
103impl Colors {
104 pub fn new() -> Self {
105 Self {
106 enabled: colors_enabled(),
107 }
108 }
109
110 pub const fn bold(self) -> &'static str {
111 if self.enabled {
112 "\x1b[1m"
113 } else {
114 ""
115 }
116 }
117
118 pub const fn dim(self) -> &'static str {
119 if self.enabled {
120 "\x1b[2m"
121 } else {
122 ""
123 }
124 }
125
126 pub const fn reset(self) -> &'static str {
127 if self.enabled {
128 "\x1b[0m"
129 } else {
130 ""
131 }
132 }
133
134 pub const fn red(self) -> &'static str {
135 if self.enabled {
136 "\x1b[31m"
137 } else {
138 ""
139 }
140 }
141
142 pub const fn green(self) -> &'static str {
143 if self.enabled {
144 "\x1b[32m"
145 } else {
146 ""
147 }
148 }
149
150 pub const fn yellow(self) -> &'static str {
151 if self.enabled {
152 "\x1b[33m"
153 } else {
154 ""
155 }
156 }
157
158 pub const fn blue(self) -> &'static str {
159 if self.enabled {
160 "\x1b[34m"
161 } else {
162 ""
163 }
164 }
165
166 pub const fn magenta(self) -> &'static str {
167 if self.enabled {
168 "\x1b[35m"
169 } else {
170 ""
171 }
172 }
173
174 pub const fn cyan(self) -> &'static str {
175 if self.enabled {
176 "\x1b[36m"
177 } else {
178 ""
179 }
180 }
181
182 pub const fn white(self) -> &'static str {
183 if self.enabled {
184 "\x1b[37m"
185 } else {
186 ""
187 }
188 }
189}
190
191impl Default for Colors {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197pub const BOX_TL: char = '╭';
199pub const BOX_TR: char = '╮';
200pub const BOX_BL: char = '╰';
201pub const BOX_BR: char = '╯';
202pub const BOX_H: char = '─';
203pub const BOX_V: char = '│';
204
205pub const ARROW: char = '→';
207pub const CHECK: char = '✓';
208pub const CROSS: char = '✗';
209pub const WARN: char = '⚠';
210pub const INFO: char = 'ℹ';
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use std::collections::HashMap;
216
217 struct MockColorEnvironment {
219 vars: HashMap<String, String>,
220 is_tty: bool,
221 }
222
223 impl MockColorEnvironment {
224 fn new() -> Self {
225 Self {
226 vars: HashMap::new(),
227 is_tty: true,
228 }
229 }
230
231 fn with_var(mut self, name: &str, value: &str) -> Self {
232 self.vars.insert(name.to_string(), value.to_string());
233 self
234 }
235
236 fn not_tty(mut self) -> Self {
237 self.is_tty = false;
238 self
239 }
240 }
241
242 impl ColorEnvironment for MockColorEnvironment {
243 fn get_var(&self, name: &str) -> Option<String> {
244 self.vars.get(name).cloned()
245 }
246
247 fn is_terminal(&self) -> bool {
248 self.is_tty
249 }
250 }
251
252 #[test]
253 fn test_colors_disabled_struct() {
254 let c = Colors { enabled: false };
255 assert_eq!(c.bold(), "");
256 assert_eq!(c.red(), "");
257 assert_eq!(c.reset(), "");
258 }
259
260 #[test]
261 fn test_colors_enabled_struct() {
262 let c = Colors { enabled: true };
263 assert_eq!(c.bold(), "\x1b[1m");
264 assert_eq!(c.red(), "\x1b[31m");
265 assert_eq!(c.reset(), "\x1b[0m");
266 }
267
268 #[test]
269 fn test_box_chars() {
270 assert_eq!(BOX_TL, '╭');
271 assert_eq!(BOX_TR, '╮');
272 assert_eq!(BOX_H, '─');
273 }
274
275 #[test]
276 fn test_colors_enabled_respects_no_color() {
277 let env = MockColorEnvironment::new().with_var("NO_COLOR", "1");
278 assert!(!colors_enabled_with_env(&env));
279 }
280
281 #[test]
282 fn test_colors_enabled_respects_clicolor_force() {
283 let env = MockColorEnvironment::new()
284 .with_var("CLICOLOR_FORCE", "1")
285 .not_tty();
286 assert!(colors_enabled_with_env(&env));
287 }
288
289 #[test]
290 fn test_colors_enabled_respects_clicolor_zero() {
291 let env = MockColorEnvironment::new().with_var("CLICOLOR", "0");
292 assert!(!colors_enabled_with_env(&env));
293 }
294
295 #[test]
296 fn test_colors_enabled_respects_term_dumb() {
297 let env = MockColorEnvironment::new().with_var("TERM", "dumb");
298 assert!(!colors_enabled_with_env(&env));
299 }
300
301 #[test]
302 fn test_colors_enabled_no_color_takes_precedence() {
303 let env = MockColorEnvironment::new()
304 .with_var("NO_COLOR", "1")
305 .with_var("CLICOLOR_FORCE", "1");
306 assert!(!colors_enabled_with_env(&env));
307 }
308
309 #[test]
310 fn test_colors_enabled_term_dumb_case_insensitive() {
311 for term in ["dumb", "DUMB", "Dumb", "DuMb"] {
312 let env = MockColorEnvironment::new().with_var("TERM", term);
313 assert!(
314 !colors_enabled_with_env(&env),
315 "TERM={term} should disable colors"
316 );
317 }
318 }
319
320 #[test]
321 fn test_colors_enabled_default_tty() {
322 let env = MockColorEnvironment::new();
323 assert!(colors_enabled_with_env(&env));
324 }
325
326 #[test]
327 fn test_colors_enabled_default_not_tty() {
328 let env = MockColorEnvironment::new().not_tty();
329 assert!(!colors_enabled_with_env(&env));
330 }
331}