1use 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
17pub struct Logger {
22 colors: Colors,
23 log_file: Option<String>,
24}
25
26impl Logger {
27 pub const fn new(colors: Colors) -> Self {
29 Self {
30 colors,
31 log_file: None,
32 }
33 }
34
35 pub fn with_log_file(mut self, path: &str) -> Self {
39 self.log_file = Some(path.to_string());
40 self
41 }
42
43 fn log_to_file(&self, msg: &str) {
45 if let Some(ref path) = self.log_file {
46 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 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 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 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 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 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 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 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
205impl std::io::Write for Logger {
208 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
209 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
224pub 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
258pub fn argv_requests_json(argv: &[String]) -> bool {
269 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 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
328pub 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}