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