1use std::io;
7use tracing::Level;
8use tracing_subscriber::{
9 fmt::{self, format::FmtSpan},
10 layer::SubscriberExt,
11 util::SubscriberInitExt,
12 EnvFilter, Layer, Registry,
13};
14
15#[derive(Debug, Clone)]
17pub struct LoggingConfig {
18 pub level: Level,
20 pub color: bool,
22 pub show_timestamps: bool,
24 pub show_target: bool,
26 pub json_format: bool,
28 pub enable_spans: bool,
30 pub file_output: Option<std::path::PathBuf>,
32}
33
34impl Default for LoggingConfig {
35 fn default() -> Self {
36 Self {
37 level: Level::INFO,
38 color: true,
39 show_timestamps: false,
40 show_target: false,
41 json_format: false,
42 enable_spans: false,
43 file_output: None,
44 }
45 }
46}
47
48impl LoggingConfig {
49 pub fn for_mode(mode: ApplicationMode) -> Self {
51 match mode {
52 ApplicationMode::McpServer => Self {
53 level: Level::DEBUG,
54 color: false, show_timestamps: true,
56 show_target: true,
57 json_format: true, enable_spans: false, file_output: None,
60 },
61 ApplicationMode::Dashboard => Self {
62 level: Level::INFO,
63 color: false, show_timestamps: true,
65 show_target: true,
66 json_format: false,
67 enable_spans: true, file_output: None,
69 },
70 ApplicationMode::Cli => Self {
71 level: Level::INFO,
72 color: true,
73 show_timestamps: false,
74 show_target: false,
75 json_format: false,
76 enable_spans: false,
77 file_output: None,
78 },
79 ApplicationMode::Test => Self {
80 level: Level::DEBUG,
81 color: false,
82 show_timestamps: true,
83 show_target: true,
84 json_format: false,
85 enable_spans: true,
86 file_output: None,
87 },
88 }
89 }
90
91 pub fn from_args(quiet: bool, verbose: bool, json: bool) -> Self {
93 let level = if verbose {
94 Level::DEBUG
95 } else if quiet {
96 Level::ERROR
97 } else {
98 Level::INFO
99 };
100
101 Self {
102 level,
103 color: !quiet && !json && atty::is(atty::Stream::Stdout),
104 show_timestamps: verbose || json,
105 show_target: verbose,
106 json_format: json,
107 enable_spans: verbose,
108 file_output: None,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy)]
115pub enum ApplicationMode {
116 McpServer,
118 Dashboard,
120 Cli,
122 Test,
124}
125
126pub fn init_logging(config: LoggingConfig) -> io::Result<()> {
132 let env_filter = EnvFilter::try_from_default_env()
133 .unwrap_or_else(|_| EnvFilter::new(format!("intent_engine={}", config.level)));
134
135 let registry = Registry::default().with(env_filter);
136
137 if let Some(log_file) = config.file_output {
138 let log_dir = log_file
139 .parent()
140 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid log file path"))?;
141
142 let file_name = log_file
143 .file_name()
144 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid log file name"))?;
145
146 std::fs::create_dir_all(log_dir)?;
148
149 let file_appender = tracing_appender::rolling::daily(log_dir, file_name);
151
152 if config.json_format {
153 let json_layer = tracing_subscriber::fmt::layer()
154 .json()
155 .with_current_span(config.enable_spans)
156 .with_span_events(FmtSpan::CLOSE)
157 .with_writer(file_appender);
158 json_layer.with_subscriber(registry).init();
159 } else {
160 let fmt_layer = fmt::layer()
161 .with_target(config.show_target)
162 .with_level(true)
163 .with_ansi(false)
164 .with_writer(file_appender);
165
166 if config.show_timestamps {
167 fmt_layer
168 .with_timer(fmt::time::ChronoUtc::rfc_3339())
169 .with_subscriber(registry)
170 .init();
171 } else {
172 fmt_layer.with_subscriber(registry).init();
173 }
174 }
175 } else if config.json_format {
176 let json_layer = tracing_subscriber::fmt::layer()
177 .json()
178 .with_current_span(config.enable_spans)
179 .with_span_events(FmtSpan::CLOSE)
180 .with_writer(io::stdout);
181 json_layer.with_subscriber(registry).init();
182 } else {
183 let fmt_layer = fmt::layer()
184 .with_target(config.show_target)
185 .with_level(true)
186 .with_ansi(config.color)
187 .with_writer(io::stdout);
188
189 if config.show_timestamps {
190 fmt_layer
191 .with_timer(fmt::time::ChronoUtc::rfc_3339())
192 .with_subscriber(registry)
193 .init();
194 } else {
195 fmt_layer.with_subscriber(registry).init();
196 }
197 }
198
199 Ok(())
200}
201
202pub fn init_from_env() -> io::Result<()> {
204 let _level = match std::env::var("IE_LOG_LEVEL").as_deref() {
205 Ok("error") => Level::ERROR,
206 Ok("warn") => Level::WARN,
207 Ok("info") => Level::INFO,
208 Ok("debug") => Level::DEBUG,
209 Ok("trace") => Level::TRACE,
210 _ => Level::INFO,
211 };
212
213 let json = std::env::var("IE_LOG_JSON").as_deref() == Ok("true");
214 let verbose = std::env::var("IE_LOG_VERBOSE").as_deref() == Ok("true");
215 let quiet = std::env::var("IE_LOG_QUIET").as_deref() == Ok("true");
216
217 let config = LoggingConfig::from_args(quiet, verbose, json);
218 init_logging(config)
219}
220
221pub fn cleanup_old_logs(log_dir: &std::path::Path, retention_days: u32) -> io::Result<()> {
239 use std::fs;
240 use std::time::SystemTime;
241
242 if !log_dir.exists() {
243 return Ok(()); }
245
246 let now = SystemTime::now();
247 let retention_duration = std::time::Duration::from_secs(retention_days as u64 * 24 * 60 * 60);
248
249 let mut cleaned_count = 0;
250 let mut cleaned_size: u64 = 0;
251
252 for entry in fs::read_dir(log_dir)? {
253 let entry = entry?;
254 let path = entry.path();
255
256 let path_str = path.to_string_lossy();
259 if !path_str.contains(".log.") || !path.is_file() {
260 continue;
261 }
262
263 let metadata = entry.metadata()?;
264 let modified = metadata.modified()?;
265
266 if let Ok(age) = now.duration_since(modified) {
267 if age > retention_duration {
268 let size = metadata.len();
269 match fs::remove_file(&path) {
270 Ok(_) => {
271 cleaned_count += 1;
272 cleaned_size += size;
273 tracing::info!(
274 "Cleaned up old log file: {} (age: {} days, size: {} bytes)",
275 path.display(),
276 age.as_secs() / 86400,
277 size
278 );
279 },
280 Err(e) => {
281 tracing::warn!("Failed to remove old log file {}: {}", path.display(), e);
282 },
283 }
284 }
285 }
286 }
287
288 if cleaned_count > 0 {
289 tracing::info!(
290 "Log cleanup completed: removed {} files, freed {} bytes",
291 cleaned_count,
292 cleaned_size
293 );
294 }
295
296 Ok(())
297}
298
299#[macro_export]
301macro_rules! log_project_operation {
302 ($operation:expr, $project_path:expr) => {
303 tracing::info!(
304 operation = $operation,
305 project_path = %$project_path.display(),
306 "Project operation"
307 );
308 };
309 ($operation:expr, $project_path:expr, $details:expr) => {
310 tracing::info!(
311 operation = $operation,
312 project_path = %$project_path.display(),
313 details = $details,
314 "Project operation"
315 );
316 };
317}
318
319#[macro_export]
320macro_rules! log_mcp_operation {
321 ($operation:expr, $method:expr) => {
322 tracing::debug!(
323 operation = $operation,
324 mcp_method = $method,
325 "MCP operation"
326 );
327 };
328 ($operation:expr, $method:expr, $details:expr) => {
329 tracing::debug!(
330 operation = $operation,
331 mcp_method = $method,
332 details = $details,
333 "MCP operation"
334 );
335 };
336}
337
338#[macro_export]
339macro_rules! log_dashboard_operation {
340 ($operation:expr) => {
341 tracing::info!(operation = $operation, "Dashboard operation");
342 };
343 ($operation:expr, $details:expr) => {
344 tracing::info!(
345 operation = $operation,
346 details = $details,
347 "Dashboard operation"
348 );
349 };
350}
351
352#[macro_export]
353macro_rules! log_task_operation {
354 ($operation:expr, $task_id:expr) => {
355 tracing::info!(operation = $operation, task_id = $task_id, "Task operation");
356 };
357 ($operation:expr, $task_id:expr, $details:expr) => {
358 tracing::info!(
359 operation = $operation,
360 task_id = $task_id,
361 details = $details,
362 "Task operation"
363 );
364 };
365}
366
367#[macro_export]
368macro_rules! log_registry_operation {
369 ($operation:expr, $count:expr) => {
370 tracing::debug!(
371 operation = $operation,
372 project_count = $count,
373 "Registry operation"
374 );
375 };
376}
377
378#[macro_export]
380macro_rules! log_error {
381 ($error:expr, $context:expr) => {
382 tracing::error!(
383 error = %$error,
384 context = $context,
385 "Operation failed"
386 );
387 };
388}
389
390#[macro_export]
392macro_rules! log_warning {
393 ($message:expr) => {
394 tracing::warn!($message);
395 };
396 ($message:expr, $details:expr) => {
397 tracing::warn!(message = $message, details = $details, "Warning");
398 };
399}
400
401pub fn log_file_path(mode: ApplicationMode) -> std::path::PathBuf {
403 let home = dirs::home_dir().expect("Failed to get home directory");
404 let log_dir = home.join(".intent-engine").join("logs");
405
406 std::fs::create_dir_all(&log_dir).ok();
408
409 match mode {
410 ApplicationMode::Dashboard => log_dir.join("dashboard.log"),
411 ApplicationMode::McpServer => log_dir.join("mcp-server.log"),
412 ApplicationMode::Cli => log_dir.join("cli.log"),
413 ApplicationMode::Test => log_dir.join("test.log"),
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use std::fs;
421 use std::time::SystemTime;
422 use tempfile::TempDir;
423
424 #[test]
427 fn test_logging_config_default() {
428 let config = LoggingConfig::default();
429
430 assert_eq!(config.level, Level::INFO);
431 assert!(config.color);
432 assert!(!config.show_timestamps);
433 assert!(!config.show_target);
434 assert!(!config.json_format);
435 assert!(!config.enable_spans);
436 assert!(config.file_output.is_none());
437 }
438
439 #[test]
440 fn test_logging_config_for_mode_mcp_server() {
441 let config = LoggingConfig::for_mode(ApplicationMode::McpServer);
442
443 assert_eq!(config.level, Level::DEBUG);
444 assert!(!config.color); assert!(config.show_timestamps);
446 assert!(config.show_target);
447 assert!(config.json_format); assert!(!config.enable_spans); assert!(config.file_output.is_none());
450 }
451
452 #[test]
453 fn test_logging_config_for_mode_dashboard() {
454 let config = LoggingConfig::for_mode(ApplicationMode::Dashboard);
455
456 assert_eq!(config.level, Level::INFO);
457 assert!(!config.color); assert!(config.show_timestamps);
459 assert!(config.show_target);
460 assert!(!config.json_format);
461 assert!(config.enable_spans); assert!(config.file_output.is_none());
463 }
464
465 #[test]
466 fn test_logging_config_for_mode_cli() {
467 let config = LoggingConfig::for_mode(ApplicationMode::Cli);
468
469 assert_eq!(config.level, Level::INFO);
470 assert!(config.color); assert!(!config.show_timestamps);
472 assert!(!config.show_target);
473 assert!(!config.json_format);
474 assert!(!config.enable_spans);
475 assert!(config.file_output.is_none());
476 }
477
478 #[test]
479 fn test_logging_config_for_mode_test() {
480 let config = LoggingConfig::for_mode(ApplicationMode::Test);
481
482 assert_eq!(config.level, Level::DEBUG);
483 assert!(!config.color);
484 assert!(config.show_timestamps);
485 assert!(config.show_target);
486 assert!(!config.json_format);
487 assert!(config.enable_spans); assert!(config.file_output.is_none());
489 }
490
491 #[test]
492 fn test_logging_config_from_args_verbose() {
493 let config = LoggingConfig::from_args(false, true, false);
494
495 assert_eq!(config.level, Level::DEBUG);
496 assert!(config.show_timestamps);
497 assert!(config.show_target);
498 assert!(!config.json_format);
499 assert!(config.enable_spans);
500 }
501
502 #[test]
503 fn test_logging_config_from_args_quiet() {
504 let config = LoggingConfig::from_args(true, false, false);
505
506 assert_eq!(config.level, Level::ERROR);
507 assert!(!config.color); assert!(!config.show_timestamps); assert!(!config.show_target);
510 }
511
512 #[test]
513 fn test_logging_config_from_args_json() {
514 let config = LoggingConfig::from_args(false, false, true);
515
516 assert_eq!(config.level, Level::INFO);
517 assert!(!config.color); assert!(config.show_timestamps); assert!(config.json_format);
520 }
521
522 #[test]
523 fn test_logging_config_from_args_normal() {
524 let config = LoggingConfig::from_args(false, false, false);
525
526 assert_eq!(config.level, Level::INFO);
527 assert!(!config.show_timestamps);
528 assert!(!config.show_target);
529 assert!(!config.json_format);
530 assert!(!config.enable_spans);
531 }
532
533 #[test]
536 fn test_log_file_path_dashboard() {
537 let path = log_file_path(ApplicationMode::Dashboard);
538 assert!(path.to_string_lossy().ends_with("dashboard.log"));
539 assert!(path.to_string_lossy().contains(".intent-engine"));
540 assert!(path.to_string_lossy().contains("logs"));
541 }
542
543 #[test]
544 fn test_log_file_path_mcp_server() {
545 let path = log_file_path(ApplicationMode::McpServer);
546 assert!(path.to_string_lossy().ends_with("mcp-server.log"));
547 }
548
549 #[test]
550 fn test_log_file_path_cli() {
551 let path = log_file_path(ApplicationMode::Cli);
552 assert!(path.to_string_lossy().ends_with("cli.log"));
553 }
554
555 #[test]
556 fn test_log_file_path_test() {
557 let path = log_file_path(ApplicationMode::Test);
558 assert!(path.to_string_lossy().ends_with("test.log"));
559 }
560
561 #[test]
564 fn test_cleanup_old_logs_nonexistent_dir() {
565 let temp = TempDir::new().unwrap();
566 let nonexistent = temp.path().join("nonexistent");
567
568 let result = cleanup_old_logs(&nonexistent, 7);
570 assert!(result.is_ok());
571 }
572
573 #[test]
574 fn test_cleanup_old_logs_empty_dir() {
575 let temp = TempDir::new().unwrap();
576
577 let result = cleanup_old_logs(temp.path(), 7);
578 assert!(result.is_ok());
579 }
580
581 #[test]
582 fn test_cleanup_old_logs_keeps_current_logs() {
583 let temp = TempDir::new().unwrap();
584
585 let current_log = temp.path().join("dashboard.log");
587 fs::write(¤t_log, "current log data").unwrap();
588
589 cleanup_old_logs(temp.path(), 0).unwrap();
591
592 assert!(current_log.exists());
593 }
594
595 #[test]
596 fn test_cleanup_old_logs_removes_old_rotated_files() {
597 let temp = TempDir::new().unwrap();
598
599 let old_log = temp.path().join("dashboard.log.2020-01-01");
601 fs::write(&old_log, "old log data").unwrap();
602
603 let ten_days_ago = SystemTime::now()
605 .checked_sub(std::time::Duration::from_secs(10 * 24 * 60 * 60))
606 .unwrap();
607 filetime::set_file_mtime(&old_log, filetime::FileTime::from_system_time(ten_days_ago))
608 .unwrap();
609
610 cleanup_old_logs(temp.path(), 7).unwrap();
612
613 assert!(!old_log.exists());
615 }
616
617 #[test]
618 fn test_cleanup_old_logs_keeps_recent_rotated_files() {
619 let temp = TempDir::new().unwrap();
620
621 let recent_log = temp.path().join("mcp-server.log.2025-11-25");
623 fs::write(&recent_log, "recent log data").unwrap();
624
625 let three_days_ago = SystemTime::now()
627 .checked_sub(std::time::Duration::from_secs(3 * 24 * 60 * 60))
628 .unwrap();
629 filetime::set_file_mtime(
630 &recent_log,
631 filetime::FileTime::from_system_time(three_days_ago),
632 )
633 .unwrap();
634
635 cleanup_old_logs(temp.path(), 7).unwrap();
637
638 assert!(recent_log.exists());
640 }
641
642 #[test]
643 fn test_cleanup_old_logs_ignores_non_log_files() {
644 let temp = TempDir::new().unwrap();
645
646 let old_file = temp.path().join("config.json");
648 fs::write(&old_file, "{}").unwrap();
649
650 let ten_days_ago = SystemTime::now()
651 .checked_sub(std::time::Duration::from_secs(10 * 24 * 60 * 60))
652 .unwrap();
653 filetime::set_file_mtime(
654 &old_file,
655 filetime::FileTime::from_system_time(ten_days_ago),
656 )
657 .unwrap();
658
659 cleanup_old_logs(temp.path(), 7).unwrap();
661
662 assert!(old_file.exists());
663 }
664
665 #[test]
666 fn test_cleanup_old_logs_ignores_subdirectories() {
667 let temp = TempDir::new().unwrap();
668
669 let subdir = temp.path().join("archive.log.2020-01-01");
671 fs::create_dir(&subdir).unwrap();
672
673 let result = cleanup_old_logs(temp.path(), 7);
675 assert!(result.is_ok());
676 assert!(subdir.exists());
677 }
678}