Skip to main content

arcbox_logging/
rotating.rs

1//! Size-based log file rotation.
2//!
3//! Rotates `daemon.log` → `daemon.log.1` → `daemon.log.2` → … when the
4//! current file exceeds `max_file_size`. Oldest files beyond `max_files`
5//! are deleted.
6
7use std::fs::{self, File, OpenOptions};
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10
11use parking_lot::Mutex;
12
13/// A `Write` implementation that rotates the underlying file when it
14/// exceeds `max_size` bytes. Thread-safe via internal mutex.
15pub struct SizeRotatingWriter {
16    inner: Mutex<RotatingState>,
17}
18
19struct RotatingState {
20    path: PathBuf,
21    max_size: u64,
22    max_files: usize,
23    file: File,
24    current_size: u64,
25}
26
27impl SizeRotatingWriter {
28    /// Create a new rotating writer.
29    ///
30    /// Opens (or creates) the file at `path` in append mode. The file is
31    /// rotated when it exceeds `max_size` bytes.
32    ///
33    /// # Panics
34    ///
35    /// Panics if `max_size` is 0 or `max_files` is 0.
36    pub fn new(path: PathBuf, max_size: u64, max_files: usize) -> Self {
37        assert!(max_size > 0, "max_size must be > 0");
38        assert!(max_files > 0, "max_files must be > 0");
39
40        let (file, current_size) = open_log_file(&path);
41        Self {
42            inner: Mutex::new(RotatingState {
43                path,
44                max_size,
45                max_files,
46                file,
47                current_size,
48            }),
49        }
50    }
51}
52
53impl Write for SizeRotatingWriter {
54    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
55        let mut state = self.inner.lock();
56        write_and_maybe_rotate(&mut state, buf)
57    }
58
59    fn flush(&mut self) -> io::Result<()> {
60        let mut state = self.inner.lock();
61        state.file.flush()
62    }
63}
64
65/// `tracing_appender::non_blocking` calls `Write` through a `&` reference,
66/// so we need this impl for the non-blocking wrapper to work.
67impl Write for &SizeRotatingWriter {
68    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
69        let mut state = self.inner.lock();
70        write_and_maybe_rotate(&mut state, buf)
71    }
72
73    fn flush(&mut self) -> io::Result<()> {
74        let mut state = self.inner.lock();
75        state.file.flush()
76    }
77}
78
79fn write_and_maybe_rotate(state: &mut RotatingState, buf: &[u8]) -> io::Result<usize> {
80    // Check if rotation is needed before writing.
81    if state.current_size > 0 && state.current_size + buf.len() as u64 > state.max_size {
82        rotate(state);
83    }
84
85    let written = state.file.write(buf)?;
86    state.current_size += written as u64;
87    Ok(written)
88}
89
90/// Rotate files: app.log → app.log.1 → app.log.2 → …
91/// Delete app.log.{max_files} if it exists.
92fn rotate(state: &mut RotatingState) {
93    // Flush current file before rotation.
94    let _ = state.file.flush();
95
96    // Delete the oldest rotated file first, then shift the rest up.
97    let oldest = rotated_path(&state.path, state.max_files);
98    if let Err(e) = fs::remove_file(&oldest) {
99        if e.kind() != io::ErrorKind::NotFound {
100            eprintln!("log rotate: failed to remove {}: {e}", oldest.display());
101        }
102    }
103
104    // Shift existing rotated files: .{n-1} → .{n}, ..., .1 → .2
105    for i in (1..state.max_files).rev() {
106        let from = rotated_path(&state.path, i);
107        let to = rotated_path(&state.path, i + 1);
108        if let Err(e) = fs::rename(&from, &to) {
109            if e.kind() != io::ErrorKind::NotFound {
110                eprintln!(
111                    "log rotate: failed to rename {} → {}: {e}",
112                    from.display(),
113                    to.display()
114                );
115            }
116        }
117    }
118
119    // Move current log to .1
120    let rotated = rotated_path(&state.path, 1);
121    if let Err(e) = fs::rename(&state.path, &rotated) {
122        eprintln!(
123            "log rotate: failed to rename {} → {}: {e}",
124            state.path.display(),
125            rotated.display()
126        );
127        // Rotation failed — continue writing to the same file.
128        return;
129    }
130
131    // Open a fresh file.
132    let (file, size) = open_log_file(&state.path);
133    state.file = file;
134    state.current_size = size;
135}
136
137fn rotated_path(base: &Path, index: usize) -> PathBuf {
138    let mut p = base.as_os_str().to_os_string();
139    p.push(format!(".{index}"));
140    PathBuf::from(p)
141}
142
143fn open_log_file(path: &Path) -> (File, u64) {
144    let file = OpenOptions::new()
145        .create(true)
146        .append(true)
147        .open(path)
148        .unwrap_or_else(|e| panic!("failed to open log file {}: {e}", path.display()));
149
150    let size = file.metadata().map_or(0, |m| m.len());
151    (file, size)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::io::Write;
158
159    #[test]
160    fn rotation_creates_numbered_files() {
161        let dir = tempfile::tempdir().unwrap();
162        let log_path = dir.path().join("test.log");
163
164        // 100 bytes max, 3 files max.
165        let mut writer = SizeRotatingWriter::new(log_path.clone(), 100, 3);
166
167        // Write 120 bytes → should stay in test.log (rotation happens before next write).
168        let data = vec![b'A'; 60];
169        writer.write_all(&data).unwrap();
170        writer.write_all(&data).unwrap();
171        assert!(log_path.exists());
172
173        // Write more → triggers rotation.
174        writer.write_all(&data).unwrap();
175        assert!(log_path.exists());
176        assert!(dir.path().join("test.log.1").exists());
177    }
178
179    #[test]
180    fn oldest_file_is_deleted() {
181        let dir = tempfile::tempdir().unwrap();
182        let log_path = dir.path().join("test.log");
183
184        // max_files=3: keep .1, .2, .3 — never .4
185        let mut writer = SizeRotatingWriter::new(log_path.clone(), 50, 3);
186
187        let data = vec![b'X'; 60];
188
189        // Trigger enough rotations to overflow max_files.
190        for _ in 0..6 {
191            writer.write_all(&data).unwrap();
192        }
193
194        assert!(log_path.exists());
195        assert!(dir.path().join("test.log.1").exists());
196        assert!(dir.path().join("test.log.2").exists());
197        assert!(dir.path().join("test.log.3").exists());
198        // .4 should not exist (max_files=3).
199        assert!(!dir.path().join("test.log.4").exists());
200    }
201}