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