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`] to enforce automatic rotation.
9//! On rotation, the current file is renamed with a timestamp suffix and
10//! a fresh file is opened at the original path.
11
12use crate::config::LogRotation;
13use std::fs::{self, File, OpenOptions};
14use std::io::{self, Write};
15use std::path::{Path, PathBuf};
16use std::time::Instant;
17
18/// File writer that enforces a [`LogRotation`] policy.
19#[derive(Debug)]
20pub struct RotatingFile {
21    /// Current open file handle.
22    file: File,
23    /// Path to the current log file.
24    path: PathBuf,
25    /// Rotation policy to enforce.
26    policy: LogRotation,
27    /// Bytes written to the current file.
28    bytes_written: u64,
29    /// Events written to the current file (for count-based rotation).
30    events_written: u32,
31    /// Time when the current file was opened (for time-based rotation).
32    opened_at: Instant,
33    /// Date string when the current file was opened (for date-based rotation).
34    opened_date: String,
35}
36
37impl RotatingFile {
38    /// Open (or create) a log file with the given rotation policy.
39    ///
40    /// # Errors
41    ///
42    /// Returns `io::Error` if the file cannot be opened or created.
43    pub fn open(path: &Path, policy: LogRotation) -> io::Result<Self> {
44        let file =
45            OpenOptions::new().create(true).append(true).open(path)?;
46        let bytes_written =
47            file.metadata().map(|m| m.len()).unwrap_or(0);
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}