Skip to main content

rlg/
rotation.rs

1// rotation.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! Log rotation policies: size, time, date, and count-based.
7//!
8//! Wrap a file sink with [`RotatingFile`][crate::rotation::RotatingFile]
9//! to enforce automatic rotation.
10//! On rotation, the current file is renamed with a timestamp suffix and
11//! a fresh file is opened at the original path.
12
13use crate::config::LogRotation;
14use std::fs::{self, File, OpenOptions};
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::time::Instant;
18
19/// File writer that enforces a [`LogRotation`] policy.
20#[derive(Debug)]
21pub struct RotatingFile {
22    /// Current open file handle.
23    file: File,
24    /// Path to the current log file.
25    path: PathBuf,
26    /// Rotation policy to enforce.
27    policy: LogRotation,
28    /// Bytes written to the current file.
29    bytes_written: u64,
30    /// Events written to the current file (for count-based rotation).
31    events_written: u32,
32    /// Time when the current file was opened (for time-based rotation).
33    opened_at: Instant,
34    /// Date string when the current file was opened (for date-based rotation).
35    opened_date: String,
36}
37
38impl RotatingFile {
39    /// Open (or create) a log file with the given rotation policy.
40    ///
41    /// # Errors
42    ///
43    /// Returns `io::Error` if the file cannot be opened or created.
44    pub fn open(path: &Path, policy: LogRotation) -> io::Result<Self> {
45        let file =
46            OpenOptions::new().create(true).append(true).open(path)?;
47        let bytes_written = file.metadata().map_or(0, |m| m.len());
48        Ok(Self {
49            file,
50            path: path.to_path_buf(),
51            policy,
52            bytes_written,
53            events_written: 0,
54            opened_at: Instant::now(),
55            opened_date: today_date_string(),
56        })
57    }
58
59    /// Write a batch of bytes, then rotate if the policy threshold is met.
60    ///
61    /// # Errors
62    ///
63    /// Returns `io::Error` if the write or file rotation fails.
64    pub fn write_batch(
65        &mut self,
66        data: &[u8],
67        event_count: u32,
68    ) -> io::Result<()> {
69        self.file.write_all(data)?;
70        self.bytes_written += data.len() as u64;
71        self.events_written += event_count;
72
73        if self.should_rotate() {
74            self.rotate()?;
75        }
76        Ok(())
77    }
78
79    /// Checks whether the current file should be rotated.
80    fn should_rotate(&self) -> bool {
81        match self.policy {
82            LogRotation::Size(max_bytes) => {
83                self.bytes_written >= max_bytes.get()
84            }
85            LogRotation::Time(seconds) => {
86                self.opened_at.elapsed().as_secs() >= seconds.get()
87            }
88            LogRotation::Date => {
89                today_date_string() != self.opened_date
90            }
91            LogRotation::Count(max_events) => {
92                self.events_written >= max_events
93            }
94        }
95    }
96
97    /// Rotates the current file by renaming it with a timestamp suffix
98    /// and opening a new file at the original path.
99    fn rotate(&mut self) -> io::Result<()> {
100        // Flush and drop the current file handle.
101        self.file.flush()?;
102
103        // Build the rotated file name.
104        let timestamp = chrono_like_timestamp();
105        let rotated_name = if let Some(ext) = self.path.extension() {
106            let stem = self.path.with_extension("");
107            PathBuf::from(format!(
108                "{}.{timestamp}.{}",
109                stem.display(),
110                ext.to_string_lossy()
111            ))
112        } else {
113            PathBuf::from(format!(
114                "{}.{timestamp}",
115                self.path.display()
116            ))
117        };
118
119        fs::rename(&self.path, &rotated_name)?;
120
121        // Open a new file at the original path.
122        self.file = OpenOptions::new()
123            .create(true)
124            .append(true)
125            .open(&self.path)?;
126        self.bytes_written = 0;
127        self.events_written = 0;
128        self.opened_at = Instant::now();
129        self.opened_date = today_date_string();
130
131        Ok(())
132    }
133}
134
135/// Returns today's date as `YYYY-MM-DD`.
136fn today_date_string() -> String {
137    let now = std::time::SystemTime::now()
138        .duration_since(std::time::UNIX_EPOCH)
139        .unwrap_or_default();
140    let secs = now.as_secs();
141    // Simple date calculation (no leap-second precision needed for rotation).
142    let days = secs / 86400;
143    let (year, month, day) = days_to_ymd(days);
144    format!("{year:04}-{month:02}-{day:02}")
145}
146
147/// Returns a compact timestamp for rotated file names: `YYYYMMDD-HHMMSS`.
148fn chrono_like_timestamp() -> String {
149    let now = std::time::SystemTime::now()
150        .duration_since(std::time::UNIX_EPOCH)
151        .unwrap_or_default();
152    let secs = now.as_secs();
153    let days = secs / 86400;
154    let (year, month, day) = days_to_ymd(days);
155    let day_secs = secs % 86400;
156    let h = day_secs / 3600;
157    let m = (day_secs % 3600) / 60;
158    let s = day_secs % 60;
159    format!("{year:04}{month:02}{day:02}-{h:02}{m:02}{s:02}")
160}
161
162/// Converts days since Unix epoch to (year, month, day).
163const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
164    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
165    let z = days + 719_468;
166    let era = z / 146_097;
167    let doe = z - era * 146_097;
168    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
169    let y = yoe + era * 400;
170    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
171    let mp = (5 * doy + 2) / 153;
172    let d = doy - (153 * mp + 2) / 5 + 1;
173    let m = if mp < 10 { mp + 3 } else { mp - 9 };
174    let y = if m <= 2 { y + 1 } else { y };
175    (y, m, d)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::num::NonZeroU64;
182
183    #[test]
184    fn test_today_date_string_format() {
185        let date = today_date_string();
186        // YYYY-MM-DD
187        assert_eq!(date.len(), 10);
188        assert_eq!(&date[4..5], "-");
189        assert_eq!(&date[7..8], "-");
190    }
191
192    #[test]
193    fn test_chrono_like_timestamp_format() {
194        let ts = chrono_like_timestamp();
195        // YYYYMMDD-HHMMSS
196        assert_eq!(ts.len(), 15);
197        assert_eq!(&ts[8..9], "-");
198    }
199
200    #[test]
201    fn test_days_to_ymd_epoch() {
202        let (y, m, d) = days_to_ymd(0);
203        assert_eq!((y, m, d), (1970, 1, 1));
204    }
205
206    #[test]
207    fn test_rotating_file_size_based() {
208        let dir = tempfile::tempdir().unwrap();
209        let path = dir.path().join("test.log");
210        let policy = LogRotation::Size(NonZeroU64::new(100).unwrap());
211        let mut rf = RotatingFile::open(&path, policy).unwrap();
212        // Write 50 bytes — no rotation
213        rf.write_batch(&[b'A'; 50], 1).unwrap();
214        assert!(path.exists());
215        // Write 60 more bytes — triggers rotation
216        rf.write_batch(&[b'B'; 60], 1).unwrap();
217        // Original path should still exist (new file)
218        assert!(path.exists());
219        // There should be a rotated file
220        let entries: Vec<_> = fs::read_dir(dir.path())
221            .unwrap()
222            .filter_map(Result::ok)
223            .collect();
224        assert!(entries.len() >= 2, "expected rotated file");
225    }
226
227    #[test]
228    fn test_rotating_file_count_based() {
229        let dir = tempfile::tempdir().unwrap();
230        let path = dir.path().join("count.log");
231        let policy = LogRotation::Count(3);
232        let mut rf = RotatingFile::open(&path, policy).unwrap();
233        rf.write_batch(b"event1\n", 1).unwrap();
234        rf.write_batch(b"event2\n", 1).unwrap();
235        rf.write_batch(b"event3\n", 1).unwrap(); // triggers rotation
236        let entries: Vec<_> = fs::read_dir(dir.path())
237            .unwrap()
238            .filter_map(Result::ok)
239            .collect();
240        assert!(entries.len() >= 2, "expected rotated file");
241    }
242
243    #[test]
244    fn test_rotating_file_no_extension() {
245        // Drives the `else` branch in `rotate()` that handles a path
246        // without an extension (lines 112-114 in coverage report).
247        let dir = tempfile::tempdir().unwrap();
248        let path = dir.path().join("rawlogfile");
249        let policy = LogRotation::Size(NonZeroU64::new(10).unwrap());
250        let mut rf = RotatingFile::open(&path, policy).unwrap();
251        rf.write_batch(b"0123456789X", 1).unwrap(); // > 10 bytes → rotate
252        let entries: Vec<_> = fs::read_dir(dir.path())
253            .unwrap()
254            .filter_map(Result::ok)
255            .collect();
256        assert!(entries.len() >= 2);
257        // The rotated name must start with the original stem.
258        let names: Vec<_> = entries
259            .iter()
260            .map(|e| e.file_name().to_string_lossy().into_owned())
261            .collect();
262        assert!(
263            names.iter().any(|n| n.starts_with("rawlogfile.")),
264            "no rotated file found in {names:?}"
265        );
266    }
267
268    #[test]
269    fn test_rotating_file_time_based_does_not_trigger_immediately() {
270        // Exercises the Time policy branch in `should_rotate()`.
271        let dir = tempfile::tempdir().unwrap();
272        let path = dir.path().join("time.log");
273        let policy = LogRotation::Time(NonZeroU64::new(3600).unwrap());
274        let mut rf = RotatingFile::open(&path, policy).unwrap();
275        rf.write_batch(b"data\n", 1).unwrap();
276        // Only the current file should exist; 3600s hasn't elapsed.
277        let entries: Vec<_> = fs::read_dir(dir.path())
278            .unwrap()
279            .filter_map(Result::ok)
280            .collect();
281        assert_eq!(entries.len(), 1);
282    }
283
284    #[test]
285    fn test_rotating_file_date_based_does_not_trigger_immediately() {
286        // Exercises the Date policy branch in `should_rotate()`.
287        let dir = tempfile::tempdir().unwrap();
288        let path = dir.path().join("date.log");
289        let mut rf =
290            RotatingFile::open(&path, LogRotation::Date).unwrap();
291        rf.write_batch(b"data\n", 1).unwrap();
292        let entries: Vec<_> = fs::read_dir(dir.path())
293            .unwrap()
294            .filter_map(Result::ok)
295            .collect();
296        assert_eq!(entries.len(), 1);
297    }
298
299    #[test]
300    fn test_rotating_file_open_existing_picks_up_size() {
301        // Pre-create a file with known content, then `open()` it.
302        // Verifies `bytes_written` is populated from existing metadata.
303        let dir = tempfile::tempdir().unwrap();
304        let path = dir.path().join("existing.log");
305        fs::write(&path, b"already here\n").unwrap();
306        let policy = LogRotation::Size(NonZeroU64::new(5).unwrap());
307        let mut rf = RotatingFile::open(&path, policy).unwrap();
308        // First write should trigger rotation because preexisting bytes
309        // already exceed the threshold.
310        rf.write_batch(b"x", 1).unwrap();
311        let entries: Vec<_> = fs::read_dir(dir.path())
312            .unwrap()
313            .filter_map(Result::ok)
314            .collect();
315        assert!(entries.len() >= 2);
316    }
317}