Skip to main content

amaters_server/
log_rotation.rs

1//! Log rotation support for the AmateRS server.
2//!
3//! Provides configurable file-based log rotation supporting both time-based
4//! (hourly/daily via `tracing-appender`) and size-based rotation (custom
5//! writer that rotates when a file exceeds a byte threshold).
6
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use tracing_subscriber::prelude::*;
11
12// ---------------------------------------------------------------------------
13// Public types
14// ---------------------------------------------------------------------------
15
16/// Rotation policy for the log writer.
17#[derive(Debug, Clone, PartialEq)]
18pub enum LogRotation {
19    /// Rotate every hour (delegates to `tracing_appender`).
20    Hourly,
21    /// Rotate every day (delegates to `tracing_appender`).
22    Daily,
23    /// Rotate when the log file exceeds `u64` bytes.
24    Size(u64),
25    /// Never rotate (useful for testing / short-lived processes).
26    Never,
27}
28
29/// Full configuration for log rotation.
30#[derive(Debug, Clone)]
31pub struct LogRotationConfig {
32    /// Directory where log files are written.
33    pub log_dir: PathBuf,
34    /// Base filename prefix (e.g. `"amaters-server"`).
35    pub file_prefix: String,
36    /// Rotation policy.
37    pub rotation: LogRotation,
38    /// Maximum number of log files to retain (`0` = unlimited).
39    pub max_files: usize,
40    /// Whether to also write to stdout.
41    pub also_stdout: bool,
42}
43
44/// RAII guard that keeps the background writer thread alive.
45///
46/// Drop this value only when you are ready to flush and stop logging.
47pub struct LogGuard {
48    _guard: tracing_appender::non_blocking::WorkerGuard,
49}
50
51/// Errors produced by this module.
52#[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
60// ---------------------------------------------------------------------------
61// Size-based rotating writer
62// ---------------------------------------------------------------------------
63
64/// A writer that rotates the underlying log file when it exceeds a byte
65/// threshold.  After each rotation the oldest files are pruned so that at
66/// most `max_files` remain (0 = unlimited).
67struct 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(&current_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    /// Rotate: rename the current file to a timestamped backup, open a fresh
103    /// file at `current_path`, and clean up old backups if needed.
104    fn rotate(&mut self) -> std::io::Result<()> {
105        use std::time::{SystemTime, UNIX_EPOCH};
106
107        // Flush before renaming.
108        self.current_file.flush()?;
109
110        // Build a backup name using wall-clock nanos to guarantee uniqueness.
111        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        // Open a fresh file.
120        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        // Prune old files if requested.
127        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            // Best-effort rotation — log a warning on failure but don't
141            // surface the error to the caller (tracing internals expect
142            // writes to succeed).
143            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
155// ---------------------------------------------------------------------------
156// Public API
157// ---------------------------------------------------------------------------
158
159/// Set up a global [`tracing`] subscriber that writes to a rotating log file.
160///
161/// For time-based rotation ([`LogRotation::Hourly`] / [`LogRotation::Daily`] /
162/// [`LogRotation::Never`]) this delegates to `tracing_appender`'s rolling
163/// appenders.  For [`LogRotation::Size`] a custom byte-counting writer is used
164/// instead.
165///
166/// # Errors
167/// Returns [`LogRotationError::DirectoryCreation`] if the log directory cannot
168/// be created, or [`LogRotationError::LoggerInit`] if a global subscriber is
169/// already installed.
170pub 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            // Time-based or Never — delegate to tracing_appender.
199            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
219/// Scan `dir` for files whose name starts with `prefix`, sort by modification
220/// time (oldest first), and delete the excess so that at most `max_files`
221/// remain.
222///
223/// Returns the number of files deleted.  If `max_files == 0`, returns `0`
224/// without deleting anything.
225pub 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    // Collect matching entries with their modification times.
231    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    // Oldest first.
253    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
264// ---------------------------------------------------------------------------
265// Private helpers
266// ---------------------------------------------------------------------------
267
268fn 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        // Never or Size — Size is handled by the caller; Never uses `never()`.
279        _ => tracing_appender::rolling::never(&config.log_dir, &config.file_prefix),
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Tests
285// ---------------------------------------------------------------------------
286
287#[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        // Verify exactly 3 remain.
349        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    /// Verify that `SizeRotatingWriter` triggers a rotation once the threshold
367    /// is exceeded.
368    #[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        // Threshold: 100 bytes.
381        let threshold: u64 = 100;
382        let mut writer =
383            SizeRotatingWriter::new(&base, prefix, threshold, 10).expect("create writer");
384
385        // Write 90 bytes — should NOT yet rotate.
386        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        // Exactly 1 file at this point (the active log).
394        assert_eq!(
395            files_before_rotation.len(),
396            1,
397            "expected exactly 1 file before rotation"
398        );
399
400        // Write 20 more bytes — pushes total to 110, exceeding threshold.
401        let payload_b = vec![b'B'; 20];
402        writer.write_all(&payload_b).expect("write B");
403
404        // After the write that triggered rotation there should be 2 files:
405        // the backup (renamed) and the new active file.
406        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}