Skip to main content

codesearch/logger/
mod.rs

1//! Logging module for codesearch
2//!
3//! Provides centralized logging configuration with:
4//! - Daily log file rotation (via tracing-appender)
5//! - Periodic cleanup of old log files (by age and count)
6//! - Per-database log storage in .codesearch.db/logs/
7//! - Configurable via environment variables
8//!
9//! Daily rotation creates files named `codesearch.log.YYYY-MM-DD`.
10//! Cleanup removes files older than `retention_days` and enforces `max_files`.
11
12use anyhow::Result;
13use chrono::{NaiveDate, Utc};
14use std::fs;
15use std::path::{Path, PathBuf};
16use tokio_util::sync::CancellationToken;
17use tracing_appender::rolling::{RollingFileAppender, Rotation};
18use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
19
20use crate::constants::{
21    DEFAULT_LOG_MAX_FILES, DEFAULT_LOG_RETENTION_DAYS, LOG_DIR_NAME, LOG_FILE_NAME,
22};
23
24/// Result of logger initialization, indicating whether file logging is active
25#[derive(Debug)]
26pub enum LoggerInitResult {
27    /// File logging successfully initialized (with optional console output)
28    FileLogging,
29    /// Subscriber already set, only console logging active (fallback)
30    ConsoleOnly,
31}
32
33/// Log level configuration
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum LogLevel {
36    Error,
37    Warn,
38    Info,
39    Debug,
40    Trace,
41}
42
43impl LogLevel {
44    /// Parse from string (case-insensitive)
45    pub fn parse(s: &str) -> Option<Self> {
46        match s.to_lowercase().as_str() {
47            "error" => Some(LogLevel::Error),
48            "warn" | "warning" => Some(LogLevel::Warn),
49            "info" => Some(LogLevel::Info),
50            "debug" => Some(LogLevel::Debug),
51            "trace" => Some(LogLevel::Trace),
52            _ => None,
53        }
54    }
55
56    /// Convert to string
57    pub fn as_str(&self) -> &'static str {
58        match self {
59            LogLevel::Error => "error",
60            LogLevel::Warn => "warn",
61            LogLevel::Info => "info",
62            LogLevel::Debug => "debug",
63            LogLevel::Trace => "trace",
64        }
65    }
66}
67
68/// Log rotation configuration
69#[derive(Debug, Clone)]
70pub struct LogRotationConfig {
71    /// Maximum number of log files to retain
72    pub max_files: usize,
73    /// Number of days to retain log files
74    pub retention_days: i64,
75}
76
77impl LogRotationConfig {
78    /// Load configuration from environment variables
79    pub fn from_env() -> Self {
80        Self {
81            max_files: std::env::var("CODESEARCH_LOG_MAX_FILES")
82                .ok()
83                .and_then(|s| s.parse().ok())
84                .unwrap_or(DEFAULT_LOG_MAX_FILES),
85            retention_days: std::env::var("CODESEARCH_LOG_RETENTION_DAYS")
86                .ok()
87                .and_then(|s| s.parse().ok())
88                .unwrap_or(DEFAULT_LOG_RETENTION_DAYS as i64),
89        }
90    }
91}
92
93/// Get the log directory path for a given database path
94pub fn get_log_dir(db_path: &Path) -> PathBuf {
95    db_path.join(LOG_DIR_NAME)
96}
97
98/// Ensure the log directory exists
99pub fn ensure_log_dir(log_dir: &Path) -> Result<()> {
100    if !log_dir.exists() {
101        fs::create_dir_all(log_dir)?;
102        tracing::debug!("Created log directory: {:?}", log_dir);
103    }
104    Ok(())
105}
106
107/// Try to extract a date from a daily-rotated log filename.
108///
109/// tracing-appender DAILY rotation produces files named `<prefix>.YYYY-MM-DD`.
110/// Returns `None` if the filename doesn't match the expected pattern.
111fn parse_log_date(file_name: &str) -> Option<NaiveDate> {
112    // Pattern: "codesearch.log.YYYY-MM-DD"
113    let suffix = file_name.strip_prefix(&format!("{}.", LOG_FILE_NAME))?;
114    NaiveDate::parse_from_str(suffix, "%Y-%m-%d").ok()
115}
116
117/// Remove old log files based on retention period and max file count.
118///
119/// Two independent criteria:
120/// 1. Files older than `retention_days` are always removed.
121/// 2. If more than `max_files` remain, the oldest are removed.
122pub fn cleanup_old_logs(log_dir: &Path, config: &LogRotationConfig) -> Result<()> {
123    if !log_dir.exists() {
124        return Ok(());
125    }
126
127    let today = Utc::now().date_naive();
128
129    // Collect dated log files: (date, path)
130    let mut dated_files: Vec<(NaiveDate, PathBuf)> = Vec::new();
131
132    for entry in fs::read_dir(log_dir)? {
133        let entry = entry?;
134        let path = entry.path();
135
136        if !path.is_file() {
137            continue;
138        }
139
140        if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
141            if let Some(date) = parse_log_date(file_name) {
142                dated_files.push((date, path));
143            }
144        }
145    }
146
147    // Sort by date, oldest first
148    dated_files.sort_by_key(|(date, _)| *date);
149
150    let mut removed_count = 0u32;
151
152    // Pass 1: remove files older than retention_days
153    dated_files.retain(|(date, path)| {
154        let age_days = (today - *date).num_days();
155        if age_days > config.retention_days {
156            if let Err(e) = fs::remove_file(path) {
157                tracing::warn!("Failed to remove old log file {:?}: {}", path, e);
158            } else {
159                tracing::debug!("Removed old log file {:?} (age: {} days)", path, age_days);
160                removed_count += 1;
161            }
162            false // remove from list
163        } else {
164            true // keep in list
165        }
166    });
167
168    // Pass 2: enforce max_files (remove oldest beyond the limit)
169    if dated_files.len() > config.max_files {
170        let excess = dated_files.len() - config.max_files;
171        for (_, path) in dated_files.iter().take(excess) {
172            if let Err(e) = fs::remove_file(path) {
173                tracing::warn!("Failed to remove excess log file {:?}: {}", path, e);
174            } else {
175                tracing::debug!("Removed excess log file {:?}", path);
176                removed_count += 1;
177            }
178        }
179    }
180
181    if removed_count > 0 {
182        tracing::info!(
183            "Log cleanup: removed {} file(s) (retention={}d, max_files={})",
184            removed_count,
185            config.retention_days,
186            config.max_files
187        );
188    }
189
190    Ok(())
191}
192
193/// Initialize the logger with file rotation and optional console output.
194///
195/// # Arguments
196/// * `db_path` - Path to the database directory (logs stored in `db_path/logs/`)
197/// * `log_level` - Log level to use
198/// * `quiet` - If true, suppress console output (log only to file)
199///
200/// # Returns
201/// Returns `LoggerInitResult` indicating whether file logging is active:
202/// - `FileLogging`: File logging successfully initialized
203/// - `ConsoleOnly`: Subscriber already set, fallback to console-only
204///
205/// Uses `try_init()` so it won't panic if a subscriber is already set
206/// (e.g. early console-only subscriber from main.rs).
207pub fn init_logger(db_path: &Path, log_level: LogLevel, quiet: bool) -> Result<LoggerInitResult> {
208    let log_dir = get_log_dir(db_path);
209    ensure_log_dir(&log_dir)?;
210
211    let config = LogRotationConfig::from_env();
212
213    // Create file appender with DAILY rotation.
214    // Produces files like: logs/codesearch.log.2026-02-09
215    let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, LOG_FILE_NAME);
216
217    // Build EnvFilter with per-crate directives.
218    // Specific crate directives override the default level.
219    let filter_str = format!(
220        "{level},tantivy=warn,arroy=warn,ort=warn,h2=warn,hyper=warn,tower=warn",
221        level = log_level.as_str()
222    );
223    let env_filter =
224        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&filter_str));
225
226    let subscriber = tracing_subscriber::registry().with(env_filter);
227
228    if quiet {
229        // File-only logging (MCP mode: keep stdout clean for JSON-RPC)
230        let result = subscriber
231            .with(
232                fmt::layer()
233                    .with_writer(file_appender)
234                    .with_ansi(false)
235                    .with_target(true)
236                    .with_thread_ids(false),
237            )
238            .try_init();
239
240        if let Err(e) = result {
241            eprintln!(
242                "Logger: subscriber already set ({}), file logging not active",
243                e
244            );
245            return Ok(LoggerInitResult::ConsoleOnly);
246        }
247    } else {
248        // Console (stderr) + file logging
249        let result = subscriber
250            .with(
251                fmt::layer()
252                    .with_writer(std::io::stderr)
253                    .with_ansi(true)
254                    .with_target(true)
255                    .with_thread_ids(false),
256            )
257            .with(
258                fmt::layer()
259                    .with_writer(file_appender)
260                    .with_ansi(false)
261                    .with_target(true)
262                    .with_thread_ids(false),
263            )
264            .try_init();
265
266        if let Err(e) = result {
267            eprintln!(
268                "Logger: subscriber already set ({}), file logging not active",
269                e
270            );
271            return Ok(LoggerInitResult::ConsoleOnly);
272        }
273    }
274
275    tracing::info!(
276        "Logger initialized: level={}, log_dir={:?}, max_files={}, retention_days={}",
277        log_level.as_str(),
278        log_dir,
279        config.max_files,
280        config.retention_days,
281    );
282
283    Ok(LoggerInitResult::FileLogging)
284}
285
286/// Start periodic log cleanup task.
287///
288/// Runs every `CODESEARCH_LOG_CLEANUP_INTERVAL_HOURS` hours (default: 24)
289/// and removes old log files based on retention_days and max_files.
290pub fn start_cleanup_task(
291    log_dir: PathBuf,
292    config: LogRotationConfig,
293    cancel_token: CancellationToken,
294) -> tokio::task::JoinHandle<()> {
295    tokio::spawn(async move {
296        let cleanup_interval_hours: u64 = std::env::var("CODESEARCH_LOG_CLEANUP_INTERVAL_HOURS")
297            .ok()
298            .and_then(|s| s.parse().ok())
299            .unwrap_or(24);
300
301        let interval = std::time::Duration::from_secs(cleanup_interval_hours * 3600);
302
303        tracing::info!(
304            "Log cleanup task started: interval={}h, retention_days={}, max_files={}",
305            cleanup_interval_hours,
306            config.retention_days,
307            config.max_files,
308        );
309
310        loop {
311            tokio::select! {
312                _ = tokio::time::sleep(interval) => {
313                    if let Err(e) = cleanup_old_logs(&log_dir, &config) {
314                        tracing::error!("Failed to cleanup old logs: {}", e);
315                    }
316                }
317                _ = cancel_token.cancelled() => {
318                    tracing::info!("Log cleanup task stopped");
319                    break;
320                }
321            }
322        }
323    })
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::fs::File;
330    use std::io::Write;
331    use tempfile::TempDir;
332
333    #[test]
334    fn test_log_level_parse() {
335        assert_eq!(LogLevel::parse("error"), Some(LogLevel::Error));
336        assert_eq!(LogLevel::parse("ERROR"), Some(LogLevel::Error));
337        assert_eq!(LogLevel::parse("warn"), Some(LogLevel::Warn));
338        assert_eq!(LogLevel::parse("warning"), Some(LogLevel::Warn));
339        assert_eq!(LogLevel::parse("info"), Some(LogLevel::Info));
340        assert_eq!(LogLevel::parse("debug"), Some(LogLevel::Debug));
341        assert_eq!(LogLevel::parse("trace"), Some(LogLevel::Trace));
342        assert_eq!(LogLevel::parse("invalid"), None);
343    }
344
345    #[test]
346    fn test_log_level_as_str() {
347        assert_eq!(LogLevel::Error.as_str(), "error");
348        assert_eq!(LogLevel::Warn.as_str(), "warn");
349        assert_eq!(LogLevel::Info.as_str(), "info");
350        assert_eq!(LogLevel::Debug.as_str(), "debug");
351        assert_eq!(LogLevel::Trace.as_str(), "trace");
352    }
353
354    #[test]
355    fn test_log_rotation_config_from_env() {
356        let config = LogRotationConfig::from_env();
357        assert!(config.max_files > 0);
358        assert!(config.retention_days > 0);
359    }
360
361    #[test]
362    fn test_get_log_dir() {
363        let db_path = PathBuf::from("/test/db");
364        let log_dir = get_log_dir(&db_path);
365        assert_eq!(log_dir, PathBuf::from("/test/db/logs"));
366    }
367
368    #[test]
369    fn test_parse_log_date() {
370        assert_eq!(
371            parse_log_date("codesearch.log.2026-02-09"),
372            Some(NaiveDate::from_ymd_opt(2026, 2, 9).unwrap())
373        );
374        assert_eq!(parse_log_date("codesearch.log"), None);
375        assert_eq!(parse_log_date("codesearch.log.1"), None);
376        assert_eq!(parse_log_date("other.log.2026-02-09"), None);
377    }
378
379    #[test]
380    fn test_cleanup_old_logs_by_retention() {
381        let temp_dir = TempDir::new().unwrap();
382        let log_dir = temp_dir.path();
383
384        // Create a "recent" log file (today)
385        let today = Utc::now().date_naive();
386        let recent_name = format!("{}.{}", LOG_FILE_NAME, today.format("%Y-%m-%d"));
387        let recent_path = log_dir.join(&recent_name);
388        let mut f = File::create(&recent_path).unwrap();
389        write!(f, "recent log").unwrap();
390
391        // Create an "old" log file (10 days ago)
392        let old_date = today - chrono::Duration::days(10);
393        let old_name = format!("{}.{}", LOG_FILE_NAME, old_date.format("%Y-%m-%d"));
394        let old_path = log_dir.join(&old_name);
395        let mut f = File::create(&old_path).unwrap();
396        write!(f, "old log").unwrap();
397
398        let config = LogRotationConfig {
399            max_files: 100, // high limit so only retention matters
400            retention_days: 5,
401        };
402
403        cleanup_old_logs(log_dir, &config).unwrap();
404
405        // Recent file should still exist
406        assert!(recent_path.exists(), "Recent log file should be retained");
407        // Old file should be removed
408        assert!(!old_path.exists(), "Old log file should be removed");
409    }
410
411    #[test]
412    fn test_cleanup_old_logs_by_max_files() {
413        let temp_dir = TempDir::new().unwrap();
414        let log_dir = temp_dir.path();
415
416        let today = Utc::now().date_naive();
417
418        // Create 5 log files (today, yesterday, ...)
419        let mut paths = Vec::new();
420        for i in 0..5 {
421            let date = today - chrono::Duration::days(i);
422            let name = format!("{}.{}", LOG_FILE_NAME, date.format("%Y-%m-%d"));
423            let path = log_dir.join(&name);
424            let mut f = File::create(&path).unwrap();
425            write!(f, "log day {}", i).unwrap();
426            paths.push(path);
427        }
428
429        let config = LogRotationConfig {
430            max_files: 3,
431            retention_days: 30, // high limit so only max_files matters
432        };
433
434        cleanup_old_logs(log_dir, &config).unwrap();
435
436        // 3 most recent should remain
437        assert!(paths[0].exists(), "Today's log should remain");
438        assert!(paths[1].exists(), "Yesterday's log should remain");
439        assert!(paths[2].exists(), "2 days ago log should remain");
440        // 2 oldest should be removed
441        assert!(!paths[3].exists(), "3 days ago log should be removed");
442        assert!(!paths[4].exists(), "4 days ago log should be removed");
443    }
444
445    #[test]
446    fn test_cleanup_empty_dir() {
447        let temp_dir = TempDir::new().unwrap();
448        let config = LogRotationConfig {
449            max_files: 5,
450            retention_days: 5,
451        };
452        // Should not error on empty directory
453        assert!(cleanup_old_logs(temp_dir.path(), &config).is_ok());
454    }
455
456    #[test]
457    fn test_cleanup_nonexistent_dir() {
458        let config = LogRotationConfig {
459            max_files: 5,
460            retention_days: 5,
461        };
462        // Should not error on non-existent directory
463        assert!(cleanup_old_logs(Path::new("/nonexistent/path"), &config).is_ok());
464    }
465}