intent_engine/
logging.rs

1//! Intent-Engine Logging System
2//!
3//! Provides structured logging with configurable levels and output formats.
4//! Uses tracing crate for structured logging with spans and events.
5
6use 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/// Logging configuration options
16#[derive(Debug, Clone)]
17pub struct LoggingConfig {
18    /// Minimum log level to output
19    pub level: Level,
20    /// Enable colored output
21    pub color: bool,
22    /// Show timestamps
23    pub show_timestamps: bool,
24    /// Show target/module name
25    pub show_target: bool,
26    /// Enable JSON format for machine parsing
27    pub json_format: bool,
28    /// Enable span events for tracing
29    pub enable_spans: bool,
30    /// Output to file instead of stdout (for daemon mode)
31    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    /// Create config for different application modes
50    pub fn for_mode(mode: ApplicationMode) -> Self {
51        match mode {
52            ApplicationMode::McpServer => Self {
53                level: Level::DEBUG,
54                color: false, // MCP output should be clean
55                show_timestamps: true,
56                show_target: true,
57                json_format: true,   // Machine-readable for MCP
58                enable_spans: false, // Avoid noise in JSON-RPC
59                file_output: None,
60            },
61            ApplicationMode::Dashboard => Self {
62                level: Level::INFO,
63                color: false, // Background service
64                show_timestamps: true,
65                show_target: true,
66                json_format: false,
67                enable_spans: true, // Good for debugging dashboard
68                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    /// Create config from CLI arguments
92    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/// Application modes with different logging requirements
114#[derive(Debug, Clone, Copy)]
115pub enum ApplicationMode {
116    /// MCP server mode - clean, structured output
117    McpServer,
118    /// Dashboard server mode - detailed for debugging
119    Dashboard,
120    /// CLI mode - user-friendly output
121    Cli,
122    /// Test mode - maximum detail for testing
123    Test,
124}
125
126/// Initialize the logging system
127///
128/// Note: For production use on Linux/Unix, consider using `logrotate` for log rotation.
129/// See `docs/deployment/logrotate.conf` for configuration example.
130/// The built-in daily rotation is provided as a fallback for Windows or simple deployments.
131pub 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        // Create log directory if it doesn't exist
147        std::fs::create_dir_all(log_dir)?;
148
149        // Use daily rotation (recommended to configure logrotate on Linux)
150        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
202/// Clean up old log files based on retention policy
203///
204/// Scans the log directory and removes files older than the specified retention period.
205/// Only removes files matching the pattern `.log.YYYY-MM-DD` (rotated log files).
206///
207/// # Arguments
208/// * `log_dir` - Directory containing log files
209/// * `retention_days` - Number of days to retain logs (default: 7)
210///
211/// # Example
212/// ```no_run
213/// use std::path::Path;
214/// use intent_engine::logging::cleanup_old_logs;
215///
216/// let log_dir = Path::new("/home/user/.intent-engine/logs");
217/// cleanup_old_logs(log_dir, 7).ok();
218/// ```
219pub 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(()); // Nothing to clean if directory doesn't exist
225    }
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        // Only process rotated log files (containing .log. followed by a date)
238        // Examples: dashboard.log.2025-11-22, mcp-server.log.2025-11-21
239        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
280/// Get log file path for a given application mode
281pub 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    // Create log directory if it doesn't exist
286    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    // ========== LoggingConfig tests ==========
304
305    #[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); // MCP should be clean
324        assert!(config.show_timestamps);
325        assert!(config.show_target);
326        assert!(config.json_format); // Machine-readable
327        assert!(!config.enable_spans); // Avoid noise
328        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); // Background service
337        assert!(config.show_timestamps);
338        assert!(config.show_target);
339        assert!(!config.json_format);
340        assert!(config.enable_spans); // Good for debugging
341        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); // User-friendly
350        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); // Maximum detail
367        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); // Quiet mode disables color
387        assert!(!config.show_timestamps); // Quiet mode, no verbose
388        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); // JSON disables color
397        assert!(config.show_timestamps); // JSON enables timestamps
398        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    // ========== log_file_path tests ==========
413
414    #[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    // ========== cleanup_old_logs tests ==========
441
442    #[test]
443    fn test_cleanup_old_logs_nonexistent_dir() {
444        let temp = TempDir::new().unwrap();
445        let nonexistent = temp.path().join("nonexistent");
446
447        // Should not error on non-existent directory
448        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        // Create a current log file (not rotated)
465        let current_log = temp.path().join("dashboard.log");
466        fs::write(&current_log, "current log data").unwrap();
467
468        // Should not remove current log file (no .log.DATE pattern)
469        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        // Create an old rotated log file
479        let old_log = temp.path().join("dashboard.log.2020-01-01");
480        fs::write(&old_log, "old log data").unwrap();
481
482        // Set modification time to 10 days ago
483        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        // Clean up logs older than 7 days
490        cleanup_old_logs(temp.path(), 7).unwrap();
491
492        // Old file should be removed
493        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        // Create a recent rotated log file
501        let recent_log = temp.path().join("mcp-server.log.2025-11-25");
502        fs::write(&recent_log, "recent log data").unwrap();
503
504        // Set modification time to 3 days ago
505        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        // Clean up logs older than 7 days
515        cleanup_old_logs(temp.path(), 7).unwrap();
516
517        // Recent file should be kept
518        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        // Create a non-log file that's old
526        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        // Should not remove non-log files
539        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        // Create a subdirectory with log-like name
549        let subdir = temp.path().join("archive.log.2020-01-01");
550        fs::create_dir(&subdir).unwrap();
551
552        // Should not try to remove directories
553        let result = cleanup_old_logs(temp.path(), 7);
554        assert!(result.is_ok());
555        assert!(subdir.exists());
556    }
557}