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 std::fs::{self, OpenOptions};
98use std::io::{IsTerminal, Write};
99use std::path::Path;
100
101#[cfg(any(test, feature = "test-utils"))]
102use std::cell::RefCell;
103
104/// Logger for Ralph output.
105///
106/// Provides consistent, colorized output with optional file logging.
107/// All messages include timestamps and appropriate icons.
108pub struct Logger {
109    colors: Colors,
110    log_file: Option<String>,
111}
112
113impl Logger {
114    /// Create a new Logger with the given colors configuration.
115    pub const fn new(colors: Colors) -> Self {
116        Self {
117            colors,
118            log_file: None,
119        }
120    }
121
122    /// Configure the logger to also write to a file.
123    ///
124    /// Log messages written to the file will have ANSI codes stripped.
125    pub fn with_log_file(mut self, path: &str) -> Self {
126        self.log_file = Some(path.to_string());
127        self
128    }
129
130    /// Write a message to the log file (if configured).
131    fn log_to_file(&self, msg: &str) {
132        if let Some(ref path) = self.log_file {
133            // Strip ANSI codes for file logging
134            let clean_msg = strip_ansi_codes(msg);
135            if let Some(parent) = Path::new(path).parent() {
136                let _ = fs::create_dir_all(parent);
137            }
138            if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
139                let _ = writeln!(file, "{clean_msg}");
140                let _ = file.flush();
141                // Ensure data is written to disk before continuing
142                let _ = file.sync_all();
143            }
144        }
145    }
146
147    /// Log an informational message.
148    pub fn info(&self, msg: &str) {
149        let c = &self.colors;
150        println!(
151            "{}[{}]{} {}{}{} {}",
152            c.dim(),
153            timestamp(),
154            c.reset(),
155            c.blue(),
156            INFO,
157            c.reset(),
158            msg
159        );
160        self.log_to_file(&format!("[{}] [INFO] {}", timestamp(), msg));
161    }
162
163    /// Log a success message.
164    pub fn success(&self, msg: &str) {
165        let c = &self.colors;
166        println!(
167            "{}[{}]{} {}{}{} {}{}{}",
168            c.dim(),
169            timestamp(),
170            c.reset(),
171            c.green(),
172            CHECK,
173            c.reset(),
174            c.green(),
175            msg,
176            c.reset()
177        );
178        self.log_to_file(&format!("[{}] [OK] {}", timestamp(), msg));
179    }
180
181    /// Log a warning message.
182    pub fn warn(&self, msg: &str) {
183        let c = &self.colors;
184        println!(
185            "{}[{}]{} {}{}{} {}{}{}",
186            c.dim(),
187            timestamp(),
188            c.reset(),
189            c.yellow(),
190            WARN,
191            c.reset(),
192            c.yellow(),
193            msg,
194            c.reset()
195        );
196        self.log_to_file(&format!("[{}] [WARN] {}", timestamp(), msg));
197    }
198
199    /// Log an error message.
200    pub fn error(&self, msg: &str) {
201        let c = &self.colors;
202        eprintln!(
203            "{}[{}]{} {}{}{} {}{}{}",
204            c.dim(),
205            timestamp(),
206            c.reset(),
207            c.red(),
208            CROSS,
209            c.reset(),
210            c.red(),
211            msg,
212            c.reset()
213        );
214        self.log_to_file(&format!("[{}] [ERROR] {}", timestamp(), msg));
215    }
216
217    /// Log a step/action message.
218    pub fn step(&self, msg: &str) {
219        let c = &self.colors;
220        println!(
221            "{}[{}]{} {}{}{} {}",
222            c.dim(),
223            timestamp(),
224            c.reset(),
225            c.magenta(),
226            ARROW,
227            c.reset(),
228            msg
229        );
230        self.log_to_file(&format!("[{}] [STEP] {}", timestamp(), msg));
231    }
232
233    /// Print a section header with box drawing.
234    ///
235    /// # Arguments
236    ///
237    /// * `title` - The header title text
238    /// * `color_fn` - Function that returns the color to use
239    pub fn header(&self, title: &str, color_fn: fn(Colors) -> &'static str) {
240        let c = self.colors;
241        let color = color_fn(c);
242        let width = 60;
243        let title_len = title.chars().count();
244        let padding = (width - title_len - 2) / 2;
245
246        println!();
247        println!(
248            "{}{}{}{}{}{}",
249            color,
250            c.bold(),
251            BOX_TL,
252            BOX_H.to_string().repeat(width),
253            BOX_TR,
254            c.reset()
255        );
256        println!(
257            "{}{}{}{}{}{}{}{}{}{}",
258            color,
259            c.bold(),
260            BOX_V,
261            " ".repeat(padding),
262            c.white(),
263            title,
264            color,
265            " ".repeat(width - padding - title_len),
266            BOX_V,
267            c.reset()
268        );
269        println!(
270            "{}{}{}{}{}{}",
271            color,
272            c.bold(),
273            BOX_BL,
274            BOX_H.to_string().repeat(width),
275            BOX_BR,
276            c.reset()
277        );
278    }
279
280    /// Print a sub-header (less prominent than header).
281    pub fn subheader(&self, title: &str) {
282        let c = &self.colors;
283        println!();
284        println!("{}{}{} {}{}", c.bold(), c.blue(), ARROW, title, c.reset());
285        println!("{}{}──{}", c.dim(), "─".repeat(title.len()), c.reset());
286    }
287}
288
289impl Default for Logger {
290    fn default() -> Self {
291        Self::new(Colors::new())
292    }
293}
294
295// ===== Loggable Implementation =====
296
297impl Loggable for Logger {
298    fn log(&self, msg: &str) {
299        self.log_to_file(msg);
300    }
301
302    fn info(&self, msg: &str) {
303        let c = &self.colors;
304        println!(
305            "{}[{}]{} {}{}{} {}",
306            c.dim(),
307            timestamp(),
308            c.reset(),
309            c.blue(),
310            INFO,
311            c.reset(),
312            msg
313        );
314        self.log(&format!("[{}] [INFO] {msg}", timestamp()));
315    }
316
317    fn success(&self, msg: &str) {
318        let c = &self.colors;
319        println!(
320            "{}[{}]{} {}{}{} {}{}{}",
321            c.dim(),
322            timestamp(),
323            c.reset(),
324            c.green(),
325            CHECK,
326            c.reset(),
327            c.green(),
328            msg,
329            c.reset()
330        );
331        self.log(&format!("[{}] [OK] {msg}", timestamp()));
332    }
333
334    fn warn(&self, msg: &str) {
335        let c = &self.colors;
336        println!(
337            "{}[{}]{} {}{}{} {}{}{}",
338            c.dim(),
339            timestamp(),
340            c.reset(),
341            c.yellow(),
342            WARN,
343            c.reset(),
344            c.yellow(),
345            msg,
346            c.reset()
347        );
348        self.log(&format!("[{}] [WARN] {msg}", timestamp()));
349    }
350
351    fn error(&self, msg: &str) {
352        let c = &self.colors;
353        eprintln!(
354            "{}[{}]{} {}{}{} {}{}{}",
355            c.dim(),
356            timestamp(),
357            c.reset(),
358            c.red(),
359            CROSS,
360            c.reset(),
361            c.red(),
362            msg,
363            c.reset()
364        );
365        self.log(&format!("[{}] [ERROR] {msg}", timestamp()));
366    }
367
368    fn header(&self, title: &str, color_fn: fn(Colors) -> &'static str) {
369        // Call the inherent impl's header method
370        // We need to duplicate the implementation here since calling the inherent
371        // method from a trait impl causes issues with method resolution
372        let c = self.colors;
373        let color = color_fn(c);
374        let width = 60;
375        let title_len = title.chars().count();
376        let padding = (width - title_len - 2) / 2;
377
378        println!();
379        println!(
380            "{}{}{}{}{}{}",
381            color,
382            c.bold(),
383            BOX_TL,
384            BOX_H.to_string().repeat(width),
385            BOX_TR,
386            c.reset()
387        );
388        println!(
389            "{}{}{}{}{}{}{}{}{}{}",
390            color,
391            c.bold(),
392            BOX_V,
393            " ".repeat(padding),
394            c.white(),
395            title,
396            color,
397            " ".repeat(width - padding - title_len),
398            BOX_V,
399            c.reset()
400        );
401        println!(
402            "{}{}{}{}{}{}",
403            color,
404            c.bold(),
405            BOX_BL,
406            BOX_H.to_string().repeat(width),
407            BOX_BR,
408            c.reset()
409        );
410    }
411}
412
413// ===== Printable and Write Implementations =====
414
415impl std::io::Write for Logger {
416    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
417        // Write directly to stdout
418        std::io::stdout().write(buf)
419    }
420
421    fn flush(&mut self) -> std::io::Result<()> {
422        std::io::stdout().flush()
423    }
424}
425
426impl Printable for Logger {
427    fn is_terminal(&self) -> bool {
428        std::io::stdout().is_terminal()
429    }
430}
431
432/// Strip ANSI escape sequences from a string.
433///
434/// Used when writing to log files where ANSI codes are not supported.
435pub fn strip_ansi_codes(s: &str) -> String {
436    static ANSI_RE: std::sync::LazyLock<Result<regex::Regex, regex::Error>> =
437        std::sync::LazyLock::new(|| regex::Regex::new(r"\x1b\[[0-9;]*m"));
438    (*ANSI_RE)
439        .as_ref()
440        .map_or_else(|_| s.to_string(), |re| re.replace_all(s, "").to_string())
441}
442
443/// Trait for logger output destinations.
444///
445/// This trait allows loggers to write to different destinations
446/// (console, files, test collectors) without hardcoding the specific destination.
447/// This makes loggers testable by allowing output capture.
448///
449/// This trait mirrors the `Printable` trait pattern used for printers,
450/// providing a unified interface for both production and test loggers.
451pub trait Loggable {
452    /// Write a log message to the sink.
453    ///
454    /// This is the core logging method that all loggers must implement.
455    /// For `Logger`, this writes to the configured log file (if any).
456    /// For `TestLogger`, this captures the message in memory for testing.
457    fn log(&self, msg: &str);
458
459    /// Log an informational message.
460    ///
461    /// Default implementation formats the message with [INFO] prefix
462    /// and delegates to the `log` method.
463    fn info(&self, msg: &str) {
464        self.log(&format!("[INFO] {msg}"));
465    }
466
467    /// Log a success message.
468    ///
469    /// Default implementation formats the message with [OK] prefix
470    /// and delegates to the `log` method.
471    fn success(&self, msg: &str) {
472        self.log(&format!("[OK] {msg}"));
473    }
474
475    /// Log a warning message.
476    ///
477    /// Default implementation formats the message with [WARN] prefix
478    /// and delegates to the `log` method.
479    fn warn(&self, msg: &str) {
480        self.log(&format!("[WARN] {msg}"));
481    }
482
483    /// Log an error message.
484    ///
485    /// Default implementation formats the message with [ERROR] prefix
486    /// and delegates to the `log` method.
487    fn error(&self, msg: &str) {
488        self.log(&format!("[ERROR] {msg}"));
489    }
490
491    /// Print a section header with box drawing.
492    ///
493    /// Default implementation does nothing (test loggers may not need headers).
494    /// Production loggers override this to display styled headers.
495    fn header(&self, _title: &str, _color_fn: fn(Colors) -> &'static str) {
496        // Default: no-op for test loggers
497    }
498}
499
500/// Test logger that captures log output for assertion.
501///
502/// This logger stores all log messages in memory for testing purposes.
503/// It provides methods to retrieve and inspect the captured log output.
504/// Uses line buffering similar to `TestPrinter` to handle partial writes.
505///
506/// # Availability
507///
508/// `TestLogger` is available in test builds (`#[cfg(any(test, feature = "test-utils"))]`) and when the
509/// `test-utils` feature is enabled (for integration tests). In production
510/// binary builds with `--all-features`, the `test-utils` feature enables
511/// this code but it's not used by the binary, which is expected behavior.
512#[cfg(any(test, feature = "test-utils"))]
513#[derive(Debug, Default)]
514pub struct TestLogger {
515    /// Captured complete log lines.
516    logs: RefCell<Vec<String>>,
517    /// Buffer for incomplete lines (content without trailing newline).
518    buffer: RefCell<String>,
519}
520
521#[cfg(any(test, feature = "test-utils"))]
522impl TestLogger {
523    /// Create a new test logger.
524    pub fn new() -> Self {
525        Self::default()
526    }
527
528    /// Get all captured log messages including partial buffered content.
529    pub fn get_logs(&self) -> Vec<String> {
530        let mut result = self.logs.borrow().clone();
531        let buffer = self.buffer.borrow();
532        if !buffer.is_empty() {
533            result.push(buffer.clone());
534        }
535        result
536    }
537
538    /// Clear all captured log messages and buffered content.
539    pub fn clear(&self) {
540        self.logs.borrow_mut().clear();
541        self.buffer.borrow_mut().clear();
542    }
543
544    /// Check if a specific message exists in the logs.
545    pub fn has_log(&self, msg: &str) -> bool {
546        self.get_logs().iter().any(|l| l.contains(msg))
547    }
548
549    /// Get the number of times a specific pattern appears in logs.
550    pub fn count_pattern(&self, pattern: &str) -> usize {
551        self.get_logs()
552            .iter()
553            .filter(|l| l.contains(pattern))
554            .count()
555    }
556}
557
558#[cfg(any(test, feature = "test-utils"))]
559impl Loggable for TestLogger {
560    fn log(&self, msg: &str) {
561        self.logs.borrow_mut().push(msg.to_string());
562    }
563
564    fn info(&self, msg: &str) {
565        self.log(&format!("[INFO] {msg}"));
566    }
567
568    fn success(&self, msg: &str) {
569        self.log(&format!("[OK] {msg}"));
570    }
571
572    fn warn(&self, msg: &str) {
573        self.log(&format!("[WARN] {msg}"));
574    }
575
576    fn error(&self, msg: &str) {
577        self.log(&format!("[ERROR] {msg}"));
578    }
579}
580
581#[cfg(any(test, feature = "test-utils"))]
582impl Printable for TestLogger {
583    fn is_terminal(&self) -> bool {
584        // Test logger is never a terminal
585        false
586    }
587}
588
589#[cfg(any(test, feature = "test-utils"))]
590impl std::io::Write for TestLogger {
591    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
592        let s = std::str::from_utf8(buf)
593            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
594        let mut buffer = self.buffer.borrow_mut();
595        buffer.push_str(s);
596
597        // Process complete lines (similar to TestPrinter)
598        while let Some(newline_pos) = buffer.find('\n') {
599            let line = buffer.drain(..=newline_pos).collect::<String>();
600            self.logs.borrow_mut().push(line);
601        }
602
603        Ok(buf.len())
604    }
605
606    fn flush(&mut self) -> std::io::Result<()> {
607        // Flush any remaining buffer content to logs
608        let mut buffer = self.buffer.borrow_mut();
609        if !buffer.is_empty() {
610            self.logs.borrow_mut().push(buffer.clone());
611            buffer.clear();
612        }
613        Ok(())
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn test_strip_ansi_codes() {
623        let input = "\x1b[31mred\x1b[0m text";
624        assert_eq!(strip_ansi_codes(input), "red text");
625    }
626
627    #[test]
628    fn test_strip_ansi_codes_no_codes() {
629        let input = "plain text";
630        assert_eq!(strip_ansi_codes(input), "plain text");
631    }
632
633    #[test]
634    fn test_strip_ansi_codes_multiple() {
635        let input = "\x1b[1m\x1b[32mbold green\x1b[0m \x1b[34mblue\x1b[0m";
636        assert_eq!(strip_ansi_codes(input), "bold green blue");
637    }
638
639    #[test]
640    fn test_logger_captures_output() {
641        let logger = TestLogger::new();
642        logger.log("Test message");
643        assert!(logger.has_log("Test message"));
644    }
645
646    #[test]
647    fn test_logger_get_logs() {
648        let logger = TestLogger::new();
649        logger.log("Message 1");
650        logger.log("Message 2");
651        let logs = logger.get_logs();
652        assert_eq!(logs.len(), 2);
653        assert_eq!(logs[0], "Message 1");
654        assert_eq!(logs[1], "Message 2");
655    }
656
657    #[test]
658    fn test_logger_clear() {
659        let logger = TestLogger::new();
660        logger.log("Before clear");
661        assert!(!logger.get_logs().is_empty());
662        logger.clear();
663        assert!(logger.get_logs().is_empty());
664    }
665
666    #[test]
667    fn test_logger_count_pattern() {
668        let logger = TestLogger::new();
669        logger.log("test message 1");
670        logger.log("test message 2");
671        logger.log("other message");
672        assert_eq!(logger.count_pattern("test"), 2);
673    }
674}
675
676// ===== Output Formatting Functions =====
677
678/// Detect if command-line arguments request JSON output.
679///
680/// Scans the provided argv for common JSON output flags used by various CLIs:
681/// - `--json` or `--json=...`
682/// - `--output-format` with json value
683/// - `--format json`
684/// - `-F json`
685/// - `-o stream-json` or similar
686pub fn argv_requests_json(argv: &[String]) -> bool {
687    // Skip argv[0] (the executable); scan flags/args only.
688    let mut iter = argv.iter().skip(1).peekable();
689    while let Some(arg) = iter.next() {
690        if arg == "--json" || arg.starts_with("--json=") {
691            return true;
692        }
693
694        if arg == "--output-format" {
695            if let Some(next) = iter.peek() {
696                let next = next.as_str();
697                if next.contains("json") {
698                    return true;
699                }
700            }
701        }
702        if let Some((flag, value)) = arg.split_once('=') {
703            if flag == "--output-format" && value.contains("json") {
704                return true;
705            }
706            if flag == "--format" && value == "json" {
707                return true;
708            }
709        }
710
711        if arg == "--format" {
712            if let Some(next) = iter.peek() {
713                if next.as_str() == "json" {
714                    return true;
715                }
716            }
717        }
718
719        // Some CLIs use short flags like -F json or -o stream-json
720        if arg == "-F" {
721            if let Some(next) = iter.peek() {
722                if next.as_str() == "json" {
723                    return true;
724                }
725            }
726        }
727        if arg.starts_with("-F") && arg != "-F" && arg.trim_start_matches("-F") == "json" {
728            return true;
729        }
730
731        if arg == "-o" {
732            if let Some(next) = iter.peek() {
733                let next = next.as_str();
734                if next.contains("json") {
735                    return true;
736                }
737            }
738        }
739        if arg.starts_with("-o") && arg != "-o" && arg.trim_start_matches("-o").contains("json") {
740            return true;
741        }
742    }
743    false
744}
745
746/// Format generic JSON output for display.
747///
748/// Parses the input as JSON and formats it according to verbosity level:
749/// - `Full` or `Debug`: Pretty-print with indentation
750/// - Other levels: Compact single-line format
751///
752/// Output is truncated according to verbosity limits.
753pub fn format_generic_json_for_display(line: &str, verbosity: Verbosity) -> String {
754    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
755        return truncate_text(line, verbosity.truncate_limit("agent_msg"));
756    };
757
758    let formatted = match verbosity {
759        Verbosity::Full | Verbosity::Debug => {
760            serde_json::to_string_pretty(&value).unwrap_or_else(|_| line.to_string())
761        }
762        _ => serde_json::to_string(&value).unwrap_or_else(|_| line.to_string()),
763    };
764    truncate_text(&formatted, verbosity.truncate_limit("agent_msg"))
765}
766
767#[cfg(test)]
768mod output_formatting_tests {
769    use super::argv_requests_json;
770    use super::format_generic_json_for_display;
771    use crate::config::Verbosity;
772
773    #[test]
774    fn test_argv_requests_json_detects_common_flags() {
775        assert!(argv_requests_json(&[
776            "tool".to_string(),
777            "--json".to_string()
778        ]));
779        assert!(argv_requests_json(&[
780            "tool".to_string(),
781            "--output-format=stream-json".to_string()
782        ]));
783        assert!(argv_requests_json(&[
784            "tool".to_string(),
785            "--output-format".to_string(),
786            "stream-json".to_string()
787        ]));
788        assert!(argv_requests_json(&[
789            "tool".to_string(),
790            "--format".to_string(),
791            "json".to_string()
792        ]));
793        assert!(argv_requests_json(&[
794            "tool".to_string(),
795            "-F".to_string(),
796            "json".to_string()
797        ]));
798        assert!(argv_requests_json(&[
799            "tool".to_string(),
800            "-o".to_string(),
801            "stream-json".to_string()
802        ]));
803    }
804
805    #[test]
806    fn test_format_generic_json_for_display_pretty_prints_when_full() {
807        let line = r#"{"type":"message","content":{"text":"hello"}}"#;
808        let formatted = format_generic_json_for_display(line, Verbosity::Full);
809        assert!(formatted.contains('\n'));
810        assert!(formatted.contains("\"type\""));
811        assert!(formatted.contains("\"message\""));
812    }
813}