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    pub const fn bold(self) -> &'static str {
111        if self.enabled {
112            "\x1b[1m"
113        } else {
114            ""
115        }
116    }
117
118    pub const fn dim(self) -> &'static str {
119        if self.enabled {
120            "\x1b[2m"
121        } else {
122            ""
123        }
124    }
125
126    pub const fn reset(self) -> &'static str {
127        if self.enabled {
128            "\x1b[0m"
129        } else {
130            ""
131        }
132    }
133
134    pub const fn red(self) -> &'static str {
135        if self.enabled {
136            "\x1b[31m"
137        } else {
138            ""
139        }
140    }
141
142    pub const fn green(self) -> &'static str {
143        if self.enabled {
144            "\x1b[32m"
145        } else {
146            ""
147        }
148    }
149
150    pub const fn yellow(self) -> &'static str {
151        if self.enabled {
152            "\x1b[33m"
153        } else {
154            ""
155        }
156    }
157
158    pub const fn blue(self) -> &'static str {
159        if self.enabled {
160            "\x1b[34m"
161        } else {
162            ""
163        }
164    }
165
166    pub const fn magenta(self) -> &'static str {
167        if self.enabled {
168            "\x1b[35m"
169        } else {
170            ""
171        }
172    }
173
174    pub const fn cyan(self) -> &'static str {
175        if self.enabled {
176            "\x1b[36m"
177        } else {
178            ""
179        }
180    }
181
182    pub const fn white(self) -> &'static str {
183        if self.enabled {
184            "\x1b[37m"
185        } else {
186            ""
187        }
188    }
189}
190
191impl Default for Colors {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197/// Box-drawing characters for visual structure
198pub const BOX_TL: char = '╭';
199pub const BOX_TR: char = '╮';
200pub const BOX_BL: char = '╰';
201pub const BOX_BR: char = '╯';
202pub const BOX_H: char = '─';
203pub const BOX_V: char = '│';
204
205/// Icons for output
206pub const ARROW: char = '→';
207pub const CHECK: char = '✓';
208pub const CROSS: char = '✗';
209pub const WARN: char = '⚠';
210pub const INFO: char = 'ℹ';
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::collections::HashMap;
216
217    /// Mock environment for testing color detection.
218    struct MockColorEnvironment {
219        vars: HashMap<String, String>,
220        is_tty: bool,
221    }
222
223    impl MockColorEnvironment {
224        fn new() -> Self {
225            Self {
226                vars: HashMap::new(),
227                is_tty: true,
228            }
229        }
230
231        fn with_var(mut self, name: &str, value: &str) -> Self {
232            self.vars.insert(name.to_string(), value.to_string());
233            self
234        }
235
236        fn not_tty(mut self) -> Self {
237            self.is_tty = false;
238            self
239        }
240    }
241
242    impl ColorEnvironment for MockColorEnvironment {
243        fn get_var(&self, name: &str) -> Option<String> {
244            self.vars.get(name).cloned()
245        }
246
247        fn is_terminal(&self) -> bool {
248            self.is_tty
249        }
250    }
251
252    #[test]
253    fn test_colors_disabled_struct() {
254        let c = Colors { enabled: false };
255        assert_eq!(c.bold(), "");
256        assert_eq!(c.red(), "");
257        assert_eq!(c.reset(), "");
258    }
259
260    #[test]
261    fn test_colors_enabled_struct() {
262        let c = Colors { enabled: true };
263        assert_eq!(c.bold(), "\x1b[1m");
264        assert_eq!(c.red(), "\x1b[31m");
265        assert_eq!(c.reset(), "\x1b[0m");
266    }
267
268    #[test]
269    fn test_box_chars() {
270        assert_eq!(BOX_TL, '╭');
271        assert_eq!(BOX_TR, '╮');
272        assert_eq!(BOX_H, '─');
273    }
274
275    #[test]
276    fn test_colors_enabled_respects_no_color() {
277        let env = MockColorEnvironment::new().with_var("NO_COLOR", "1");
278        assert!(!colors_enabled_with_env(&env));
279    }
280
281    #[test]
282    fn test_colors_enabled_respects_clicolor_force() {
283        let env = MockColorEnvironment::new()
284            .with_var("CLICOLOR_FORCE", "1")
285            .not_tty();
286        assert!(colors_enabled_with_env(&env));
287    }
288
289    #[test]
290    fn test_colors_enabled_respects_clicolor_zero() {
291        let env = MockColorEnvironment::new().with_var("CLICOLOR", "0");
292        assert!(!colors_enabled_with_env(&env));
293    }
294
295    #[test]
296    fn test_colors_enabled_respects_term_dumb() {
297        let env = MockColorEnvironment::new().with_var("TERM", "dumb");
298        assert!(!colors_enabled_with_env(&env));
299    }
300
301    #[test]
302    fn test_colors_enabled_no_color_takes_precedence() {
303        let env = MockColorEnvironment::new()
304            .with_var("NO_COLOR", "1")
305            .with_var("CLICOLOR_FORCE", "1");
306        assert!(!colors_enabled_with_env(&env));
307    }
308
309    #[test]
310    fn test_colors_enabled_term_dumb_case_insensitive() {
311        for term in ["dumb", "DUMB", "Dumb", "DuMb"] {
312            let env = MockColorEnvironment::new().with_var("TERM", term);
313            assert!(
314                !colors_enabled_with_env(&env),
315                "TERM={term} should disable colors"
316            );
317        }
318    }
319
320    #[test]
321    fn test_colors_enabled_default_tty() {
322        let env = MockColorEnvironment::new();
323        assert!(colors_enabled_with_env(&env));
324    }
325
326    #[test]
327    fn test_colors_enabled_default_not_tty() {
328        let env = MockColorEnvironment::new().not_tty();
329        assert!(!colors_enabled_with_env(&env));
330    }
331}