Skip to main content

oxigaf_cli/
log_rotation.rs

1//! Log file rotation and structured logging.
2//!
3//! Provides structured logging to files with rotation support.
4//! Supports JSON Lines format, timestamps, log levels, and automatic
5//! cleanup of old log files.
6
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use tracing_appender::rolling::{RollingFileAppender, Rotation};
11use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer};
12
13use crate::verbosity::Verbosity;
14
15/// Configuration for log file output and rotation.
16#[derive(Debug, Clone)]
17pub struct LogConfig {
18    /// Optional path to the log file.
19    pub file_path: Option<PathBuf>,
20    /// Log rotation strategy.
21    pub rotation: LogRotation,
22    /// Maximum number of log files to keep.
23    pub max_files: usize,
24    /// Log format for file output.
25    pub format: LogFormat,
26}
27
28/// Log rotation strategies.
29#[derive(Debug, Clone, Copy)]
30pub enum LogRotation {
31    /// Never rotate (single file).
32    Never,
33    /// Rotate hourly.
34    Hourly,
35    /// Rotate daily.
36    Daily,
37    /// Rotate by size (bytes).
38    ///
39    /// Note: tracing-appender doesn't support size-based rotation directly,
40    /// so this is approximated with daily rotation.
41    #[allow(dead_code)]
42    Size(u64),
43}
44
45/// Log output format.
46#[derive(Debug, Clone, Copy)]
47pub enum LogFormat {
48    /// JSON Lines format (recommended for parsing).
49    Json,
50    /// Pretty-printed format (human-readable).
51    Pretty,
52    /// Compact format (minimal whitespace).
53    Compact,
54}
55
56impl Default for LogConfig {
57    fn default() -> Self {
58        Self {
59            file_path: None,
60            rotation: LogRotation::Size(10 * 1024 * 1024), // 10MB
61            max_files: 5,
62            format: LogFormat::Json,
63        }
64    }
65}
66
67/// Initialize logging with optional file output and rotation.
68///
69/// Sets up dual logging: file (if specified) and stdout.
70/// File logs use the specified format without ANSI colors.
71/// Console logs use standard format with colors.
72///
73/// # Arguments
74///
75/// * `log_config` - Configuration for log file and rotation
76/// * `verbosity` - Verbosity level from CLI flags
77///
78/// # Errors
79///
80/// Returns error if:
81/// - Log directory cannot be created
82/// - File appender cannot be initialized
83/// - Subscriber cannot be set as global default
84pub fn init_logging_with_file(log_config: LogConfig, verbosity: Verbosity) -> Result<()> {
85    use tracing_subscriber::filter::LevelFilter;
86
87    let filter = LevelFilter::from_level(verbosity.tracing_level());
88    let env_filter = EnvFilter::from_default_env().add_directive(filter.into());
89
90    if let Some(ref log_path) = log_config.file_path {
91        // Ensure parent directory exists
92        if let Some(parent) = log_path.parent() {
93            std::fs::create_dir_all(parent)
94                .with_context(|| format!("Failed to create log directory: {}", parent.display()))?;
95        }
96
97        // Create file appender with rotation
98        let file_appender = create_appender(log_path, log_config.rotation)?;
99
100        // Create file layer based on format
101        let file_layer = match log_config.format {
102            LogFormat::Json => fmt::layer()
103                .json()
104                .with_writer(file_appender)
105                .with_ansi(false)
106                .with_filter(env_filter.clone())
107                .boxed(),
108            LogFormat::Pretty => fmt::layer()
109                .pretty()
110                .with_writer(file_appender)
111                .with_ansi(false)
112                .with_filter(env_filter.clone())
113                .boxed(),
114            LogFormat::Compact => fmt::layer()
115                .compact()
116                .with_writer(file_appender)
117                .with_ansi(false)
118                .with_filter(env_filter.clone())
119                .boxed(),
120        };
121
122        // Create stdout layer
123        let stdout_layer = fmt::layer()
124            .with_target(verbosity >= Verbosity::Debug)
125            .with_file(verbosity >= Verbosity::Debug)
126            .with_line_number(verbosity >= Verbosity::Debug)
127            .with_filter(env_filter);
128
129        // Combine layers
130        tracing_subscriber::registry()
131            .with(file_layer)
132            .with(stdout_layer)
133            .try_init()
134            .map_err(|e| anyhow::anyhow!("Failed to initialize tracing subscriber: {}", e))?;
135
136        // Log rotation metadata
137        tracing::info!(
138            log_file = %log_path.display(),
139            rotation = ?log_config.rotation,
140            max_files = log_config.max_files,
141            "Logging to file with rotation"
142        );
143    } else {
144        // Console-only logging
145        tracing_subscriber::fmt()
146            .with_env_filter(env_filter)
147            .with_target(verbosity >= Verbosity::Debug)
148            .with_file(verbosity >= Verbosity::Debug)
149            .with_line_number(verbosity >= Verbosity::Debug)
150            .try_init()
151            .map_err(|e| anyhow::anyhow!("Failed to initialize tracing subscriber: {}", e))?;
152    }
153
154    Ok(())
155}
156
157/// Create a rolling file appender based on rotation strategy.
158///
159/// # Arguments
160///
161/// * `log_path` - Full path to the log file
162/// * `rotation` - Rotation strategy to use
163///
164/// # Errors
165///
166/// Returns error if the log path is invalid or the appender cannot be created.
167fn create_appender(log_path: &Path, rotation: LogRotation) -> Result<RollingFileAppender> {
168    let dir = log_path
169        .parent()
170        .ok_or_else(|| anyhow::anyhow!("Invalid log path: no parent directory"))?;
171
172    let filename = log_path
173        .file_name()
174        .and_then(|n| n.to_str())
175        .ok_or_else(|| anyhow::anyhow!("Invalid log filename"))?;
176
177    let appender = match rotation {
178        LogRotation::Never => RollingFileAppender::new(Rotation::NEVER, dir, filename),
179        LogRotation::Hourly => RollingFileAppender::new(Rotation::HOURLY, dir, filename),
180        LogRotation::Daily | LogRotation::Size(_) => {
181            // tracing-appender doesn't support size-based rotation directly
182            // Use daily rotation as approximation
183            RollingFileAppender::new(Rotation::DAILY, dir, filename)
184        }
185    };
186
187    Ok(appender)
188}
189
190/// Clean up old log files, keeping only the most recent max_files.
191///
192/// Sorts log files by modification time and removes the oldest ones
193/// if the total count exceeds max_files.
194///
195/// # Arguments
196///
197/// * `log_dir` - Directory containing log files
198/// * `prefix` - Filename prefix to match (e.g., "oxigaf" matches "oxigaf.log", "oxigaf.2026-01-01.log", etc.)
199/// * `max_files` - Maximum number of log files to keep
200///
201/// # Errors
202///
203/// Returns error if:
204/// - Directory cannot be read
205/// - File metadata cannot be accessed
206/// - Files cannot be removed
207pub fn cleanup_old_logs(log_dir: &Path, prefix: &str, max_files: usize) -> Result<()> {
208    if !log_dir.exists() {
209        return Ok(());
210    }
211
212    let mut log_files: Vec<_> = std::fs::read_dir(log_dir)
213        .with_context(|| format!("Failed to read log directory: {}", log_dir.display()))?
214        .filter_map(|e| e.ok())
215        .filter(|e| {
216            e.file_name()
217                .to_str()
218                .map(|n| n.starts_with(prefix) && (n.ends_with(".log") || n.contains(".log.")))
219                .unwrap_or(false)
220        })
221        .collect();
222
223    // Sort by modification time (oldest first)
224    log_files.sort_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
225
226    // Remove oldest files if we have more than max_files
227    if log_files.len() > max_files {
228        let to_remove = log_files.len() - max_files;
229        for entry in log_files.iter().take(to_remove) {
230            let path = entry.path();
231            std::fs::remove_file(&path)
232                .with_context(|| format!("Failed to remove old log file: {}", path.display()))?;
233            tracing::debug!(removed_log = %path.display(), "Removed old log file");
234        }
235        tracing::info!(
236            removed_count = to_remove,
237            max_files = max_files,
238            "Cleaned up old log files"
239        );
240    }
241
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_log_config_default() {
251        let config = LogConfig::default();
252        assert!(config.file_path.is_none());
253        assert_eq!(config.max_files, 5);
254        assert!(matches!(config.format, LogFormat::Json));
255        assert!(matches!(config.rotation, LogRotation::Size(10485760)));
256    }
257
258    #[test]
259    fn test_cleanup_old_logs_nonexistent_dir() {
260        // Should not error on nonexistent directory
261        let tmpdir = tempfile::tempdir().expect("create temp dir");
262        let nonexistent = tmpdir.path().join("nonexistent_subdir_12345");
263        let result = cleanup_old_logs(&nonexistent, "test", 5);
264        assert!(result.is_ok());
265    }
266}