Skip to main content

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// The output module is pub so that integration tests can access TestLogger
11// when the test-utils feature is enabled.
12#[cfg(any(test, feature = "test-utils"))]
13pub mod output;
14#[cfg(not(any(test, feature = "test-utils")))]
15mod output;
16
17mod ansi;
18mod ansi_stripper;
19mod file_writer;
20mod io;
21mod logger_wrapper;
22mod progress;
23mod runtime;
24mod stdout_writer;
25
26pub use ansi::strip_ansi_codes;
27pub use output::{argv_requests_json, format_generic_json_for_display, Loggable, Logger};
28pub use progress::print_progress;
29
30pub use crate::logger::file_writer::append_to_file;
31pub use crate::logger::logger_wrapper::LoggerIoWrapper;
32
33pub trait ColorEnvironment {
34    fn get_var(&self, name: &str) -> Option<String>;
35    fn is_terminal(&self) -> bool;
36}
37
38pub struct RealColorEnvironment;
39
40impl ColorEnvironment for RealColorEnvironment {
41    fn get_var(&self, name: &str) -> Option<String> {
42        runtime::get_color_env_var(name)
43    }
44
45    fn is_terminal(&self) -> bool {
46        runtime_color_env_is_terminal()
47    }
48}
49
50pub fn stdout_write(buf: &[u8]) -> std::io::Result<usize> {
51    runtime::stdout_write(buf)
52}
53
54pub fn stdout_write_line(s: &str) -> std::io::Result<()> {
55    runtime::stdout_write_line(s)
56}
57
58pub fn stderr_write_line(s: &str) -> std::io::Result<()> {
59    runtime::stderr_write_line(s)
60}
61
62pub fn stdout_flush() -> std::io::Result<()> {
63    runtime::stdout_flush()
64}
65
66#[must_use]
67pub fn stdout_is_terminal() -> bool {
68    runtime::stdout_is_terminal()
69}
70
71fn runtime_color_env_is_terminal() -> bool {
72    let env = runtime::RealColorEnvironment;
73    let _ = runtime::ColorEnvironment::get_var(&env, "TERM");
74    runtime::ColorEnvironment::is_terminal(&env)
75}
76
77/// Check if colors should be enabled using the provided environment.
78///
79/// This is the testable version that takes an environment trait.
80pub fn colors_enabled_with_env(env: &dyn ColorEnvironment) -> bool {
81    // Check NO_COLOR first - this is the strongest user preference
82    // See <https://no-color.org/>
83    if env.get_var("NO_COLOR").is_some() {
84        return false;
85    }
86
87    // Check CLICOLOR_FORCE - forces colors even in non-TTY
88    // See <https://man.openbsd.org/man1/ls.1#CLICOLOR_FORCE>
89    if let Some(val) = env.get_var("CLICOLOR_FORCE") {
90        if !val.is_empty() && val != "0" {
91            return true;
92        }
93    }
94
95    // Check CLICOLOR (macOS) - 0 means disable colors
96    if let Some(val) = env.get_var("CLICOLOR") {
97        if val == "0" {
98            return false;
99        }
100    }
101
102    // Check TERM for dumb terminal
103    if let Some(term) = env.get_var("TERM") {
104        if term.to_lowercase() == "dumb" {
105            return false;
106        }
107    }
108
109    // Default: color if TTY
110    env.is_terminal()
111}
112
113/// Check if colors should be enabled.
114///
115/// This respects standard environment variables for color control:
116/// - `NO_COLOR=1`: Disables all ANSI output (<https://no-color.org/>)
117/// - `CLICOLOR_FORCE=1`: Forces colors even in non-TTY
118/// - `CLICOLOR=0`: Disables colors on macOS
119/// - `TERM=dumb`: Disables colors for basic terminals
120#[must_use]
121pub fn colors_enabled() -> bool {
122    colors_enabled_with_env(&RealColorEnvironment)
123}
124
125/// ANSI color codes
126#[derive(Clone, Copy)]
127pub struct Colors {
128    pub(crate) enabled: bool,
129}
130
131impl Colors {
132    #[must_use]
133    pub fn new() -> Self {
134        Self {
135            enabled: colors_enabled(),
136        }
137    }
138
139    /// **TEST-ONLY:** Create a Colors instance with explicit enabled/disabled state.
140    ///
141    /// This constructor is for test utilities that need explicit control over
142    /// color state, bypassing the automatic detection from `colors_enabled()`.
143    ///
144    /// # Availability
145    ///
146    /// **This method is ONLY available in test/test-utils builds** (`#[cfg(any(test, feature = "test-utils"))]`).
147    /// It is not part of the public API for library users in production builds.
148    /// The extensive documentation here is for maintainers reviewing test code.
149    ///
150    /// # Example
151    ///
152    /// ```
153    /// # // This doctest only runs when test-utils is enabled
154    /// # #[cfg(any(test, feature = "test-utils"))]
155    /// # {
156    /// use ralph_workflow::logger::Colors;
157    ///
158    /// // Force colors off for tests checking raw output
159    /// let colors = Colors::with_enabled(false);
160    /// assert_eq!(colors.bold(), ""); // No ANSI codes
161    ///
162    /// // Force colors on for tests checking colored output
163    /// let colors = Colors::with_enabled(true);
164    /// assert_eq!(colors.bold(), "\x1b[1m"); // ANSI bold code
165    /// # }
166    /// ```
167    #[cfg(any(test, feature = "test-utils"))]
168    #[must_use]
169    pub const fn with_enabled(enabled: bool) -> Self {
170        Self { enabled }
171    }
172
173    #[must_use]
174    pub const fn bold(self) -> &'static str {
175        if self.enabled {
176            "\x1b[1m"
177        } else {
178            ""
179        }
180    }
181
182    #[must_use]
183    pub const fn dim(self) -> &'static str {
184        if self.enabled {
185            "\x1b[2m"
186        } else {
187            ""
188        }
189    }
190
191    #[must_use]
192    pub const fn reset(self) -> &'static str {
193        if self.enabled {
194            "\x1b[0m"
195        } else {
196            ""
197        }
198    }
199
200    #[must_use]
201    pub const fn red(self) -> &'static str {
202        if self.enabled {
203            "\x1b[31m"
204        } else {
205            ""
206        }
207    }
208
209    #[must_use]
210    pub const fn green(self) -> &'static str {
211        if self.enabled {
212            "\x1b[32m"
213        } else {
214            ""
215        }
216    }
217
218    #[must_use]
219    pub const fn yellow(self) -> &'static str {
220        if self.enabled {
221            "\x1b[33m"
222        } else {
223            ""
224        }
225    }
226
227    #[must_use]
228    pub const fn blue(self) -> &'static str {
229        if self.enabled {
230            "\x1b[34m"
231        } else {
232            ""
233        }
234    }
235
236    #[must_use]
237    pub const fn magenta(self) -> &'static str {
238        if self.enabled {
239            "\x1b[35m"
240        } else {
241            ""
242        }
243    }
244
245    #[must_use]
246    pub const fn cyan(self) -> &'static str {
247        if self.enabled {
248            "\x1b[36m"
249        } else {
250            ""
251        }
252    }
253
254    #[must_use]
255    pub const fn white(self) -> &'static str {
256        if self.enabled {
257            "\x1b[37m"
258        } else {
259            ""
260        }
261    }
262}
263
264impl Default for Colors {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270/// Box-drawing characters for visual structure
271pub const BOX_TL: char = '╭';
272pub const BOX_TR: char = '╮';
273pub const BOX_BL: char = '╰';
274pub const BOX_BR: char = '╯';
275pub const BOX_H: char = '─';
276pub const BOX_V: char = '│';
277
278/// Icons for output
279pub const ARROW: char = '→';
280pub const CHECK: char = '✓';
281pub const CROSS: char = '✗';
282pub const WARN: char = '⚠';
283pub const INFO: char = 'ℹ';
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use std::collections::HashMap;
289
290    /// Mock environment for testing color detection.
291    struct MockColorEnvironment {
292        vars: HashMap<String, String>,
293        is_tty: bool,
294    }
295
296    impl MockColorEnvironment {
297        fn new() -> Self {
298            Self {
299                vars: HashMap::new(),
300                is_tty: true,
301            }
302        }
303
304        fn with_var(mut self, name: &str, value: &str) -> Self {
305            self.vars.insert(name.to_string(), value.to_string());
306            self
307        }
308
309        fn not_tty(mut self) -> Self {
310            self.is_tty = false;
311            self
312        }
313    }
314
315    impl ColorEnvironment for MockColorEnvironment {
316        fn get_var(&self, name: &str) -> Option<String> {
317            self.vars.get(name).cloned()
318        }
319
320        fn is_terminal(&self) -> bool {
321            self.is_tty
322        }
323    }
324
325    #[test]
326    fn test_colors_disabled_struct() {
327        let c = Colors { enabled: false };
328        assert_eq!(c.bold(), "");
329        assert_eq!(c.red(), "");
330        assert_eq!(c.reset(), "");
331    }
332
333    #[test]
334    fn test_colors_enabled_struct() {
335        let c = Colors { enabled: true };
336        assert_eq!(c.bold(), "\x1b[1m");
337        assert_eq!(c.red(), "\x1b[31m");
338        assert_eq!(c.reset(), "\x1b[0m");
339    }
340
341    #[test]
342    fn test_box_chars() {
343        assert_eq!(BOX_TL, '╭');
344        assert_eq!(BOX_TR, '╮');
345        assert_eq!(BOX_H, '─');
346    }
347
348    #[test]
349    fn test_colors_enabled_respects_no_color() {
350        let env = MockColorEnvironment::new().with_var("NO_COLOR", "1");
351        assert!(!colors_enabled_with_env(&env));
352    }
353
354    #[test]
355    fn test_colors_enabled_respects_clicolor_force() {
356        let env = MockColorEnvironment::new()
357            .with_var("CLICOLOR_FORCE", "1")
358            .not_tty();
359        assert!(colors_enabled_with_env(&env));
360    }
361
362    #[test]
363    fn test_colors_enabled_respects_clicolor_zero() {
364        let env = MockColorEnvironment::new().with_var("CLICOLOR", "0");
365        assert!(!colors_enabled_with_env(&env));
366    }
367
368    #[test]
369    fn test_colors_enabled_respects_term_dumb() {
370        let env = MockColorEnvironment::new().with_var("TERM", "dumb");
371        assert!(!colors_enabled_with_env(&env));
372    }
373
374    #[test]
375    fn test_colors_enabled_no_color_takes_precedence() {
376        let env = MockColorEnvironment::new()
377            .with_var("NO_COLOR", "1")
378            .with_var("CLICOLOR_FORCE", "1");
379        assert!(!colors_enabled_with_env(&env));
380    }
381
382    #[test]
383    fn test_colors_enabled_term_dumb_case_insensitive() {
384        assert!(["dumb", "DUMB", "Dumb", "DuMb"].iter().all(|&term| {
385            let env = MockColorEnvironment::new().with_var("TERM", term);
386            !colors_enabled_with_env(&env)
387        }));
388    }
389
390    #[test]
391    fn test_colors_enabled_default_tty() {
392        let env = MockColorEnvironment::new();
393        assert!(colors_enabled_with_env(&env));
394    }
395
396    #[test]
397    fn test_colors_enabled_default_not_tty() {
398        let env = MockColorEnvironment::new().not_tty();
399        assert!(!colors_enabled_with_env(&env));
400    }
401}