oxigaf_cli/
log_rotation.rs1use 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#[derive(Debug, Clone)]
17pub struct LogConfig {
18 pub file_path: Option<PathBuf>,
20 pub rotation: LogRotation,
22 pub max_files: usize,
24 pub format: LogFormat,
26}
27
28#[derive(Debug, Clone, Copy)]
30pub enum LogRotation {
31 Never,
33 Hourly,
35 Daily,
37 #[allow(dead_code)]
42 Size(u64),
43}
44
45#[derive(Debug, Clone, Copy)]
47pub enum LogFormat {
48 Json,
50 Pretty,
52 Compact,
54}
55
56impl Default for LogConfig {
57 fn default() -> Self {
58 Self {
59 file_path: None,
60 rotation: LogRotation::Size(10 * 1024 * 1024), max_files: 5,
62 format: LogFormat::Json,
63 }
64 }
65}
66
67pub 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 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 let file_appender = create_appender(log_path, log_config.rotation)?;
99
100 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 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 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 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 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
157fn 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 RollingFileAppender::new(Rotation::DAILY, dir, filename)
184 }
185 };
186
187 Ok(appender)
188}
189
190pub 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 log_files.sort_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
225
226 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 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}