1use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use tracing_subscriber::prelude::*;
11
12#[derive(Debug, Clone, PartialEq)]
18pub enum LogRotation {
19 Hourly,
21 Daily,
23 Size(u64),
25 Never,
27}
28
29#[derive(Debug, Clone)]
31pub struct LogRotationConfig {
32 pub log_dir: PathBuf,
34 pub file_prefix: String,
36 pub rotation: LogRotation,
38 pub max_files: usize,
40 pub also_stdout: bool,
42}
43
44pub struct LogGuard {
48 _guard: tracing_appender::non_blocking::WorkerGuard,
49}
50
51#[derive(Debug, thiserror::Error)]
53pub enum LogRotationError {
54 #[error("Failed to create log directory: {0}")]
55 DirectoryCreation(#[from] std::io::Error),
56 #[error("Failed to initialize logger: {0}")]
57 LoggerInit(String),
58}
59
60struct SizeRotatingWriter {
68 log_dir: PathBuf,
69 file_prefix: String,
70 threshold_bytes: u64,
71 max_files: usize,
72 current_file: std::fs::File,
73 current_path: PathBuf,
74 bytes_written: u64,
75}
76
77impl SizeRotatingWriter {
78 fn new(
79 log_dir: &Path,
80 file_prefix: &str,
81 threshold_bytes: u64,
82 max_files: usize,
83 ) -> std::io::Result<Self> {
84 std::fs::create_dir_all(log_dir)?;
85 let current_path = log_dir.join(file_prefix);
86 let current_file = std::fs::OpenOptions::new()
87 .create(true)
88 .append(true)
89 .open(¤t_path)?;
90 let bytes_written = current_file.metadata().map(|m| m.len()).unwrap_or(0);
91 Ok(Self {
92 log_dir: log_dir.to_owned(),
93 file_prefix: file_prefix.to_owned(),
94 threshold_bytes,
95 max_files,
96 current_file,
97 current_path,
98 bytes_written,
99 })
100 }
101
102 fn rotate(&mut self) -> std::io::Result<()> {
105 use std::time::{SystemTime, UNIX_EPOCH};
106
107 self.current_file.flush()?;
109
110 let ts = SystemTime::now()
112 .duration_since(UNIX_EPOCH)
113 .map(|d| d.as_nanos())
114 .unwrap_or(0);
115 let backup_name = format!("{}.{}", self.file_prefix, ts);
116 let backup_path = self.log_dir.join(&backup_name);
117 std::fs::rename(&self.current_path, &backup_path)?;
118
119 self.current_file = std::fs::OpenOptions::new()
121 .create(true)
122 .append(true)
123 .open(&self.current_path)?;
124 self.bytes_written = 0;
125
126 if self.max_files > 0 {
128 let _ = cleanup_old_logs(&self.log_dir, &self.file_prefix, self.max_files);
129 }
130
131 Ok(())
132 }
133}
134
135impl Write for SizeRotatingWriter {
136 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
137 let n = self.current_file.write(buf)?;
138 self.bytes_written += n as u64;
139 if self.bytes_written >= self.threshold_bytes {
140 if let Err(e) = self.rotate() {
144 eprintln!("[amaters-server] log rotation failed: {e}");
145 }
146 }
147 Ok(n)
148 }
149
150 fn flush(&mut self) -> std::io::Result<()> {
151 self.current_file.flush()
152 }
153}
154
155pub fn setup_rotating_logger(config: &LogRotationConfig) -> Result<LogGuard, LogRotationError> {
171 std::fs::create_dir_all(&config.log_dir)?;
172
173 match &config.rotation {
174 LogRotation::Size(threshold) => {
175 let writer = SizeRotatingWriter::new(
176 &config.log_dir,
177 &config.file_prefix,
178 *threshold,
179 config.max_files,
180 )?;
181 let (non_blocking, guard) = tracing_appender::non_blocking(writer);
182 let file_layer = tracing_subscriber::fmt::layer().with_writer(non_blocking);
183
184 let result = if config.also_stdout {
185 let stdout_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stdout);
186 tracing_subscriber::registry()
187 .with(file_layer)
188 .with(stdout_layer)
189 .try_init()
190 } else {
191 tracing_subscriber::registry().with(file_layer).try_init()
192 };
193
194 result.map_err(|e| LogRotationError::LoggerInit(e.to_string()))?;
195 Ok(LogGuard { _guard: guard })
196 }
197 _ => {
198 let appender = build_time_appender(config);
200 let (non_blocking, guard) = tracing_appender::non_blocking(appender);
201 let file_layer = tracing_subscriber::fmt::layer().with_writer(non_blocking);
202
203 let result = if config.also_stdout {
204 let stdout_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stdout);
205 tracing_subscriber::registry()
206 .with(file_layer)
207 .with(stdout_layer)
208 .try_init()
209 } else {
210 tracing_subscriber::registry().with(file_layer).try_init()
211 };
212
213 result.map_err(|e| LogRotationError::LoggerInit(e.to_string()))?;
214 Ok(LogGuard { _guard: guard })
215 }
216 }
217}
218
219pub fn cleanup_old_logs(dir: &Path, prefix: &str, max_files: usize) -> std::io::Result<usize> {
226 if max_files == 0 {
227 return Ok(0);
228 }
229
230 let mut entries: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir)?
232 .filter_map(|entry| {
233 let entry = entry.ok()?;
234 let name = entry.file_name();
235 let name_str = name.to_string_lossy();
236 if !name_str.starts_with(prefix) {
237 return None;
238 }
239 let meta = entry.metadata().ok()?;
240 if !meta.is_file() {
241 return None;
242 }
243 let modified = meta.modified().ok()?;
244 Some((modified, entry.path()))
245 })
246 .collect();
247
248 if entries.len() <= max_files {
249 return Ok(0);
250 }
251
252 entries.sort_by_key(|(t, _)| *t);
254
255 let to_delete = entries.len() - max_files;
256 let mut deleted = 0usize;
257 for (_, path) in entries.into_iter().take(to_delete) {
258 std::fs::remove_file(&path)?;
259 deleted += 1;
260 }
261 Ok(deleted)
262}
263
264fn build_time_appender(
269 config: &LogRotationConfig,
270) -> tracing_appender::rolling::RollingFileAppender {
271 match config.rotation {
272 LogRotation::Hourly => {
273 tracing_appender::rolling::hourly(&config.log_dir, &config.file_prefix)
274 }
275 LogRotation::Daily => {
276 tracing_appender::rolling::daily(&config.log_dir, &config.file_prefix)
277 }
278 _ => tracing_appender::rolling::never(&config.log_dir, &config.file_prefix),
280 }
281}
282
283#[cfg(test)]
288mod tests {
289 use super::*;
290 use std::fs::File;
291 use std::io::Write;
292
293 #[test]
294 fn test_log_rotation_config_default() {
295 let config = LogRotationConfig {
296 log_dir: PathBuf::from("/var/log/amaters"),
297 file_prefix: "amaters-server".to_string(),
298 rotation: LogRotation::Daily,
299 max_files: 7,
300 also_stdout: false,
301 };
302 assert_eq!(config.file_prefix, "amaters-server");
303 assert_eq!(config.max_files, 7);
304 assert_eq!(config.rotation, LogRotation::Daily);
305 assert!(!config.also_stdout);
306 }
307
308 #[test]
309 fn test_log_rotation_enum() {
310 assert_eq!(LogRotation::Hourly, LogRotation::Hourly);
311 assert_eq!(LogRotation::Daily, LogRotation::Daily);
312 assert_eq!(LogRotation::Never, LogRotation::Never);
313 assert_ne!(LogRotation::Hourly, LogRotation::Daily);
314 assert_ne!(LogRotation::Daily, LogRotation::Never);
315 assert_eq!(LogRotation::Size(1024), LogRotation::Size(1024));
316 assert_ne!(LogRotation::Size(512), LogRotation::Size(1024));
317 }
318
319 fn make_temp_log_files(dir: &Path, prefix: &str, count: usize) -> std::io::Result<()> {
320 for i in 0..count {
321 let path = dir.join(format!("{}.{:04}", prefix, i));
322 File::create(&path)?;
323 }
324 Ok(())
325 }
326
327 #[test]
328 fn test_cleanup_old_logs_under_limit() {
329 let base = std::env::temp_dir().join("amaters_cleanup_under");
330 std::fs::create_dir_all(&base).expect("create temp dir");
331 make_temp_log_files(&base, "test-server", 3).expect("create files");
332
333 let deleted = cleanup_old_logs(&base, "test-server", 5).expect("cleanup");
334 assert_eq!(deleted, 0);
335
336 let _ = std::fs::remove_dir_all(&base);
337 }
338
339 #[test]
340 fn test_cleanup_old_logs_over_limit() {
341 let base = std::env::temp_dir().join("amaters_cleanup_over");
342 std::fs::create_dir_all(&base).expect("create temp dir");
343 make_temp_log_files(&base, "test-server", 5).expect("create files");
344
345 let deleted = cleanup_old_logs(&base, "test-server", 3).expect("cleanup");
346 assert_eq!(deleted, 2);
347
348 let remaining = std::fs::read_dir(&base)
350 .expect("read dir")
351 .filter_map(|e| {
352 let e = e.ok()?;
353 let name = e.file_name();
354 if name.to_string_lossy().starts_with("test-server") {
355 Some(())
356 } else {
357 None
358 }
359 })
360 .count();
361 assert_eq!(remaining, 3);
362
363 let _ = std::fs::remove_dir_all(&base);
364 }
365
366 #[test]
369 fn test_log_rotation_size_triggers() {
370 let base = std::env::temp_dir().join(format!(
371 "amaters_size_rotate_{}",
372 std::time::SystemTime::now()
373 .duration_since(std::time::UNIX_EPOCH)
374 .map(|d| d.as_nanos())
375 .unwrap_or(0)
376 ));
377 std::fs::create_dir_all(&base).expect("create temp dir");
378
379 let prefix = "logtest";
380 let threshold: u64 = 100;
382 let mut writer =
383 SizeRotatingWriter::new(&base, prefix, threshold, 10).expect("create writer");
384
385 let payload_a = vec![b'A'; 90];
387 writer.write_all(&payload_a).expect("write A");
388
389 let files_before_rotation: Vec<_> = std::fs::read_dir(&base)
390 .expect("read dir")
391 .filter_map(|e| e.ok())
392 .collect();
393 assert_eq!(
395 files_before_rotation.len(),
396 1,
397 "expected exactly 1 file before rotation"
398 );
399
400 let payload_b = vec![b'B'; 20];
402 writer.write_all(&payload_b).expect("write B");
403
404 let files_after_rotation: Vec<_> = std::fs::read_dir(&base)
407 .expect("read dir")
408 .filter_map(|e| e.ok())
409 .filter(|e| e.file_name().to_string_lossy().starts_with(prefix))
410 .collect();
411 assert!(
412 files_after_rotation.len() >= 2,
413 "expected at least 2 files after rotation, got {}",
414 files_after_rotation.len()
415 );
416
417 let _ = std::fs::remove_dir_all(&base);
418 }
419}