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 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
106pub struct Logger {
111 colors: Colors,
112 log_file: Option<String>,
114 workspace: Option<Arc<dyn Workspace>>,
116 workspace_log_path: Option<String>,
118}
119
120impl Logger {
121 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 pub fn with_log_file(mut self, path: &str) -> Self {
141 self.log_file = Some(path.to_string());
142 self
143 }
144
145 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 fn log_to_file(&self, msg: &str) {
167 let clean_msg = strip_ansi_codes(msg);
169
170 if let (Some(workspace), Some(ref path)) = (&self.workspace, &self.workspace_log_path) {
172 let path = std::path::Path::new(path);
173 if let Some(parent) = path.parent() {
175 let _ = workspace.create_dir_all(parent);
176 }
177 let _ = workspace.append_bytes(path, format!("{clean_msg}\n").as_bytes());
179 return;
180 }
181
182 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 let _ = file.sync_all();
192 }
193 }
194 }
195
196 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 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 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 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 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 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 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
344impl 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 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
462impl std::io::Write for Logger {
465 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
466 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
481pub 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
492pub trait Loggable {
501 fn log(&self, msg: &str);
507
508 fn info(&self, msg: &str) {
513 self.log(&format!("[INFO] {msg}"));
514 }
515
516 fn success(&self, msg: &str) {
521 self.log(&format!("[OK] {msg}"));
522 }
523
524 fn warn(&self, msg: &str) {
529 self.log(&format!("[WARN] {msg}"));
530 }
531
532 fn error(&self, msg: &str) {
537 self.log(&format!("[ERROR] {msg}"));
538 }
539
540 fn header(&self, _title: &str, _color_fn: fn(Colors) -> &'static str) {
545 }
547}
548
549#[cfg(any(test, feature = "test-utils"))]
562#[derive(Debug, Default)]
563pub struct TestLogger {
564 logs: RefCell<Vec<String>>,
566 buffer: RefCell<String>,
568}
569
570#[cfg(any(test, feature = "test-utils"))]
571impl TestLogger {
572 pub fn new() -> Self {
574 Self::default()
575 }
576
577 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 pub fn clear(&self) {
589 self.logs.borrow_mut().clear();
590 self.buffer.borrow_mut().clear();
591 }
592
593 pub fn has_log(&self, msg: &str) -> bool {
595 self.get_logs().iter().any(|l| l.contains(msg))
596 }
597
598 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 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 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 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 #[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 Loggable::info(&logger, "test message");
688
689 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 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 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
780pub fn argv_requests_json(argv: &[String]) -> bool {
791 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 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
850pub 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}