Skip to main content

ralph_workflow/logger/
logger_impl.rs

1//! Logger struct and its implementations.
2//!
3//! This file contains the Logger struct and all its impl blocks,
4//! including the Loggable trait implementation.
5
6use super::loggable::Loggable;
7use crate::checkpoint::timestamp;
8use crate::json_parser::printer::Printable;
9use crate::logger::{
10    Colors, ARROW, BOX_BL, BOX_BR, BOX_H, BOX_TL, BOX_TR, BOX_V, CHECK, CROSS, INFO, WARN,
11};
12use crate::workspace::Workspace;
13use std::fs::{self, OpenOptions};
14use std::io::{IsTerminal, Write};
15use std::path::Path;
16use std::sync::Arc;
17
18use crate::logger::output::strip_ansi_codes;
19
20/// Logger for Ralph output.
21///
22/// Provides consistent, colorized output with optional file logging.
23/// All messages include timestamps and appropriate icons.
24pub struct Logger {
25    colors: Colors,
26    /// Path for direct filesystem logging (CLI layer before workspace available).
27    log_file: Option<String>,
28    /// Workspace for abstracted file logging (preferred when workspace is available).
29    workspace: Option<Arc<dyn Workspace>>,
30    /// Relative path within workspace for log file.
31    workspace_log_path: Option<String>,
32}
33
34impl Logger {
35    /// Create a new Logger with the given colors configuration.
36    #[must_use]
37    pub const fn new(colors: Colors) -> Self {
38        Self {
39            colors,
40            log_file: None,
41            workspace: None,
42            workspace_log_path: None,
43        }
44    }
45
46    /// Configure the logger to also write to a file using direct filesystem access.
47    ///
48    /// Log messages written to the file will have ANSI codes stripped.
49    ///
50    /// # Note
51    ///
52    /// For pipeline code where a workspace exists, prefer `with_workspace_log`
53    /// instead. This method uses `std::fs` directly and is intended for CLI layer
54    /// code or legacy compatibility.
55    #[must_use]
56    pub fn with_log_file(mut self, path: &str) -> Self {
57        self.log_file = Some(path.to_string());
58        self
59    }
60
61    /// Configure the logger to write logs via a workspace.
62    ///
63    /// This is the preferred method for pipeline code where a workspace exists.
64    /// Log messages will be written using the workspace abstraction, allowing
65    /// for testing with `MemoryWorkspace`.
66    ///
67    /// # Arguments
68    ///
69    /// * `workspace` - The workspace to use for file operations
70    /// * `relative_path` - Path relative to workspace root for the log file
71    #[must_use]
72    pub fn with_workspace_log(
73        mut self,
74        workspace: Arc<dyn Workspace>,
75        relative_path: &str,
76    ) -> Self {
77        self.workspace = Some(workspace);
78        self.workspace_log_path = Some(relative_path.to_string());
79        self
80    }
81
82    /// Write a message to the log file (if configured).
83    fn log_to_file(&self, msg: &str) {
84        // Strip ANSI codes for file logging
85        let clean_msg = strip_ansi_codes(msg);
86
87        // Try workspace-based logging first
88        if let (Some(workspace), Some(ref path)) = (&self.workspace, &self.workspace_log_path) {
89            let path = std::path::Path::new(path);
90            // Create parent directories if needed
91            if let Some(parent) = path.parent() {
92                let _ = workspace.create_dir_all(parent);
93            }
94            // Append to the log file
95            let _ = workspace.append_bytes(path, format!("{clean_msg}\n").as_bytes());
96            return;
97        }
98
99        // Fall back to direct filesystem logging (CLI layer before workspace available)
100        if let Some(ref path) = self.log_file {
101            if let Some(parent) = Path::new(path).parent() {
102                let _ = fs::create_dir_all(parent);
103            }
104            if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
105                let _ = writeln!(file, "{clean_msg}");
106                let _ = file.flush();
107                // Ensure data is written to disk before continuing
108                let _ = file.sync_all();
109            }
110        }
111    }
112
113    /// Log an informational message.
114    pub fn info(&self, msg: &str) {
115        let c = &self.colors;
116        println!(
117            "{}[{}]{} {}{}{} {}",
118            c.dim(),
119            timestamp(),
120            c.reset(),
121            c.blue(),
122            INFO,
123            c.reset(),
124            msg
125        );
126        self.log_to_file(&format!("[{}] [INFO] {}", timestamp(), msg));
127    }
128
129    /// Log a success message.
130    pub fn success(&self, msg: &str) {
131        let c = &self.colors;
132        println!(
133            "{}[{}]{} {}{}{} {}{}{}",
134            c.dim(),
135            timestamp(),
136            c.reset(),
137            c.green(),
138            CHECK,
139            c.reset(),
140            c.green(),
141            msg,
142            c.reset()
143        );
144        self.log_to_file(&format!("[{}] [OK] {}", timestamp(), msg));
145    }
146
147    /// Log a warning message.
148    pub fn warn(&self, msg: &str) {
149        let c = &self.colors;
150        println!(
151            "{}[{}]{} {}{}{} {}{}{}",
152            c.dim(),
153            timestamp(),
154            c.reset(),
155            c.yellow(),
156            WARN,
157            c.reset(),
158            c.yellow(),
159            msg,
160            c.reset()
161        );
162        self.log_to_file(&format!("[{}] [WARN] {}", timestamp(), msg));
163    }
164
165    /// Log an error message.
166    pub fn error(&self, msg: &str) {
167        let c = &self.colors;
168        eprintln!(
169            "{}[{}]{} {}{}{} {}{}{}",
170            c.dim(),
171            timestamp(),
172            c.reset(),
173            c.red(),
174            CROSS,
175            c.reset(),
176            c.red(),
177            msg,
178            c.reset()
179        );
180        self.log_to_file(&format!("[{}] [ERROR] {}", timestamp(), msg));
181    }
182
183    /// Log a step/action message.
184    pub fn step(&self, msg: &str) {
185        let c = &self.colors;
186        println!(
187            "{}[{}]{} {}{}{} {}",
188            c.dim(),
189            timestamp(),
190            c.reset(),
191            c.magenta(),
192            ARROW,
193            c.reset(),
194            msg
195        );
196        self.log_to_file(&format!("[{}] [STEP] {}", timestamp(), msg));
197    }
198
199    /// Print a section header with box drawing.
200    ///
201    /// # Arguments
202    ///
203    /// * `title` - The header title text
204    /// * `color_fn` - Function that returns the color to use
205    pub fn header(&self, title: &str, color_fn: fn(Colors) -> &'static str) {
206        let c = self.colors;
207        let color = color_fn(c);
208        let width = 60;
209        let title_len = title.chars().count();
210        let padding = (width - title_len - 2) / 2;
211
212        println!();
213        println!(
214            "{}{}{}{}{}{}",
215            color,
216            c.bold(),
217            BOX_TL,
218            BOX_H.to_string().repeat(width),
219            BOX_TR,
220            c.reset()
221        );
222        println!(
223            "{}{}{}{}{}{}{}{}{}{}",
224            color,
225            c.bold(),
226            BOX_V,
227            " ".repeat(padding),
228            c.white(),
229            title,
230            color,
231            " ".repeat(width - padding - title_len),
232            BOX_V,
233            c.reset()
234        );
235        println!(
236            "{}{}{}{}{}{}",
237            color,
238            c.bold(),
239            BOX_BL,
240            BOX_H.to_string().repeat(width),
241            BOX_BR,
242            c.reset()
243        );
244    }
245
246    /// Print a sub-header (less prominent than header).
247    pub fn subheader(&self, title: &str) {
248        let c = &self.colors;
249        println!();
250        println!("{}{}{} {}{}", c.bold(), c.blue(), ARROW, title, c.reset());
251        println!("{}{}──{}", c.dim(), "─".repeat(title.len()), c.reset());
252    }
253}
254
255impl Default for Logger {
256    fn default() -> Self {
257        Self::new(Colors::new())
258    }
259}
260
261// ===== Loggable Implementation =====
262
263impl Loggable for Logger {
264    fn log(&self, msg: &str) {
265        self.log_to_file(msg);
266    }
267
268    fn info(&self, msg: &str) {
269        let c = &self.colors;
270        println!(
271            "{}[{}]{} {}{}{} {}",
272            c.dim(),
273            timestamp(),
274            c.reset(),
275            c.blue(),
276            INFO,
277            c.reset(),
278            msg
279        );
280        self.log(&format!("[{}] [INFO] {msg}", timestamp()));
281    }
282
283    fn success(&self, msg: &str) {
284        let c = &self.colors;
285        println!(
286            "{}[{}]{} {}{}{} {}{}{}",
287            c.dim(),
288            timestamp(),
289            c.reset(),
290            c.green(),
291            CHECK,
292            c.reset(),
293            c.green(),
294            msg,
295            c.reset()
296        );
297        self.log(&format!("[{}] [OK] {msg}", timestamp()));
298    }
299
300    fn warn(&self, msg: &str) {
301        let c = &self.colors;
302        println!(
303            "{}[{}]{} {}{}{} {}{}{}",
304            c.dim(),
305            timestamp(),
306            c.reset(),
307            c.yellow(),
308            WARN,
309            c.reset(),
310            c.yellow(),
311            msg,
312            c.reset()
313        );
314        self.log(&format!("[{}] [WARN] {msg}", timestamp()));
315    }
316
317    fn error(&self, msg: &str) {
318        let c = &self.colors;
319        eprintln!(
320            "{}[{}]{} {}{}{} {}{}{}",
321            c.dim(),
322            timestamp(),
323            c.reset(),
324            c.red(),
325            CROSS,
326            c.reset(),
327            c.red(),
328            msg,
329            c.reset()
330        );
331        self.log(&format!("[{}] [ERROR] {msg}", timestamp()));
332    }
333
334    fn header(&self, title: &str, color_fn: fn(Colors) -> &'static str) {
335        // Call the inherent impl's header method
336        // We need to duplicate the implementation here since calling the inherent
337        // method from a trait impl causes issues with method resolution
338        let c = self.colors;
339        let color = color_fn(c);
340        let width = 60;
341        let title_len = title.chars().count();
342        let padding = (width - title_len - 2) / 2;
343
344        println!();
345        println!(
346            "{}{}{}{}{}{}",
347            color,
348            c.bold(),
349            BOX_TL,
350            BOX_H.to_string().repeat(width),
351            BOX_TR,
352            c.reset()
353        );
354        println!(
355            "{}{}{}{}{}{}{}{}{}{}",
356            color,
357            c.bold(),
358            BOX_V,
359            " ".repeat(padding),
360            c.white(),
361            title,
362            color,
363            " ".repeat(width - padding - title_len),
364            BOX_V,
365            c.reset()
366        );
367        println!(
368            "{}{}{}{}{}{}",
369            color,
370            c.bold(),
371            BOX_BL,
372            BOX_H.to_string().repeat(width),
373            BOX_BR,
374            c.reset()
375        );
376    }
377}
378
379// ===== Printable and Write Implementations =====
380
381impl std::io::Write for Logger {
382    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
383        // Write directly to stdout
384        std::io::stdout().write(buf)
385    }
386
387    fn flush(&mut self) -> std::io::Result<()> {
388        std::io::stdout().flush()
389    }
390}
391
392impl Printable for Logger {
393    fn is_terminal(&self) -> bool {
394        std::io::stdout().is_terminal()
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    // =========================================================================
401    // Workspace-based logger tests
402    // =========================================================================
403
404    #[cfg(feature = "test-utils")]
405    mod workspace_tests {
406        use super::super::*;
407        use crate::workspace::MemoryWorkspace;
408
409        #[test]
410        fn test_logger_with_workspace_writes_to_file() {
411            let workspace = Arc::new(MemoryWorkspace::new_test());
412            let logger = Logger::new(Colors::new())
413                .with_workspace_log(workspace.clone(), ".agent/logs/test.log");
414
415            // Use the Loggable trait to log a message
416            Loggable::info(&logger, "test message");
417
418            // Verify the message was written to the workspace
419            let content = workspace.get_file(".agent/logs/test.log").unwrap();
420            assert!(content.contains("test message"));
421            assert!(content.contains("[INFO]"));
422        }
423
424        #[test]
425        fn test_logger_with_workspace_strips_ansi_codes() {
426            let workspace = Arc::new(MemoryWorkspace::new_test());
427            let logger = Logger::new(Colors::new())
428                .with_workspace_log(workspace.clone(), ".agent/logs/test.log");
429
430            // Log via the internal method that includes ANSI codes
431            logger.log("[INFO] \x1b[31mcolored\x1b[0m message");
432
433            let content = workspace.get_file(".agent/logs/test.log").unwrap();
434            assert!(content.contains("colored message"));
435            assert!(!content.contains("\x1b["));
436        }
437
438        #[test]
439        fn test_logger_with_workspace_creates_parent_dirs() {
440            let workspace = Arc::new(MemoryWorkspace::new_test());
441            let logger = Logger::new(Colors::new())
442                .with_workspace_log(workspace.clone(), ".agent/logs/nested/deep/test.log");
443
444            Loggable::info(&logger, "nested log");
445
446            // Should have created parent directories
447            assert!(workspace.exists(std::path::Path::new(".agent/logs/nested/deep")));
448            let content = workspace
449                .get_file(".agent/logs/nested/deep/test.log")
450                .unwrap();
451            assert!(content.contains("nested log"));
452        }
453    }
454}