1use super::loggable::Loggable;
7use crate::checkpoint::timestamp;
8use crate::json_parser::printer::Printable;
9use crate::logger::{
10 Colors, ARROW, BOX_BL, BOX_BR, BOX_H, BOX_TL, BOX_TR, BOX_V, CHECK, CROSS, INFO, WARN,
11};
12use crate::workspace::Workspace;
13use std::fs::{self, OpenOptions};
14use std::io::{IsTerminal, Write};
15use std::path::Path;
16use std::sync::Arc;
17
18use crate::logger::output::strip_ansi_codes;
19
20pub struct Logger {
25 colors: Colors,
26 log_file: Option<String>,
28 workspace: Option<Arc<dyn Workspace>>,
30 workspace_log_path: Option<String>,
32}
33
34impl Logger {
35 #[must_use]
37 pub const fn new(colors: Colors) -> Self {
38 Self {
39 colors,
40 log_file: None,
41 workspace: None,
42 workspace_log_path: None,
43 }
44 }
45
46 #[must_use]
56 pub fn with_log_file(mut self, path: &str) -> Self {
57 self.log_file = Some(path.to_string());
58 self
59 }
60
61 #[must_use]
72 pub fn with_workspace_log(
73 mut self,
74 workspace: Arc<dyn Workspace>,
75 relative_path: &str,
76 ) -> Self {
77 self.workspace = Some(workspace);
78 self.workspace_log_path = Some(relative_path.to_string());
79 self
80 }
81
82 fn log_to_file(&self, msg: &str) {
84 let clean_msg = strip_ansi_codes(msg);
86
87 if let (Some(workspace), Some(ref path)) = (&self.workspace, &self.workspace_log_path) {
89 let path = std::path::Path::new(path);
90 if let Some(parent) = path.parent() {
92 let _ = workspace.create_dir_all(parent);
93 }
94 let _ = workspace.append_bytes(path, format!("{clean_msg}\n").as_bytes());
96 return;
97 }
98
99 if let Some(ref path) = self.log_file {
101 if let Some(parent) = Path::new(path).parent() {
102 let _ = fs::create_dir_all(parent);
103 }
104 if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
105 let _ = writeln!(file, "{clean_msg}");
106 let _ = file.flush();
107 let _ = file.sync_all();
109 }
110 }
111 }
112
113 pub fn info(&self, msg: &str) {
115 let c = &self.colors;
116 println!(
117 "{}[{}]{} {}{}{} {}",
118 c.dim(),
119 timestamp(),
120 c.reset(),
121 c.blue(),
122 INFO,
123 c.reset(),
124 msg
125 );
126 self.log_to_file(&format!("[{}] [INFO] {}", timestamp(), msg));
127 }
128
129 pub fn success(&self, msg: &str) {
131 let c = &self.colors;
132 println!(
133 "{}[{}]{} {}{}{} {}{}{}",
134 c.dim(),
135 timestamp(),
136 c.reset(),
137 c.green(),
138 CHECK,
139 c.reset(),
140 c.green(),
141 msg,
142 c.reset()
143 );
144 self.log_to_file(&format!("[{}] [OK] {}", timestamp(), msg));
145 }
146
147 pub fn warn(&self, msg: &str) {
149 let c = &self.colors;
150 println!(
151 "{}[{}]{} {}{}{} {}{}{}",
152 c.dim(),
153 timestamp(),
154 c.reset(),
155 c.yellow(),
156 WARN,
157 c.reset(),
158 c.yellow(),
159 msg,
160 c.reset()
161 );
162 self.log_to_file(&format!("[{}] [WARN] {}", timestamp(), msg));
163 }
164
165 pub fn error(&self, msg: &str) {
167 let c = &self.colors;
168 eprintln!(
169 "{}[{}]{} {}{}{} {}{}{}",
170 c.dim(),
171 timestamp(),
172 c.reset(),
173 c.red(),
174 CROSS,
175 c.reset(),
176 c.red(),
177 msg,
178 c.reset()
179 );
180 self.log_to_file(&format!("[{}] [ERROR] {}", timestamp(), msg));
181 }
182
183 pub fn step(&self, msg: &str) {
185 let c = &self.colors;
186 println!(
187 "{}[{}]{} {}{}{} {}",
188 c.dim(),
189 timestamp(),
190 c.reset(),
191 c.magenta(),
192 ARROW,
193 c.reset(),
194 msg
195 );
196 self.log_to_file(&format!("[{}] [STEP] {}", timestamp(), msg));
197 }
198
199 pub fn header(&self, title: &str, color_fn: fn(Colors) -> &'static str) {
206 let c = self.colors;
207 let color = color_fn(c);
208 let width = 60;
209 let title_len = title.chars().count();
210 let padding = (width - title_len - 2) / 2;
211
212 println!();
213 println!(
214 "{}{}{}{}{}{}",
215 color,
216 c.bold(),
217 BOX_TL,
218 BOX_H.to_string().repeat(width),
219 BOX_TR,
220 c.reset()
221 );
222 println!(
223 "{}{}{}{}{}{}{}{}{}{}",
224 color,
225 c.bold(),
226 BOX_V,
227 " ".repeat(padding),
228 c.white(),
229 title,
230 color,
231 " ".repeat(width - padding - title_len),
232 BOX_V,
233 c.reset()
234 );
235 println!(
236 "{}{}{}{}{}{}",
237 color,
238 c.bold(),
239 BOX_BL,
240 BOX_H.to_string().repeat(width),
241 BOX_BR,
242 c.reset()
243 );
244 }
245
246 pub fn subheader(&self, title: &str) {
248 let c = &self.colors;
249 println!();
250 println!("{}{}{} {}{}", c.bold(), c.blue(), ARROW, title, c.reset());
251 println!("{}{}──{}", c.dim(), "─".repeat(title.len()), c.reset());
252 }
253}
254
255impl Default for Logger {
256 fn default() -> Self {
257 Self::new(Colors::new())
258 }
259}
260
261impl Loggable for Logger {
264 fn log(&self, msg: &str) {
265 self.log_to_file(msg);
266 }
267
268 fn info(&self, msg: &str) {
269 let c = &self.colors;
270 println!(
271 "{}[{}]{} {}{}{} {}",
272 c.dim(),
273 timestamp(),
274 c.reset(),
275 c.blue(),
276 INFO,
277 c.reset(),
278 msg
279 );
280 self.log(&format!("[{}] [INFO] {msg}", timestamp()));
281 }
282
283 fn success(&self, msg: &str) {
284 let c = &self.colors;
285 println!(
286 "{}[{}]{} {}{}{} {}{}{}",
287 c.dim(),
288 timestamp(),
289 c.reset(),
290 c.green(),
291 CHECK,
292 c.reset(),
293 c.green(),
294 msg,
295 c.reset()
296 );
297 self.log(&format!("[{}] [OK] {msg}", timestamp()));
298 }
299
300 fn warn(&self, msg: &str) {
301 let c = &self.colors;
302 println!(
303 "{}[{}]{} {}{}{} {}{}{}",
304 c.dim(),
305 timestamp(),
306 c.reset(),
307 c.yellow(),
308 WARN,
309 c.reset(),
310 c.yellow(),
311 msg,
312 c.reset()
313 );
314 self.log(&format!("[{}] [WARN] {msg}", timestamp()));
315 }
316
317 fn error(&self, msg: &str) {
318 let c = &self.colors;
319 eprintln!(
320 "{}[{}]{} {}{}{} {}{}{}",
321 c.dim(),
322 timestamp(),
323 c.reset(),
324 c.red(),
325 CROSS,
326 c.reset(),
327 c.red(),
328 msg,
329 c.reset()
330 );
331 self.log(&format!("[{}] [ERROR] {msg}", timestamp()));
332 }
333
334 fn header(&self, title: &str, color_fn: fn(Colors) -> &'static str) {
335 let c = self.colors;
339 let color = color_fn(c);
340 let width = 60;
341 let title_len = title.chars().count();
342 let padding = (width - title_len - 2) / 2;
343
344 println!();
345 println!(
346 "{}{}{}{}{}{}",
347 color,
348 c.bold(),
349 BOX_TL,
350 BOX_H.to_string().repeat(width),
351 BOX_TR,
352 c.reset()
353 );
354 println!(
355 "{}{}{}{}{}{}{}{}{}{}",
356 color,
357 c.bold(),
358 BOX_V,
359 " ".repeat(padding),
360 c.white(),
361 title,
362 color,
363 " ".repeat(width - padding - title_len),
364 BOX_V,
365 c.reset()
366 );
367 println!(
368 "{}{}{}{}{}{}",
369 color,
370 c.bold(),
371 BOX_BL,
372 BOX_H.to_string().repeat(width),
373 BOX_BR,
374 c.reset()
375 );
376 }
377}
378
379impl std::io::Write for Logger {
382 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
383 std::io::stdout().write(buf)
385 }
386
387 fn flush(&mut self) -> std::io::Result<()> {
388 std::io::stdout().flush()
389 }
390}
391
392impl Printable for Logger {
393 fn is_terminal(&self) -> bool {
394 std::io::stdout().is_terminal()
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 #[cfg(feature = "test-utils")]
405 mod workspace_tests {
406 use super::super::*;
407 use crate::workspace::MemoryWorkspace;
408
409 #[test]
410 fn test_logger_with_workspace_writes_to_file() {
411 let workspace = Arc::new(MemoryWorkspace::new_test());
412 let logger = Logger::new(Colors::new())
413 .with_workspace_log(workspace.clone(), ".agent/logs/test.log");
414
415 Loggable::info(&logger, "test message");
417
418 let content = workspace.get_file(".agent/logs/test.log").unwrap();
420 assert!(content.contains("test message"));
421 assert!(content.contains("[INFO]"));
422 }
423
424 #[test]
425 fn test_logger_with_workspace_strips_ansi_codes() {
426 let workspace = Arc::new(MemoryWorkspace::new_test());
427 let logger = Logger::new(Colors::new())
428 .with_workspace_log(workspace.clone(), ".agent/logs/test.log");
429
430 logger.log("[INFO] \x1b[31mcolored\x1b[0m message");
432
433 let content = workspace.get_file(".agent/logs/test.log").unwrap();
434 assert!(content.contains("colored message"));
435 assert!(!content.contains("\x1b["));
436 }
437
438 #[test]
439 fn test_logger_with_workspace_creates_parent_dirs() {
440 let workspace = Arc::new(MemoryWorkspace::new_test());
441 let logger = Logger::new(Colors::new())
442 .with_workspace_log(workspace.clone(), ".agent/logs/nested/deep/test.log");
443
444 Loggable::info(&logger, "nested log");
445
446 assert!(workspace.exists(std::path::Path::new(".agent/logs/nested/deep")));
448 let content = workspace
449 .get_file(".agent/logs/nested/deep/test.log")
450 .unwrap();
451 assert!(content.contains("nested log"));
452 }
453 }
454}