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
93pub fn colors_enabled() -> bool {
94    colors_enabled_with_env(&RealColorEnvironment)
95}
96
97/// ANSI color codes
98#[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    /// **TEST-ONLY:** Create a Colors instance with explicit enabled/disabled state.
111    ///
112    /// This constructor is for test utilities that need explicit control over
113    /// color state, bypassing the automatic detection from `colors_enabled()`.
114    ///
115    /// # Availability
116    ///
117    /// **This method is ONLY available in test/test-utils builds** (`#[cfg(any(test, feature = "test-utils"))]`).
118    /// It is not part of the public API for library users in production builds.
119    /// The extensive documentation here is for maintainers reviewing test code.
120    ///
121    /// # Example
122    ///
123    /// ```
124    /// # // This doctest only runs when test-utils is enabled
125    /// # #[cfg(any(test, feature = "test-utils"))]
126    /// # {
127    /// use ralph_workflow::logger::Colors;
128    ///
129    /// // Force colors off for tests checking raw output
130    /// let colors = Colors::with_enabled(false);
131    /// assert_eq!(colors.bold(), ""); // No ANSI codes
132    ///
133    /// // Force colors on for tests checking colored output
134    /// let colors = Colors::with_enabled(true);
135    /// assert_eq!(colors.bold(), "\x1b[1m"); // ANSI bold code
136    /// # }
137    /// ```
138    #[cfg(any(test, feature = "test-utils"))]
139    pub const fn with_enabled(enabled: bool) -> Self {
140        Self { enabled }
141    }
142
143    pub const fn bold(self) -> &'static str {
144        if self.enabled {
145            "\x1b[1m"
146        } else {
147            ""
148        }
149    }
150
151    pub const fn dim(self) -> &'static str {
152        if self.enabled {
153            "\x1b[2m"
154        } else {
155            ""
156        }
157    }
158
159    pub const fn reset(self) -> &'static str {
160        if self.enabled {
161            "\x1b[0m"
162        } else {
163            ""
164        }
165    }
166
167    pub const fn red(self) -> &'static str {
168        if self.enabled {
169            "\x1b[31m"
170        } else {
171            ""
172        }
173    }
174
175    pub const fn green(self) -> &'static str {
176        if self.enabled {
177            "\x1b[32m"
178        } else {
179            ""
180        }
181    }
182
183    pub const fn yellow(self) -> &'static str {
184        if self.enabled {
185            "\x1b[33m"
186        } else {
187            ""
188        }
189    }
190
191    pub const fn blue(self) -> &'static str {
192        if self.enabled {
193            "\x1b[34m"
194        } else {
195            ""
196        }
197    }
198
199    pub const fn magenta(self) -> &'static str {
200        if self.enabled {
201            "\x1b[35m"
202        } else {
203            ""
204        }
205    }
206
207    pub const fn cyan(self) -> &'static str {
208        if self.enabled {
209            "\x1b[36m"
210        } else {
211            ""
212        }
213    }
214
215    pub const fn white(self) -> &'static str {
216        if self.enabled {
217            "\x1b[37m"
218        } else {
219            ""
220        }
221    }
222}
223
224impl Default for Colors {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230/// Box-drawing characters for visual structure
231pub const BOX_TL: char = '╭';
232pub const BOX_TR: char = '╮';
233pub const BOX_BL: char = '╰';
234pub const BOX_BR: char = '╯';
235pub const BOX_H: char = '─';
236pub const BOX_V: char = '│';
237
238/// Icons for output
239pub const ARROW: char = '→';
240pub const CHECK: char = '✓';
241pub const CROSS: char = '✗';
242pub const WARN: char = '⚠';
243pub const INFO: char = 'ℹ';
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::collections::HashMap;
249
250    /// Mock environment for testing color detection.
251    struct MockColorEnvironment {
252        vars: HashMap<String, String>,
253        is_tty: bool,
254    }
255
256    impl MockColorEnvironment {
257        fn new() -> Self {
258            Self {
259                vars: HashMap::new(),
260                is_tty: true,
261            }
262        }
263
264        fn with_var(mut self, name: &str, value: &str) -> Self {
265            self.vars.insert(name.to_string(), value.to_string());
266            self
267        }
268
269        fn not_tty(mut self) -> Self {
270            self.is_tty = false;
271            self
272        }
273    }
274
275    impl ColorEnvironment for MockColorEnvironment {
276        fn get_var(&self, name: &str) -> Option<String> {
277            self.vars.get(name).cloned()
278        }
279
280        fn is_terminal(&self) -> bool {
281            self.is_tty
282        }
283    }
284
285    #[test]
286    fn test_colors_disabled_struct() {
287        let c = Colors { enabled: false };
288        assert_eq!(c.bold(), "");
289        assert_eq!(c.red(), "");
290        assert_eq!(c.reset(), "");
291    }
292
293    #[test]
294    fn test_colors_enabled_struct() {
295        let c = Colors { enabled: true };
296        assert_eq!(c.bold(), "\x1b[1m");
297        assert_eq!(c.red(), "\x1b[31m");
298        assert_eq!(c.reset(), "\x1b[0m");
299    }
300
301    #[test]
302    fn test_box_chars() {
303        assert_eq!(BOX_TL, '╭');
304        assert_eq!(BOX_TR, '╮');
305        assert_eq!(BOX_H, '─');
306    }
307
308    #[test]
309    fn test_colors_enabled_respects_no_color() {
310        let env = MockColorEnvironment::new().with_var("NO_COLOR", "1");
311        assert!(!colors_enabled_with_env(&env));
312    }
313
314    #[test]
315    fn test_colors_enabled_respects_clicolor_force() {
316        let env = MockColorEnvironment::new()
317            .with_var("CLICOLOR_FORCE", "1")
318            .not_tty();
319        assert!(colors_enabled_with_env(&env));
320    }
321
322    #[test]
323    fn test_colors_enabled_respects_clicolor_zero() {
324        let env = MockColorEnvironment::new().with_var("CLICOLOR", "0");
325        assert!(!colors_enabled_with_env(&env));
326    }
327
328    #[test]
329    fn test_colors_enabled_respects_term_dumb() {
330        let env = MockColorEnvironment::new().with_var("TERM", "dumb");
331        assert!(!colors_enabled_with_env(&env));
332    }
333
334    #[test]
335    fn test_colors_enabled_no_color_takes_precedence() {
336        let env = MockColorEnvironment::new()
337            .with_var("NO_COLOR", "1")
338            .with_var("CLICOLOR_FORCE", "1");
339        assert!(!colors_enabled_with_env(&env));
340    }
341
342    #[test]
343    fn test_colors_enabled_term_dumb_case_insensitive() {
344        for term in ["dumb", "DUMB", "Dumb", "DuMb"] {
345            let env = MockColorEnvironment::new().with_var("TERM", term);
346            assert!(
347                !colors_enabled_with_env(&env),
348                "TERM={term} should disable colors"
349            );
350        }
351    }
352
353    #[test]
354    fn test_colors_enabled_default_tty() {
355        let env = MockColorEnvironment::new();
356        assert!(colors_enabled_with_env(&env));
357    }
358
359    #[test]
360    fn test_colors_enabled_default_not_tty() {
361        let env = MockColorEnvironment::new().not_tty();
362        assert!(!colors_enabled_with_env(&env));
363    }
364}