ralph_workflow/logger/
output.rs

1//! Core Logger implementation.
2//!
3//! Provides structured, colorized logging output for Ralph's pipeline
4//! with support for file logging and various log levels.
5
6use super::{
7    Colors, ARROW, BOX_BL, BOX_BR, BOX_H, BOX_TL, BOX_TR, BOX_V, CHECK, CROSS, INFO, WARN,
8};
9use crate::checkpoint::timestamp;
10use crate::common::truncate_text;
11use crate::config::Verbosity;
12use crate::json_parser::printer::Printable;
13use std::fs::{self, OpenOptions};
14use std::io::{IsTerminal, Write};
15use std::path::Path;
16
17/// Logger for Ralph output.
18///
19/// Provides consistent, colorized output with optional file logging.
20/// All messages include timestamps and appropriate icons.
21pub struct Logger {
22    colors: Colors,
23    log_file: Option<String>,
24}
25
26impl Logger {
27    /// Create a new Logger with the given colors configuration.
28    pub const fn new(colors: Colors) -> Self {
29        Self {
30            colors,
31            log_file: None,
32        }
33    }
34
35    /// Configure the logger to also write to a file.
36    ///
37    /// Log messages written to the file will have ANSI codes stripped.
38    pub fn with_log_file(mut self, path: &str) -> Self {
39        self.log_file = Some(path.to_string());
40        self
41    }
42
43    /// Write a message to the log file (if configured).
44    fn log_to_file(&self, msg: &str) {
45        if let Some(ref path) = self.log_file {
46            // Strip ANSI codes for file logging
47            let clean_msg = strip_ansi_codes(msg);
48            if let Some(parent) = Path::new(path).parent() {
49                let _ = fs::create_dir_all(parent);
50            }
51            if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
52                let _ = writeln!(file, "{clean_msg}");
53            }
54        }
55    }
56
57    /// Log an informational message.
58    pub fn info(&self, msg: &str) {
59        let c = &self.colors;
60        println!(
61            "{}[{}]{} {}{}{} {}",
62            c.dim(),
63            timestamp(),
64            c.reset(),
65            c.blue(),
66            INFO,
67            c.reset(),
68            msg
69        );
70        self.log_to_file(&format!("[{}] [INFO] {}", timestamp(), msg));
71    }
72
73    /// Log a success message.
74    pub fn success(&self, msg: &str) {
75        let c = &self.colors;
76        println!(
77            "{}[{}]{} {}{}{} {}{}{}",
78            c.dim(),
79            timestamp(),
80            c.reset(),
81            c.green(),
82            CHECK,
83            c.reset(),
84            c.green(),
85            msg,
86            c.reset()
87        );
88        self.log_to_file(&format!("[{}] [OK] {}", timestamp(), msg));
89    }
90
91    /// Log a warning message.
92    pub fn warn(&self, msg: &str) {
93        let c = &self.colors;
94        println!(
95            "{}[{}]{} {}{}{} {}{}{}",
96            c.dim(),
97            timestamp(),
98            c.reset(),
99            c.yellow(),
100            WARN,
101            c.reset(),
102            c.yellow(),
103            msg,
104            c.reset()
105        );
106        self.log_to_file(&format!("[{}] [WARN] {}", timestamp(), msg));
107    }
108
109    /// Log an error message.
110    pub fn error(&self, msg: &str) {
111        let c = &self.colors;
112        eprintln!(
113            "{}[{}]{} {}{}{} {}{}{}",
114            c.dim(),
115            timestamp(),
116            c.reset(),
117            c.red(),
118            CROSS,
119            c.reset(),
120            c.red(),
121            msg,
122            c.reset()
123        );
124        self.log_to_file(&format!("[{}] [ERROR] {}", timestamp(), msg));
125    }
126
127    /// Log a step/action message.
128    pub fn step(&self, msg: &str) {
129        let c = &self.colors;
130        println!(
131            "{}[{}]{} {}{}{} {}",
132            c.dim(),
133            timestamp(),
134            c.reset(),
135            c.magenta(),
136            ARROW,
137            c.reset(),
138            msg
139        );
140        self.log_to_file(&format!("[{}] [STEP] {}", timestamp(), msg));
141    }
142
143    /// Print a section header with box drawing.
144    ///
145    /// # Arguments
146    ///
147    /// * `title` - The header title text
148    /// * `color_fn` - Function that returns the color to use
149    pub fn header(&self, title: &str, color_fn: fn(Colors) -> &'static str) {
150        let c = self.colors;
151        let color = color_fn(c);
152        let width = 60;
153        let title_len = title.chars().count();
154        let padding = (width - title_len - 2) / 2;
155
156        println!();
157        println!(
158            "{}{}{}{}{}{}",
159            color,
160            c.bold(),
161            BOX_TL,
162            BOX_H.to_string().repeat(width),
163            BOX_TR,
164            c.reset()
165        );
166        println!(
167            "{}{}{}{}{}{}{}{}{}{}",
168            color,
169            c.bold(),
170            BOX_V,
171            " ".repeat(padding),
172            c.white(),
173            title,
174            color,
175            " ".repeat(width - padding - title_len),
176            BOX_V,
177            c.reset()
178        );
179        println!(
180            "{}{}{}{}{}{}",
181            color,
182            c.bold(),
183            BOX_BL,
184            BOX_H.to_string().repeat(width),
185            BOX_BR,
186            c.reset()
187        );
188    }
189
190    /// Print a sub-header (less prominent than header).
191    pub fn subheader(&self, title: &str) {
192        let c = &self.colors;
193        println!();
194        println!("{}{}{} {}{}", c.bold(), c.blue(), ARROW, title, c.reset());
195        println!("{}{}──{}", c.dim(), "─".repeat(title.len()), c.reset());
196    }
197}
198
199impl Default for Logger {
200    fn default() -> Self {
201        Self::new(Colors::new())
202    }
203}
204
205// ===== Printable and Write Implementations =====
206
207impl std::io::Write for Logger {
208    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
209        // Write directly to stdout
210        std::io::stdout().write(buf)
211    }
212
213    fn flush(&mut self) -> std::io::Result<()> {
214        std::io::stdout().flush()
215    }
216}
217
218impl Printable for Logger {
219    fn is_terminal(&self) -> bool {
220        std::io::stdout().is_terminal()
221    }
222}
223
224/// Strip ANSI escape sequences from a string.
225///
226/// Used when writing to log files where ANSI codes are not supported.
227pub fn strip_ansi_codes(s: &str) -> String {
228    static ANSI_RE: std::sync::LazyLock<Result<regex::Regex, regex::Error>> =
229        std::sync::LazyLock::new(|| regex::Regex::new(r"\x1b\[[0-9;]*m"));
230    (*ANSI_RE)
231        .as_ref()
232        .map_or_else(|_| s.to_string(), |re| re.replace_all(s, "").to_string())
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_strip_ansi_codes() {
241        let input = "\x1b[31mred\x1b[0m text";
242        assert_eq!(strip_ansi_codes(input), "red text");
243    }
244
245    #[test]
246    fn test_strip_ansi_codes_no_codes() {
247        let input = "plain text";
248        assert_eq!(strip_ansi_codes(input), "plain text");
249    }
250
251    #[test]
252    fn test_strip_ansi_codes_multiple() {
253        let input = "\x1b[1m\x1b[32mbold green\x1b[0m \x1b[34mblue\x1b[0m";
254        assert_eq!(strip_ansi_codes(input), "bold green blue");
255    }
256}
257
258// ===== Output Formatting Functions =====
259
260/// Detect if command-line arguments request JSON output.
261///
262/// Scans the provided argv for common JSON output flags used by various CLIs:
263/// - `--json` or `--json=...`
264/// - `--output-format` with json value
265/// - `--format json`
266/// - `-F json`
267/// - `-o stream-json` or similar
268pub fn argv_requests_json(argv: &[String]) -> bool {
269    // Skip argv[0] (the executable); scan flags/args only.
270    let mut iter = argv.iter().skip(1).peekable();
271    while let Some(arg) = iter.next() {
272        if arg == "--json" || arg.starts_with("--json=") {
273            return true;
274        }
275
276        if arg == "--output-format" {
277            if let Some(next) = iter.peek() {
278                let next = next.as_str();
279                if next.contains("json") {
280                    return true;
281                }
282            }
283        }
284        if let Some((flag, value)) = arg.split_once('=') {
285            if flag == "--output-format" && value.contains("json") {
286                return true;
287            }
288            if flag == "--format" && value == "json" {
289                return true;
290            }
291        }
292
293        if arg == "--format" {
294            if let Some(next) = iter.peek() {
295                if next.as_str() == "json" {
296                    return true;
297                }
298            }
299        }
300
301        // Some CLIs use short flags like -F json or -o stream-json
302        if arg == "-F" {
303            if let Some(next) = iter.peek() {
304                if next.as_str() == "json" {
305                    return true;
306                }
307            }
308        }
309        if arg.starts_with("-F") && arg != "-F" && arg.trim_start_matches("-F") == "json" {
310            return true;
311        }
312
313        if arg == "-o" {
314            if let Some(next) = iter.peek() {
315                let next = next.as_str();
316                if next.contains("json") {
317                    return true;
318                }
319            }
320        }
321        if arg.starts_with("-o") && arg != "-o" && arg.trim_start_matches("-o").contains("json") {
322            return true;
323        }
324    }
325    false
326}
327
328/// Format generic JSON output for display.
329///
330/// Parses the input as JSON and formats it according to verbosity level:
331/// - `Full` or `Debug`: Pretty-print with indentation
332/// - Other levels: Compact single-line format
333///
334/// Output is truncated according to verbosity limits.
335pub fn format_generic_json_for_display(line: &str, verbosity: Verbosity) -> String {
336    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
337        return truncate_text(line, verbosity.truncate_limit("agent_msg"));
338    };
339
340    let formatted = match verbosity {
341        Verbosity::Full | Verbosity::Debug => {
342            serde_json::to_string_pretty(&value).unwrap_or_else(|_| line.to_string())
343        }
344        _ => serde_json::to_string(&value).unwrap_or_else(|_| line.to_string()),
345    };
346    truncate_text(&formatted, verbosity.truncate_limit("agent_msg"))
347}
348
349#[cfg(test)]
350mod output_formatting_tests {
351    use super::*;
352
353    #[test]
354    fn test_argv_requests_json_detects_common_flags() {
355        assert!(argv_requests_json(&[
356            "tool".to_string(),
357            "--json".to_string()
358        ]));
359        assert!(argv_requests_json(&[
360            "tool".to_string(),
361            "--output-format=stream-json".to_string()
362        ]));
363        assert!(argv_requests_json(&[
364            "tool".to_string(),
365            "--output-format".to_string(),
366            "stream-json".to_string()
367        ]));
368        assert!(argv_requests_json(&[
369            "tool".to_string(),
370            "--format".to_string(),
371            "json".to_string()
372        ]));
373        assert!(argv_requests_json(&[
374            "tool".to_string(),
375            "-F".to_string(),
376            "json".to_string()
377        ]));
378        assert!(argv_requests_json(&[
379            "tool".to_string(),
380            "-o".to_string(),
381            "stream-json".to_string()
382        ]));
383    }
384
385    #[test]
386    fn test_format_generic_json_for_display_pretty_prints_when_full() {
387        let line = r#"{"type":"message","content":{"text":"hello"}}"#;
388        let formatted = format_generic_json_for_display(line, Verbosity::Full);
389        assert!(formatted.contains('\n'));
390        assert!(formatted.contains("\"type\""));
391        assert!(formatted.contains("\"message\""));
392    }
393}