1use 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
104pub struct Logger {
109 colors: Colors,
110 log_file: Option<String>,
111}
112
113impl Logger {
114 pub const fn new(colors: Colors) -> Self {
116 Self {
117 colors,
118 log_file: None,
119 }
120 }
121
122 pub fn with_log_file(mut self, path: &str) -> Self {
126 self.log_file = Some(path.to_string());
127 self
128 }
129
130 fn log_to_file(&self, msg: &str) {
132 if let Some(ref path) = self.log_file {
133 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 let _ = file.sync_all();
143 }
144 }
145 }
146
147 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 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 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 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 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 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 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
295impl 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 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
413impl std::io::Write for Logger {
416 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
417 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
432pub 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
443pub trait Loggable {
452 fn log(&self, msg: &str);
458
459 fn info(&self, msg: &str) {
464 self.log(&format!("[INFO] {msg}"));
465 }
466
467 fn success(&self, msg: &str) {
472 self.log(&format!("[OK] {msg}"));
473 }
474
475 fn warn(&self, msg: &str) {
480 self.log(&format!("[WARN] {msg}"));
481 }
482
483 fn error(&self, msg: &str) {
488 self.log(&format!("[ERROR] {msg}"));
489 }
490
491 fn header(&self, _title: &str, _color_fn: fn(Colors) -> &'static str) {
496 }
498}
499
500#[cfg(any(test, feature = "test-utils"))]
513#[derive(Debug, Default)]
514pub struct TestLogger {
515 logs: RefCell<Vec<String>>,
517 buffer: RefCell<String>,
519}
520
521#[cfg(any(test, feature = "test-utils"))]
522impl TestLogger {
523 pub fn new() -> Self {
525 Self::default()
526 }
527
528 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 pub fn clear(&self) {
540 self.logs.borrow_mut().clear();
541 self.buffer.borrow_mut().clear();
542 }
543
544 pub fn has_log(&self, msg: &str) -> bool {
546 self.get_logs().iter().any(|l| l.contains(msg))
547 }
548
549 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 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 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 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
676pub fn argv_requests_json(argv: &[String]) -> bool {
687 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 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
746pub 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}