ralph_workflow/logger/
mod.rs

1//! Logging and progress display utilities.
2//!
3//! This module provides structured logging for Ralph's pipeline:
4//! - `Logger` struct for consistent, colorized output
5//! - Progress bar display
6//! - Section headers and formatting
7//! - Colors & Formatting for terminal output
8//!
9//! # Example
10//!
11//! ```ignore
12//! use ralph::logger::Logger;
13//! use ralph::logger::Colors;
14//!
15//! let colors = Colors::new();
16//! let logger = Logger::new(colors)
17//!     .with_log_file(".agent/logs/pipeline.log");
18//!
19//! logger.info("Starting pipeline...");
20//! logger.success("Task completed");
21//! logger.warn("Potential issue detected");
22//! logger.error("Critical failure");
23//! ```
24
25mod output;
26mod progress;
27
28pub use output::{argv_requests_json, format_generic_json_for_display, Logger};
29pub use progress::print_progress;
30
31// ===== Colors & Formatting =====
32
33use std::io::IsTerminal;
34
35/// Check if colors should be enabled
36///
37/// This respects standard environment variables for color control:
38/// - `NO_COLOR=1`: Disables all ANSI output (<https://no-color.org/>)
39/// - `CLICOLOR_FORCE=1`: Forces colors even in non-TTY
40/// - `CLICOLOR=0`: Disables colors on macOS
41/// - `TERM=dumb`: Disables colors for basic terminals
42///
43/// # Returns
44///
45/// `true` if colors should be used, `false` otherwise.
46pub fn colors_enabled() -> bool {
47    // Check NO_COLOR first - this is the strongest user preference
48    // See <https://no-color.org/>
49    if std::env::var("NO_COLOR").is_ok() {
50        return false;
51    }
52
53    // Check CLICOLOR_FORCE - forces colors even in non-TTY
54    // See <https://man.openbsd.org/man1/ls.1#CLICOLOR_FORCE>
55    // Per the BSD specification, any non-empty value except "0" forces colors.
56    // The empty string check handles the case where the variable is unset or
57    // explicitly set to empty (both cases should be ignored).
58    if let Ok(val) = std::env::var("CLICOLOR_FORCE") {
59        if !val.is_empty() && val != "0" {
60            return true;
61        }
62    }
63
64    // Check CLICOLOR (macOS) - 0 means disable colors
65    if let Ok(val) = std::env::var("CLICOLOR") {
66        if val == "0" {
67            return false;
68        }
69    }
70
71    // Check TERM for dumb terminal
72    if let Ok(term) = std::env::var("TERM") {
73        if term.to_lowercase() == "dumb" {
74            return false;
75        }
76    }
77
78    // Default: color if TTY
79    std::io::stdout().is_terminal()
80}
81
82/// ANSI color codes
83#[derive(Clone, Copy)]
84pub struct Colors {
85    pub(crate) enabled: bool,
86}
87
88impl Colors {
89    pub fn new() -> Self {
90        Self {
91            enabled: colors_enabled(),
92        }
93    }
94
95    // Style codes
96    pub const fn bold(self) -> &'static str {
97        if self.enabled {
98            "\x1b[1m"
99        } else {
100            ""
101        }
102    }
103
104    pub const fn dim(self) -> &'static str {
105        if self.enabled {
106            "\x1b[2m"
107        } else {
108            ""
109        }
110    }
111
112    pub const fn reset(self) -> &'static str {
113        if self.enabled {
114            "\x1b[0m"
115        } else {
116            ""
117        }
118    }
119
120    // Foreground colors
121    pub const fn red(self) -> &'static str {
122        if self.enabled {
123            "\x1b[31m"
124        } else {
125            ""
126        }
127    }
128
129    pub const fn green(self) -> &'static str {
130        if self.enabled {
131            "\x1b[32m"
132        } else {
133            ""
134        }
135    }
136
137    pub const fn yellow(self) -> &'static str {
138        if self.enabled {
139            "\x1b[33m"
140        } else {
141            ""
142        }
143    }
144
145    pub const fn blue(self) -> &'static str {
146        if self.enabled {
147            "\x1b[34m"
148        } else {
149            ""
150        }
151    }
152
153    pub const fn magenta(self) -> &'static str {
154        if self.enabled {
155            "\x1b[35m"
156        } else {
157            ""
158        }
159    }
160
161    pub const fn cyan(self) -> &'static str {
162        if self.enabled {
163            "\x1b[36m"
164        } else {
165            ""
166        }
167    }
168
169    pub const fn white(self) -> &'static str {
170        if self.enabled {
171            "\x1b[37m"
172        } else {
173            ""
174        }
175    }
176}
177
178impl Default for Colors {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184/// Box-drawing characters for visual structure
185pub const BOX_TL: char = '╭';
186pub const BOX_TR: char = '╮';
187pub const BOX_BL: char = '╰';
188pub const BOX_BR: char = '╯';
189pub const BOX_H: char = '─';
190pub const BOX_V: char = '│';
191
192/// Icons for output
193pub const ARROW: char = '→';
194pub const CHECK: char = '✓';
195pub const CROSS: char = '✗';
196pub const WARN: char = '⚠';
197pub const INFO: char = 'ℹ';
198
199#[cfg(test)]
200mod colors_tests {
201    use super::*;
202
203    #[test]
204    fn test_colors_disabled() {
205        let c = Colors { enabled: false };
206        assert_eq!(c.bold(), "");
207        assert_eq!(c.red(), "");
208        assert_eq!(c.reset(), "");
209    }
210
211    #[test]
212    fn test_colors_enabled() {
213        let c = Colors { enabled: true };
214        assert_eq!(c.bold(), "\x1b[1m");
215        assert_eq!(c.red(), "\x1b[31m");
216        assert_eq!(c.reset(), "\x1b[0m");
217    }
218
219    #[test]
220    fn test_box_chars() {
221        assert_eq!(BOX_TL, '╭');
222        assert_eq!(BOX_TR, '╮');
223        assert_eq!(BOX_H, '─');
224    }
225
226    #[test]
227    fn test_colors_enabled_respects_no_color() {
228        // Save original NO_COLOR value
229        let original = std::env::var("NO_COLOR");
230
231        // Set NO_COLOR=1
232        std::env::set_var("NO_COLOR", "1");
233
234        // Should return false regardless of TTY status
235        assert!(!colors_enabled(), "NO_COLOR=1 should disable colors");
236
237        // Restore original value
238        match original {
239            Ok(val) => std::env::set_var("NO_COLOR", val),
240            Err(_) => std::env::remove_var("NO_COLOR"),
241        }
242    }
243
244    #[test]
245    fn test_colors_enabled_respects_clicolor_force() {
246        // Save original values
247        let original_no_color = std::env::var("NO_COLOR");
248        let original_clicolor_force = std::env::var("CLICOLOR_FORCE");
249
250        // Ensure NO_COLOR is not set
251        std::env::remove_var("NO_COLOR");
252
253        // Set CLICOLOR_FORCE=1
254        std::env::set_var("CLICOLOR_FORCE", "1");
255
256        // Should return true even if not a TTY
257        assert!(colors_enabled(), "CLICOLOR_FORCE=1 should enable colors");
258
259        // Restore original values
260        match original_no_color {
261            Ok(val) => std::env::set_var("NO_COLOR", val),
262            Err(_) => std::env::remove_var("NO_COLOR"),
263        }
264        match original_clicolor_force {
265            Ok(val) => std::env::set_var("CLICOLOR_FORCE", val),
266            Err(_) => std::env::remove_var("CLICOLOR_FORCE"),
267        }
268    }
269
270    #[test]
271    fn test_colors_enabled_respects_clicolor_zero() {
272        // Save original values
273        let original_no_color = std::env::var("NO_COLOR");
274        let original_clicolor = std::env::var("CLICOLOR");
275
276        // Ensure NO_COLOR is not set
277        std::env::remove_var("NO_COLOR");
278
279        // Set CLICOLOR=0
280        std::env::set_var("CLICOLOR", "0");
281
282        // Should return false
283        assert!(!colors_enabled(), "CLICOLOR=0 should disable colors");
284
285        // Restore original values
286        match original_no_color {
287            Ok(val) => std::env::set_var("NO_COLOR", val),
288            Err(_) => std::env::remove_var("NO_COLOR"),
289        }
290        match original_clicolor {
291            Ok(val) => std::env::set_var("CLICOLOR", val),
292            Err(_) => std::env::remove_var("CLICOLOR"),
293        }
294    }
295
296    #[test]
297    fn test_colors_enabled_respects_term_dumb() {
298        // Save original values
299        let original_no_color = std::env::var("NO_COLOR");
300        let original_term = std::env::var("TERM");
301
302        // Ensure NO_COLOR is not set
303        std::env::remove_var("NO_COLOR");
304
305        // Set TERM=dumb
306        std::env::set_var("TERM", "dumb");
307
308        // Should return false
309        assert!(!colors_enabled(), "TERM=dumb should disable colors");
310
311        // Restore original values
312        match original_no_color {
313            Ok(val) => std::env::set_var("NO_COLOR", val),
314            Err(_) => std::env::remove_var("NO_COLOR"),
315        }
316        match original_term {
317            Ok(val) => std::env::set_var("TERM", val),
318            Err(_) => std::env::remove_var("TERM"),
319        }
320    }
321
322    #[test]
323    fn test_colors_enabled_no_color_takes_precedence() {
324        // Save original values
325        let original_no_color = std::env::var("NO_COLOR");
326        let original_clicolor_force = std::env::var("CLICOLOR_FORCE");
327
328        // Set both NO_COLOR=1 and CLICOLOR_FORCE=1
329        std::env::set_var("NO_COLOR", "1");
330        std::env::set_var("CLICOLOR_FORCE", "1");
331
332        // NO_COLOR should take precedence
333        assert!(
334            !colors_enabled(),
335            "NO_COLOR should take precedence over CLICOLOR_FORCE"
336        );
337
338        // Restore original values
339        match original_no_color {
340            Ok(val) => std::env::set_var("NO_COLOR", val),
341            Err(_) => std::env::remove_var("NO_COLOR"),
342        }
343        match original_clicolor_force {
344            Ok(val) => std::env::set_var("CLICOLOR_FORCE", val),
345            Err(_) => std::env::remove_var("CLICOLOR_FORCE"),
346        }
347    }
348
349    #[test]
350    fn test_colors_enabled_term_dumb_case_insensitive() {
351        // Save original values
352        let original_no_color = std::env::var("NO_COLOR");
353        let original_term = std::env::var("TERM");
354
355        // Ensure NO_COLOR is not set
356        std::env::remove_var("NO_COLOR");
357
358        // Test various case combinations
359        for term_value in ["dumb", "DUMB", "Dumb", "DuMb"] {
360            std::env::set_var("TERM", term_value);
361            assert!(
362                !colors_enabled(),
363                "TERM={term_value} should disable colors (case-insensitive)"
364            );
365        }
366
367        // Restore original values
368        match original_no_color {
369            Ok(val) => std::env::set_var("NO_COLOR", val),
370            Err(_) => std::env::remove_var("NO_COLOR"),
371        }
372        match original_term {
373            Ok(val) => std::env::set_var("TERM", val),
374            Err(_) => std::env::remove_var("TERM"),
375        }
376    }
377}