Skip to main content

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//!
6//! # Logger Design and Behavior
7//!
8//! The `Logger` struct provides dual-output logging:
9//! - **Console output**: Colorized, human-readable messages to stdout/stderr
10//! - **File output**: Plain text (ANSI codes stripped) with timestamps
11//!
12//! ## How Logger Writes to Files
13//!
14//! Logger's file logging is **not** done through the `std::io::Write` trait.
15//! Instead, file logging happens via the `Loggable` trait's `log()` method, which
16//! is called by each log level method (`info()`, `success()`, `warn()`, `error()`).
17//!
18//! ### Important: `Write` Trait Behavior
19//!
20//! Logger implements `std::io::Write`, but the `write()` method **only writes to
21//! stdout**, NOT to the log file. This is intentional design:
22//!
23//! ```ignore
24//! let logger = Logger::new(Colors::new()).with_log_file("app.log");
25//!
26//! // This writes to BOTH console AND file:
27//! logger.info("This message goes everywhere");
28//!
29//! // This writes ONLY to console (via Write trait):
30//! writeln!(logger, "This only goes to console").unwrap();
31//! ```
32//!
33//! If you need file output, always use the Logger's methods (`info()`, `success()`,
34//! etc.) rather than the `Write` trait. The `Write` trait implementation exists
35//! for compatibility with code that expects a writer, but it's a console-only path.
36//!
37//! ## Using the `Loggable` Trait
38//!
39//! The `Loggable` trait provides a unified interface for logging that works with
40//! both `Logger` (production) and `TestLogger` (testing):
41//!
42//! ```ignore
43//! use ralph_workflow::logger::Loggable;
44//!
45//! fn process_logs<L: Loggable>(logger: &L) {
46//!     logger.info("Starting process");
47//!     logger.success("Process completed");
48//!     logger.warn("Potential issue");
49//!     logger.error("Critical error");
50//! }
51//! ```
52//!
53//! ## Logger → File → Extraction Flow
54//!
55//! The review and planning phases extract structured output from agent logs:
56//!
57//! 1. **Agent writes JSON events**: Agents emit `{"type": "result", "result": "..."}` events
58//! 2. **Events written to log files**: Via direct file writes or Logger's file logging
59//! 3. **Extraction reads log files**: `extract_last_result()` parses JSON from log files
60//! 4. **Result content captured**: The orchestrator uses extracted content for ISSUES.md/PLAN.md
61//!
62//! ### Last Line Handling
63//!
64//! A key concern is whether the last line without a trailing newline is extracted.
65//! The extraction uses `BufReader::lines()`, which **does** return the last line
66//! even without a trailing newline (this is documented Rust stdlib behavior).
67//!
68//! Reference: <https://doc.rust-lang.org/std/io/struct.BufReader.html#method.lines>
69//!
70//! ### Testing Logger Output
71//!
72//! For testing, use `TestLogger` from this module:
73//!
74//! ```ignore
75//! use ralph_workflow::logger::output::TestLogger;
76//! use ralph_workflow::logger::Loggable;
77//!
78//! let logger = TestLogger::new();
79//! logger.info("Test message");
80//!
81//! assert!(logger.has_log("Test message"));
82//! assert!(logger.has_log("[INFO]"));
83//! ```
84//!
85//! `TestLogger` follows the same pattern as `TestPrinter` with line buffering
86//! and implements the same traits (`Printable`, `std::io::Write`, `Loggable`).
87//! Note: `TestLogger` is available in test builds and when the `test-utils`
88//! feature is enabled (for integration tests).
89
90use super::{
91    Colors, ARROW, BOX_BL, BOX_BR, BOX_H, BOX_TL, BOX_TR, BOX_V, CHECK, CROSS, INFO, WARN,
92};
93use crate::checkpoint::timestamp;
94use crate::common::truncate_text;
95use crate::config::Verbosity;
96use crate::json_parser::printer::Printable;
97use crate::workspace::Workspace;
98use std::fs::{self, OpenOptions};
99use std::io::{IsTerminal, Write};
100use std::path::Path;
101use std::sync::Arc;
102
103#[cfg(any(test, feature = "test-utils"))]
104use std::cell::RefCell;
105
106/// Logger for Ralph output.
107///
108/// Provides consistent, colorized output with optional file logging.
109/// All messages include timestamps and appropriate icons.
110pub struct Logger {
111    colors: Colors,
112    /// Path for direct filesystem logging (legacy mode)
113    log_file: Option<String>,
114    /// Workspace for abstracted file logging
115    workspace: Option<Arc<dyn Workspace>>,
116    /// Relative path within workspace for log file
117    workspace_log_path: Option<String>,
118}
119
120impl Logger {
121    /// Create a new Logger with the given colors configuration.
122    pub const fn new(colors: Colors) -> Self {
123        Self {
124            colors,
125            log_file: None,
126            workspace: None,
127            workspace_log_path: None,
128        }
129    }
130
131    /// Configure the logger to also write to a file using direct filesystem access.
132    ///
133    /// Log messages written to the file will have ANSI codes stripped.
134    ///
135    /// # Note
136    ///
137    /// For pipeline code where a workspace exists, prefer [`with_workspace_log`]
138    /// instead. This method uses `std::fs` directly and is intended for CLI layer
139    /// code or legacy compatibility.
140    pub fn with_log_file(mut self, path: &str) -> Self {
141        self.log_file = Some(path.to_string());
142        self
143    }
144
145    /// Configure the logger to write logs via a workspace.
146    ///
147    /// This is the preferred method for pipeline code where a workspace exists.
148    /// Log messages will be written using the workspace abstraction, allowing
149    /// for testing with `MemoryWorkspace`.
150    ///
151    /// # Arguments
152    ///
153    /// * `workspace` - The workspace to use for file operations
154    /// * `relative_path` - Path relative to workspace root for the log file
155    pub fn with_workspace_log(
156        mut self,
157        workspace: Arc<dyn Workspace>,
158        relative_path: &str,
159    ) -> Self {
160        self.workspace = Some(workspace);
161        self.workspace_log_path = Some(relative_path.to_string());
162        self
163    }
164
165    /// Write a message to the log file (if configured).
166    fn log_to_file(&self, msg: &str) {
167        // Strip ANSI codes for file logging
168        let clean_msg = strip_ansi_codes(msg);
169
170        // Try workspace-based logging first
171        if let (Some(workspace), Some(ref path)) = (&self.workspace, &self.workspace_log_path) {
172            let path = std::path::Path::new(path);
173            // Create parent directories if needed
174            if let Some(parent) = path.parent() {
175                let _ = workspace.create_dir_all(parent);
176            }
177            // Append to the log file
178            let _ = workspace.append_bytes(path, format!("{clean_msg}\n").as_bytes());
179            return;
180        }
181
182        // Fall back to direct filesystem logging (legacy mode)
183        if let Some(ref path) = self.log_file {
184            if let Some(parent) = Path::new(path).parent() {
185                let _ = fs::create_dir_all(parent);
186            }
187            if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
188                let _ = writeln!(file, "{clean_msg}");
189                let _ = file.flush();
190                // Ensure data is written to disk before continuing
191                let _ = file.sync_all();
192            }
193        }
194    }
195
196    /// Log an informational message.
197    pub fn info(&self, msg: &str) {
198        let c = &self.colors;
199        println!(
200            "{}[{}]{} {}{}{} {}",
201            c.dim(),
202            timestamp(),
203            c.reset(),
204            c.blue(),
205            INFO,
206            c.reset(),
207            msg
208        );
209        self.log_to_file(&format!("[{}] [INFO] {}", timestamp(), msg));
210    }
211
212    /// Log a success message.
213    pub fn success(&self, msg: &str) {
214        let c = &self.colors;
215        println!(
216            "{}[{}]{} {}{}{} {}{}{}",
217            c.dim(),
218            timestamp(),
219            c.reset(),
220            c.green(),
221            CHECK,
222            c.reset(),
223            c.green(),
224            msg,
225            c.reset()
226        );
227        self.log_to_file(&format!("[{}] [OK] {}", timestamp(), msg));
228    }
229
230    /// Log a warning message.
231    pub fn warn(&self, msg: &str) {
232        let c = &self.colors;
233        println!(
234            "{}[{}]{} {}{}{} {}{}{}",
235            c.dim(),
236            timestamp(),
237            c.reset(),
238            c.yellow(),
239            WARN,
240            c.reset(),
241            c.yellow(),
242            msg,
243            c.reset()
244        );
245        self.log_to_file(&format!("[{}] [WARN] {}", timestamp(), msg));
246    }
247
248    /// Log an error message.
249    pub fn error(&self, msg: &str) {
250        let c = &self.colors;
251        eprintln!(
252            "{}[{}]{} {}{}{} {}{}{}",
253            c.dim(),
254            timestamp(),
255            c.reset(),
256            c.red(),
257            CROSS,
258            c.reset(),
259            c.red(),
260            msg,
261            c.reset()
262        );
263        self.log_to_file(&format!("[{}] [ERROR] {}", timestamp(), msg));
264    }
265
266    /// Log a step/action message.
267    pub fn step(&self, msg: &str) {
268        let c = &self.colors;
269        println!(
270            "{}[{}]{} {}{}{} {}",
271            c.dim(),
272            timestamp(),
273            c.reset(),
274            c.magenta(),
275            ARROW,
276            c.reset(),
277            msg
278        );
279        self.log_to_file(&format!("[{}] [STEP] {}", timestamp(), msg));
280    }
281
282    /// Print a section header with box drawing.
283    ///
284    /// # Arguments
285    ///
286    /// * `title` - The header title text
287    /// * `color_fn` - Function that returns the color to use
288    pub fn header(&self, title: &str, color_fn: fn(Colors) -> &'static str) {
289        let c = self.colors;
290        let color = color_fn(c);
291        let width = 60;
292        let title_len = title.chars().count();
293        let padding = (width - title_len - 2) / 2;
294
295        println!();
296        println!(
297            "{}{}{}{}{}{}",
298            color,
299            c.bold(),
300            BOX_TL,
301            BOX_H.to_string().repeat(width),
302            BOX_TR,
303            c.reset()
304        );
305        println!(
306            "{}{}{}{}{}{}{}{}{}{}",
307            color,
308            c.bold(),
309            BOX_V,
310            " ".repeat(padding),
311            c.white(),
312            title,
313            color,
314            " ".repeat(width - padding - title_len),
315            BOX_V,
316            c.reset()
317        );
318        println!(
319            "{}{}{}{}{}{}",
320            color,
321            c.bold(),
322            BOX_BL,
323            BOX_H.to_string().repeat(width),
324            BOX_BR,
325            c.reset()
326        );
327    }
328
329    /// Print a sub-header (less prominent than header).
330    pub fn subheader(&self, title: &str) {
331        let c = &self.colors;
332        println!();
333        println!("{}{}{} {}{}", c.bold(), c.blue(), ARROW, title, c.reset());
334        println!("{}{}──{}", c.dim(), "─".repeat(title.len()), c.reset());
335    }
336}
337
338impl Default for Logger {
339    fn default() -> Self {
340        Self::new(Colors::new())
341    }
342}
343
344// ===== Loggable Implementation =====
345
346impl Loggable for Logger {
347    fn log(&self, msg: &str) {
348        self.log_to_file(msg);
349    }
350
351    fn info(&self, msg: &str) {
352        let c = &self.colors;
353        println!(
354            "{}[{}]{} {}{}{} {}",
355            c.dim(),
356            timestamp(),
357            c.reset(),
358            c.blue(),
359            INFO,
360            c.reset(),
361            msg
362        );
363        self.log(&format!("[{}] [INFO] {msg}", timestamp()));
364    }
365
366    fn success(&self, msg: &str) {
367        let c = &self.colors;
368        println!(
369            "{}[{}]{} {}{}{} {}{}{}",
370            c.dim(),
371            timestamp(),
372            c.reset(),
373            c.green(),
374            CHECK,
375            c.reset(),
376            c.green(),
377            msg,
378            c.reset()
379        );
380        self.log(&format!("[{}] [OK] {msg}", timestamp()));
381    }
382
383    fn warn(&self, msg: &str) {
384        let c = &self.colors;
385        println!(
386            "{}[{}]{} {}{}{} {}{}{}",
387            c.dim(),
388            timestamp(),
389            c.reset(),
390            c.yellow(),
391            WARN,
392            c.reset(),
393            c.yellow(),
394            msg,
395            c.reset()
396        );
397        self.log(&format!("[{}] [WARN] {msg}", timestamp()));
398    }
399
400    fn error(&self, msg: &str) {
401        let c = &self.colors;
402        eprintln!(
403            "{}[{}]{} {}{}{} {}{}{}",
404            c.dim(),
405            timestamp(),
406            c.reset(),
407            c.red(),
408            CROSS,
409            c.reset(),
410            c.red(),
411            msg,
412            c.reset()
413        );
414        self.log(&format!("[{}] [ERROR] {msg}", timestamp()));
415    }
416
417    fn header(&self, title: &str, color_fn: fn(Colors) -> &'static str) {
418        // Call the inherent impl's header method
419        // We need to duplicate the implementation here since calling the inherent
420        // method from a trait impl causes issues with method resolution
421        let c = self.colors;
422        let color = color_fn(c);
423        let width = 60;
424        let title_len = title.chars().count();
425        let padding = (width - title_len - 2) / 2;
426
427        println!();
428        println!(
429            "{}{}{}{}{}{}",
430            color,
431            c.bold(),
432            BOX_TL,
433            BOX_H.to_string().repeat(width),
434            BOX_TR,
435            c.reset()
436        );
437        println!(
438            "{}{}{}{}{}{}{}{}{}{}",
439            color,
440            c.bold(),
441            BOX_V,
442            " ".repeat(padding),
443            c.white(),
444            title,
445            color,
446            " ".repeat(width - padding - title_len),
447            BOX_V,
448            c.reset()
449        );
450        println!(
451            "{}{}{}{}{}{}",
452            color,
453            c.bold(),
454            BOX_BL,
455            BOX_H.to_string().repeat(width),
456            BOX_BR,
457            c.reset()
458        );
459    }
460}
461
462// ===== Printable and Write Implementations =====
463
464impl std::io::Write for Logger {
465    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
466        // Write directly to stdout
467        std::io::stdout().write(buf)
468    }
469
470    fn flush(&mut self) -> std::io::Result<()> {
471        std::io::stdout().flush()
472    }
473}
474
475impl Printable for Logger {
476    fn is_terminal(&self) -> bool {
477        std::io::stdout().is_terminal()
478    }
479}
480
481/// Strip ANSI escape sequences from a string.
482///
483/// Used when writing to log files where ANSI codes are not supported.
484pub fn strip_ansi_codes(s: &str) -> String {
485    static ANSI_RE: std::sync::LazyLock<Result<regex::Regex, regex::Error>> =
486        std::sync::LazyLock::new(|| regex::Regex::new(r"\x1b\[[0-9;]*m"));
487    (*ANSI_RE)
488        .as_ref()
489        .map_or_else(|_| s.to_string(), |re| re.replace_all(s, "").to_string())
490}
491
492/// Trait for logger output destinations.
493///
494/// This trait allows loggers to write to different destinations
495/// (console, files, test collectors) without hardcoding the specific destination.
496/// This makes loggers testable by allowing output capture.
497///
498/// This trait mirrors the `Printable` trait pattern used for printers,
499/// providing a unified interface for both production and test loggers.
500pub trait Loggable {
501    /// Write a log message to the sink.
502    ///
503    /// This is the core logging method that all loggers must implement.
504    /// For `Logger`, this writes to the configured log file (if any).
505    /// For `TestLogger`, this captures the message in memory for testing.
506    fn log(&self, msg: &str);
507
508    /// Log an informational message.
509    ///
510    /// Default implementation formats the message with [INFO] prefix
511    /// and delegates to the `log` method.
512    fn info(&self, msg: &str) {
513        self.log(&format!("[INFO] {msg}"));
514    }
515
516    /// Log a success message.
517    ///
518    /// Default implementation formats the message with [OK] prefix
519    /// and delegates to the `log` method.
520    fn success(&self, msg: &str) {
521        self.log(&format!("[OK] {msg}"));
522    }
523
524    /// Log a warning message.
525    ///
526    /// Default implementation formats the message with [WARN] prefix
527    /// and delegates to the `log` method.
528    fn warn(&self, msg: &str) {
529        self.log(&format!("[WARN] {msg}"));
530    }
531
532    /// Log an error message.
533    ///
534    /// Default implementation formats the message with [ERROR] prefix
535    /// and delegates to the `log` method.
536    fn error(&self, msg: &str) {
537        self.log(&format!("[ERROR] {msg}"));
538    }
539
540    /// Print a section header with box drawing.
541    ///
542    /// Default implementation does nothing (test loggers may not need headers).
543    /// Production loggers override this to display styled headers.
544    fn header(&self, _title: &str, _color_fn: fn(Colors) -> &'static str) {
545        // Default: no-op for test loggers
546    }
547}
548
549/// Test logger that captures log output for assertion.
550///
551/// This logger stores all log messages in memory for testing purposes.
552/// It provides methods to retrieve and inspect the captured log output.
553/// Uses line buffering similar to `TestPrinter` to handle partial writes.
554///
555/// # Availability
556///
557/// `TestLogger` is available in test builds (`#[cfg(any(test, feature = "test-utils"))]`) and when the
558/// `test-utils` feature is enabled (for integration tests). In production
559/// binary builds with `--all-features`, the `test-utils` feature enables
560/// this code but it's not used by the binary, which is expected behavior.
561#[cfg(any(test, feature = "test-utils"))]
562#[derive(Debug, Default)]
563pub struct TestLogger {
564    /// Captured complete log lines.
565    logs: RefCell<Vec<String>>,
566    /// Buffer for incomplete lines (content without trailing newline).
567    buffer: RefCell<String>,
568}
569
570#[cfg(any(test, feature = "test-utils"))]
571impl TestLogger {
572    /// Create a new test logger.
573    pub fn new() -> Self {
574        Self::default()
575    }
576
577    /// Get all captured log messages including partial buffered content.
578    pub fn get_logs(&self) -> Vec<String> {
579        let mut result = self.logs.borrow().clone();
580        let buffer = self.buffer.borrow();
581        if !buffer.is_empty() {
582            result.push(buffer.clone());
583        }
584        result
585    }
586
587    /// Clear all captured log messages and buffered content.
588    pub fn clear(&self) {
589        self.logs.borrow_mut().clear();
590        self.buffer.borrow_mut().clear();
591    }
592
593    /// Check if a specific message exists in the logs.
594    pub fn has_log(&self, msg: &str) -> bool {
595        self.get_logs().iter().any(|l| l.contains(msg))
596    }
597
598    /// Get the number of times a specific pattern appears in logs.
599    pub fn count_pattern(&self, pattern: &str) -> usize {
600        self.get_logs()
601            .iter()
602            .filter(|l| l.contains(pattern))
603            .count()
604    }
605}
606
607#[cfg(any(test, feature = "test-utils"))]
608impl Loggable for TestLogger {
609    fn log(&self, msg: &str) {
610        self.logs.borrow_mut().push(msg.to_string());
611    }
612
613    fn info(&self, msg: &str) {
614        self.log(&format!("[INFO] {msg}"));
615    }
616
617    fn success(&self, msg: &str) {
618        self.log(&format!("[OK] {msg}"));
619    }
620
621    fn warn(&self, msg: &str) {
622        self.log(&format!("[WARN] {msg}"));
623    }
624
625    fn error(&self, msg: &str) {
626        self.log(&format!("[ERROR] {msg}"));
627    }
628}
629
630#[cfg(any(test, feature = "test-utils"))]
631impl Printable for TestLogger {
632    fn is_terminal(&self) -> bool {
633        // Test logger is never a terminal
634        false
635    }
636}
637
638#[cfg(any(test, feature = "test-utils"))]
639impl std::io::Write for TestLogger {
640    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
641        let s = std::str::from_utf8(buf)
642            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
643        let mut buffer = self.buffer.borrow_mut();
644        buffer.push_str(s);
645
646        // Process complete lines (similar to TestPrinter)
647        while let Some(newline_pos) = buffer.find('\n') {
648            let line = buffer.drain(..=newline_pos).collect::<String>();
649            self.logs.borrow_mut().push(line);
650        }
651
652        Ok(buf.len())
653    }
654
655    fn flush(&mut self) -> std::io::Result<()> {
656        // Flush any remaining buffer content to logs
657        let mut buffer = self.buffer.borrow_mut();
658        if !buffer.is_empty() {
659            self.logs.borrow_mut().push(buffer.clone());
660            buffer.clear();
661        }
662        Ok(())
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    // =========================================================================
671    // Workspace-based logger tests
672    // =========================================================================
673
674    #[cfg(feature = "test-utils")]
675    mod workspace_tests {
676        use super::*;
677        use crate::workspace::MemoryWorkspace;
678        use std::sync::Arc;
679
680        #[test]
681        fn test_logger_with_workspace_writes_to_file() {
682            let workspace = Arc::new(MemoryWorkspace::new_test());
683            let logger = Logger::new(Colors::new())
684                .with_workspace_log(workspace.clone(), ".agent/logs/test.log");
685
686            // Use the Loggable trait to log a message
687            Loggable::info(&logger, "test message");
688
689            // Verify the message was written to the workspace
690            let content = workspace.get_file(".agent/logs/test.log").unwrap();
691            assert!(content.contains("test message"));
692            assert!(content.contains("[INFO]"));
693        }
694
695        #[test]
696        fn test_logger_with_workspace_strips_ansi_codes() {
697            let workspace = Arc::new(MemoryWorkspace::new_test());
698            let logger = Logger::new(Colors::new())
699                .with_workspace_log(workspace.clone(), ".agent/logs/test.log");
700
701            // Log via the internal method that includes ANSI codes
702            logger.log("[INFO] \x1b[31mcolored\x1b[0m message");
703
704            let content = workspace.get_file(".agent/logs/test.log").unwrap();
705            assert!(content.contains("colored message"));
706            assert!(!content.contains("\x1b["));
707        }
708
709        #[test]
710        fn test_logger_with_workspace_creates_parent_dirs() {
711            let workspace = Arc::new(MemoryWorkspace::new_test());
712            let logger = Logger::new(Colors::new())
713                .with_workspace_log(workspace.clone(), ".agent/logs/nested/deep/test.log");
714
715            Loggable::info(&logger, "nested log");
716
717            // Should have created parent directories
718            assert!(workspace.exists(std::path::Path::new(".agent/logs/nested/deep")));
719            let content = workspace
720                .get_file(".agent/logs/nested/deep/test.log")
721                .unwrap();
722            assert!(content.contains("nested log"));
723        }
724    }
725    #[test]
726    fn test_strip_ansi_codes() {
727        let input = "\x1b[31mred\x1b[0m text";
728        assert_eq!(strip_ansi_codes(input), "red text");
729    }
730
731    #[test]
732    fn test_strip_ansi_codes_no_codes() {
733        let input = "plain text";
734        assert_eq!(strip_ansi_codes(input), "plain text");
735    }
736
737    #[test]
738    fn test_strip_ansi_codes_multiple() {
739        let input = "\x1b[1m\x1b[32mbold green\x1b[0m \x1b[34mblue\x1b[0m";
740        assert_eq!(strip_ansi_codes(input), "bold green blue");
741    }
742
743    #[test]
744    fn test_logger_captures_output() {
745        let logger = TestLogger::new();
746        logger.log("Test message");
747        assert!(logger.has_log("Test message"));
748    }
749
750    #[test]
751    fn test_logger_get_logs() {
752        let logger = TestLogger::new();
753        logger.log("Message 1");
754        logger.log("Message 2");
755        let logs = logger.get_logs();
756        assert_eq!(logs.len(), 2);
757        assert_eq!(logs[0], "Message 1");
758        assert_eq!(logs[1], "Message 2");
759    }
760
761    #[test]
762    fn test_logger_clear() {
763        let logger = TestLogger::new();
764        logger.log("Before clear");
765        assert!(!logger.get_logs().is_empty());
766        logger.clear();
767        assert!(logger.get_logs().is_empty());
768    }
769
770    #[test]
771    fn test_logger_count_pattern() {
772        let logger = TestLogger::new();
773        logger.log("test message 1");
774        logger.log("test message 2");
775        logger.log("other message");
776        assert_eq!(logger.count_pattern("test"), 2);
777    }
778}
779
780// ===== Output Formatting Functions =====
781
782/// Detect if command-line arguments request JSON output.
783///
784/// Scans the provided argv for common JSON output flags used by various CLIs:
785/// - `--json` or `--json=...`
786/// - `--output-format` with json value
787/// - `--format json`
788/// - `-F json`
789/// - `-o stream-json` or similar
790pub fn argv_requests_json(argv: &[String]) -> bool {
791    // Skip argv[0] (the executable); scan flags/args only.
792    let mut iter = argv.iter().skip(1).peekable();
793    while let Some(arg) = iter.next() {
794        if arg == "--json" || arg.starts_with("--json=") {
795            return true;
796        }
797
798        if arg == "--output-format" {
799            if let Some(next) = iter.peek() {
800                let next = next.as_str();
801                if next.contains("json") {
802                    return true;
803                }
804            }
805        }
806        if let Some((flag, value)) = arg.split_once('=') {
807            if flag == "--output-format" && value.contains("json") {
808                return true;
809            }
810            if flag == "--format" && value == "json" {
811                return true;
812            }
813        }
814
815        if arg == "--format" {
816            if let Some(next) = iter.peek() {
817                if next.as_str() == "json" {
818                    return true;
819                }
820            }
821        }
822
823        // Some CLIs use short flags like -F json or -o stream-json
824        if arg == "-F" {
825            if let Some(next) = iter.peek() {
826                if next.as_str() == "json" {
827                    return true;
828                }
829            }
830        }
831        if arg.starts_with("-F") && arg != "-F" && arg.trim_start_matches("-F") == "json" {
832            return true;
833        }
834
835        if arg == "-o" {
836            if let Some(next) = iter.peek() {
837                let next = next.as_str();
838                if next.contains("json") {
839                    return true;
840                }
841            }
842        }
843        if arg.starts_with("-o") && arg != "-o" && arg.trim_start_matches("-o").contains("json") {
844            return true;
845        }
846    }
847    false
848}
849
850/// Format generic JSON output for display.
851///
852/// Parses the input as JSON and formats it according to verbosity level:
853/// - `Full` or `Debug`: Pretty-print with indentation
854/// - Other levels: Compact single-line format
855///
856/// Output is truncated according to verbosity limits.
857pub fn format_generic_json_for_display(line: &str, verbosity: Verbosity) -> String {
858    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
859        return truncate_text(line, verbosity.truncate_limit("agent_msg"));
860    };
861
862    let formatted = match verbosity {
863        Verbosity::Full | Verbosity::Debug => {
864            serde_json::to_string_pretty(&value).unwrap_or_else(|_| line.to_string())
865        }
866        _ => serde_json::to_string(&value).unwrap_or_else(|_| line.to_string()),
867    };
868    truncate_text(&formatted, verbosity.truncate_limit("agent_msg"))
869}
870
871#[cfg(test)]
872mod output_formatting_tests {
873    use super::argv_requests_json;
874    use super::format_generic_json_for_display;
875    use crate::config::Verbosity;
876
877    #[test]
878    fn test_argv_requests_json_detects_common_flags() {
879        assert!(argv_requests_json(&[
880            "tool".to_string(),
881            "--json".to_string()
882        ]));
883        assert!(argv_requests_json(&[
884            "tool".to_string(),
885            "--output-format=stream-json".to_string()
886        ]));
887        assert!(argv_requests_json(&[
888            "tool".to_string(),
889            "--output-format".to_string(),
890            "stream-json".to_string()
891        ]));
892        assert!(argv_requests_json(&[
893            "tool".to_string(),
894            "--format".to_string(),
895            "json".to_string()
896        ]));
897        assert!(argv_requests_json(&[
898            "tool".to_string(),
899            "-F".to_string(),
900            "json".to_string()
901        ]));
902        assert!(argv_requests_json(&[
903            "tool".to_string(),
904            "-o".to_string(),
905            "stream-json".to_string()
906        ]));
907    }
908
909    #[test]
910    fn test_format_generic_json_for_display_pretty_prints_when_full() {
911        let line = r#"{"type":"message","content":{"text":"hello"}}"#;
912        let formatted = format_generic_json_for_display(line, Verbosity::Full);
913        assert!(formatted.contains('\n'));
914        assert!(formatted.contains("\"type\""));
915        assert!(formatted.contains("\"message\""));
916    }
917}