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;
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 && 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/// 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}