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/// Initialize logging from environment variables
203pub 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
221/// Clean up old log files based on retention policy
222///
223/// Scans the log directory and removes files older than the specified retention period.
224/// Only removes files matching the pattern `.log.YYYY-MM-DD` (rotated log files).
225///
226/// # Arguments
227/// * `log_dir` - Directory containing log files
228/// * `retention_days` - Number of days to retain logs (default: 7)
229///
230/// # Example
231/// ```no_run
232/// use std::path::Path;
233/// use intent_engine::logging::cleanup_old_logs;
234///
235/// let log_dir = Path::new("/home/user/.intent-engine/logs");
236/// cleanup_old_logs(log_dir, 7).ok();
237/// ```
238pub 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(()); // Nothing to clean if directory doesn't exist
244    }
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        // Only process rotated log files (containing .log. followed by a date)
257        // Examples: dashboard.log.2025-11-22, mcp-server.log.2025-11-21
258        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/// Log macros for common intent-engine operations
300#[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/// Utility macro for structured error logging
379#[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/// Utility macro for structured warning logging
391#[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
401/// Get log file path for a given application mode
402pub 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    // Create log directory if it doesn't exist
407    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    // ========== LoggingConfig tests ==========
425
426    #[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); // MCP should be clean
445        assert!(config.show_timestamps);
446        assert!(config.show_target);
447        assert!(config.json_format); // Machine-readable
448        assert!(!config.enable_spans); // Avoid noise
449        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); // Background service
458        assert!(config.show_timestamps);
459        assert!(config.show_target);
460        assert!(!config.json_format);
461        assert!(config.enable_spans); // Good for debugging
462        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); // User-friendly
471        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); // Maximum detail
488        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); // Quiet mode disables color
508        assert!(!config.show_timestamps); // Quiet mode, no verbose
509        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); // JSON disables color
518        assert!(config.show_timestamps); // JSON enables timestamps
519        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    // ========== log_file_path tests ==========
534
535    #[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    // ========== cleanup_old_logs tests ==========
562
563    #[test]
564    fn test_cleanup_old_logs_nonexistent_dir() {
565        let temp = TempDir::new().unwrap();
566        let nonexistent = temp.path().join("nonexistent");
567
568        // Should not error on non-existent directory
569        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        // Create a current log file (not rotated)
586        let current_log = temp.path().join("dashboard.log");
587        fs::write(&current_log, "current log data").unwrap();
588
589        // Should not remove current log file (no .log.DATE pattern)
590        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        // Create an old rotated log file
600        let old_log = temp.path().join("dashboard.log.2020-01-01");
601        fs::write(&old_log, "old log data").unwrap();
602
603        // Set modification time to 10 days ago
604        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        // Clean up logs older than 7 days
611        cleanup_old_logs(temp.path(), 7).unwrap();
612
613        // Old file should be removed
614        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        // Create a recent rotated log file
622        let recent_log = temp.path().join("mcp-server.log.2025-11-25");
623        fs::write(&recent_log, "recent log data").unwrap();
624
625        // Set modification time to 3 days ago
626        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        // Clean up logs older than 7 days
636        cleanup_old_logs(temp.path(), 7).unwrap();
637
638        // Recent file should be kept
639        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        // Create a non-log file that's old
647        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        // Should not remove non-log files
660        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        // Create a subdirectory with log-like name
670        let subdir = temp.path().join("archive.log.2020-01-01");
671        fs::create_dir(&subdir).unwrap();
672
673        // Should not try to remove directories
674        let result = cleanup_old_logs(temp.path(), 7);
675        assert!(result.is_ok());
676        assert!(subdir.exists());
677    }
678}