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