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