1use std::io::{self, IsTerminal};
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 && std::io::stdout().is_terminal(),
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 cleanup_old_logs(log_dir: &std::path::Path, retention_days: u32) -> io::Result<()> {
220 use std::fs;
221 use std::time::SystemTime;
222
223 if !log_dir.exists() {
224 return Ok(()); }
226
227 let now = SystemTime::now();
228 let retention_duration = std::time::Duration::from_secs(retention_days as u64 * 24 * 60 * 60);
229
230 let mut cleaned_count = 0;
231 let mut cleaned_size: u64 = 0;
232
233 for entry in fs::read_dir(log_dir)? {
234 let entry = entry?;
235 let path = entry.path();
236
237 let path_str = path.to_string_lossy();
240 if !path_str.contains(".log.") || !path.is_file() {
241 continue;
242 }
243
244 let metadata = entry.metadata()?;
245 let modified = metadata.modified()?;
246
247 if let Ok(age) = now.duration_since(modified) {
248 if age > retention_duration {
249 let size = metadata.len();
250 match fs::remove_file(&path) {
251 Ok(_) => {
252 cleaned_count += 1;
253 cleaned_size += size;
254 tracing::info!(
255 "Cleaned up old log file: {} (age: {} days, size: {} bytes)",
256 path.display(),
257 age.as_secs() / 86400,
258 size
259 );
260 },
261 Err(e) => {
262 tracing::warn!(path = %path.display(), error = %e, "Failed to remove old log file");
263 },
264 }
265 }
266 }
267 }
268
269 if cleaned_count > 0 {
270 tracing::info!(
271 "Log cleanup completed: removed {} files, freed {} bytes",
272 cleaned_count,
273 cleaned_size
274 );
275 }
276
277 Ok(())
278}
279
280pub fn log_file_path(mode: ApplicationMode) -> std::path::PathBuf {
282 let home = dirs::home_dir().expect("Failed to get home directory");
283 let log_dir = home.join(".intent-engine").join("logs");
284
285 std::fs::create_dir_all(&log_dir).ok();
287
288 match mode {
289 ApplicationMode::Dashboard => log_dir.join("dashboard.log"),
290 ApplicationMode::McpServer => log_dir.join("mcp-server.log"),
291 ApplicationMode::Cli => log_dir.join("cli.log"),
292 ApplicationMode::Test => log_dir.join("test.log"),
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::fs;
300 use std::time::SystemTime;
301 use tempfile::TempDir;
302
303 #[test]
306 fn test_logging_config_default() {
307 let config = LoggingConfig::default();
308
309 assert_eq!(config.level, Level::INFO);
310 assert!(config.color);
311 assert!(!config.show_timestamps);
312 assert!(!config.show_target);
313 assert!(!config.json_format);
314 assert!(!config.enable_spans);
315 assert!(config.file_output.is_none());
316 }
317
318 #[test]
319 fn test_logging_config_for_mode_mcp_server() {
320 let config = LoggingConfig::for_mode(ApplicationMode::McpServer);
321
322 assert_eq!(config.level, Level::DEBUG);
323 assert!(!config.color); assert!(config.show_timestamps);
325 assert!(config.show_target);
326 assert!(config.json_format); assert!(!config.enable_spans); assert!(config.file_output.is_none());
329 }
330
331 #[test]
332 fn test_logging_config_for_mode_dashboard() {
333 let config = LoggingConfig::for_mode(ApplicationMode::Dashboard);
334
335 assert_eq!(config.level, Level::INFO);
336 assert!(!config.color); assert!(config.show_timestamps);
338 assert!(config.show_target);
339 assert!(!config.json_format);
340 assert!(config.enable_spans); assert!(config.file_output.is_none());
342 }
343
344 #[test]
345 fn test_logging_config_for_mode_cli() {
346 let config = LoggingConfig::for_mode(ApplicationMode::Cli);
347
348 assert_eq!(config.level, Level::INFO);
349 assert!(config.color); assert!(!config.show_timestamps);
351 assert!(!config.show_target);
352 assert!(!config.json_format);
353 assert!(!config.enable_spans);
354 assert!(config.file_output.is_none());
355 }
356
357 #[test]
358 fn test_logging_config_for_mode_test() {
359 let config = LoggingConfig::for_mode(ApplicationMode::Test);
360
361 assert_eq!(config.level, Level::DEBUG);
362 assert!(!config.color);
363 assert!(config.show_timestamps);
364 assert!(config.show_target);
365 assert!(!config.json_format);
366 assert!(config.enable_spans); assert!(config.file_output.is_none());
368 }
369
370 #[test]
371 fn test_logging_config_from_args_verbose() {
372 let config = LoggingConfig::from_args(false, true, false);
373
374 assert_eq!(config.level, Level::DEBUG);
375 assert!(config.show_timestamps);
376 assert!(config.show_target);
377 assert!(!config.json_format);
378 assert!(config.enable_spans);
379 }
380
381 #[test]
382 fn test_logging_config_from_args_quiet() {
383 let config = LoggingConfig::from_args(true, false, false);
384
385 assert_eq!(config.level, Level::ERROR);
386 assert!(!config.color); assert!(!config.show_timestamps); assert!(!config.show_target);
389 }
390
391 #[test]
392 fn test_logging_config_from_args_json() {
393 let config = LoggingConfig::from_args(false, false, true);
394
395 assert_eq!(config.level, Level::INFO);
396 assert!(!config.color); assert!(config.show_timestamps); assert!(config.json_format);
399 }
400
401 #[test]
402 fn test_logging_config_from_args_normal() {
403 let config = LoggingConfig::from_args(false, false, false);
404
405 assert_eq!(config.level, Level::INFO);
406 assert!(!config.show_timestamps);
407 assert!(!config.show_target);
408 assert!(!config.json_format);
409 assert!(!config.enable_spans);
410 }
411
412 #[test]
415 fn test_log_file_path_dashboard() {
416 let path = log_file_path(ApplicationMode::Dashboard);
417 assert!(path.to_string_lossy().ends_with("dashboard.log"));
418 assert!(path.to_string_lossy().contains(".intent-engine"));
419 assert!(path.to_string_lossy().contains("logs"));
420 }
421
422 #[test]
423 fn test_log_file_path_mcp_server() {
424 let path = log_file_path(ApplicationMode::McpServer);
425 assert!(path.to_string_lossy().ends_with("mcp-server.log"));
426 }
427
428 #[test]
429 fn test_log_file_path_cli() {
430 let path = log_file_path(ApplicationMode::Cli);
431 assert!(path.to_string_lossy().ends_with("cli.log"));
432 }
433
434 #[test]
435 fn test_log_file_path_test() {
436 let path = log_file_path(ApplicationMode::Test);
437 assert!(path.to_string_lossy().ends_with("test.log"));
438 }
439
440 #[test]
443 fn test_cleanup_old_logs_nonexistent_dir() {
444 let temp = TempDir::new().unwrap();
445 let nonexistent = temp.path().join("nonexistent");
446
447 let result = cleanup_old_logs(&nonexistent, 7);
449 assert!(result.is_ok());
450 }
451
452 #[test]
453 fn test_cleanup_old_logs_empty_dir() {
454 let temp = TempDir::new().unwrap();
455
456 let result = cleanup_old_logs(temp.path(), 7);
457 assert!(result.is_ok());
458 }
459
460 #[test]
461 fn test_cleanup_old_logs_keeps_current_logs() {
462 let temp = TempDir::new().unwrap();
463
464 let current_log = temp.path().join("dashboard.log");
466 fs::write(¤t_log, "current log data").unwrap();
467
468 cleanup_old_logs(temp.path(), 0).unwrap();
470
471 assert!(current_log.exists());
472 }
473
474 #[test]
475 fn test_cleanup_old_logs_removes_old_rotated_files() {
476 let temp = TempDir::new().unwrap();
477
478 let old_log = temp.path().join("dashboard.log.2020-01-01");
480 fs::write(&old_log, "old log data").unwrap();
481
482 let ten_days_ago = SystemTime::now()
484 .checked_sub(std::time::Duration::from_secs(10 * 24 * 60 * 60))
485 .unwrap();
486 filetime::set_file_mtime(&old_log, filetime::FileTime::from_system_time(ten_days_ago))
487 .unwrap();
488
489 cleanup_old_logs(temp.path(), 7).unwrap();
491
492 assert!(!old_log.exists());
494 }
495
496 #[test]
497 fn test_cleanup_old_logs_keeps_recent_rotated_files() {
498 let temp = TempDir::new().unwrap();
499
500 let recent_log = temp.path().join("mcp-server.log.2025-11-25");
502 fs::write(&recent_log, "recent log data").unwrap();
503
504 let three_days_ago = SystemTime::now()
506 .checked_sub(std::time::Duration::from_secs(3 * 24 * 60 * 60))
507 .unwrap();
508 filetime::set_file_mtime(
509 &recent_log,
510 filetime::FileTime::from_system_time(three_days_ago),
511 )
512 .unwrap();
513
514 cleanup_old_logs(temp.path(), 7).unwrap();
516
517 assert!(recent_log.exists());
519 }
520
521 #[test]
522 fn test_cleanup_old_logs_ignores_non_log_files() {
523 let temp = TempDir::new().unwrap();
524
525 let old_file = temp.path().join("config.json");
527 fs::write(&old_file, "{}").unwrap();
528
529 let ten_days_ago = SystemTime::now()
530 .checked_sub(std::time::Duration::from_secs(10 * 24 * 60 * 60))
531 .unwrap();
532 filetime::set_file_mtime(
533 &old_file,
534 filetime::FileTime::from_system_time(ten_days_ago),
535 )
536 .unwrap();
537
538 cleanup_old_logs(temp.path(), 7).unwrap();
540
541 assert!(old_file.exists());
542 }
543
544 #[test]
545 fn test_cleanup_old_logs_ignores_subdirectories() {
546 let temp = TempDir::new().unwrap();
547
548 let subdir = temp.path().join("archive.log.2020-01-01");
550 fs::create_dir(&subdir).unwrap();
551
552 let result = cleanup_old_logs(temp.path(), 7);
554 assert!(result.is_ok());
555 assert!(subdir.exists());
556 }
557}