Skip to main content

log_io/sink/
rotating.rs

1//! Size- and time-based rotating file writer.
2
3use std::ffi::OsString;
4use std::fs::{self, File, OpenOptions};
5use std::io::{self, BufWriter, Write};
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9/// Time-based rotation schedule for [`RotatingFile`].
10///
11/// Periods are aligned to UTC: `Daily` rotates at 00:00 UTC, `Hourly`
12/// at the top of each UTC hour.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14#[non_exhaustive]
15pub enum Rotation {
16    /// Never rotate on time; only `max_bytes` (if set) triggers
17    /// rotation.
18    #[default]
19    Never,
20    /// Rotate at the start of every UTC minute. Mostly useful for
21    /// testing a rotation setup.
22    Minutely,
23    /// Rotate at the start of every UTC hour.
24    Hourly,
25    /// Rotate at 00:00 UTC every day.
26    Daily,
27}
28
29impl Rotation {
30    const fn period_secs(self) -> Option<u64> {
31        match self {
32            Self::Never => None,
33            Self::Minutely => Some(60),
34            Self::Hourly => Some(3_600),
35            Self::Daily => Some(86_400),
36        }
37    }
38}
39
40/// A file writer that rotates by size and/or time.
41///
42/// The active file is always at the configured path. On rotation it is
43/// renamed to `<path>.1`, an existing `<path>.1` becomes `<path>.2`,
44/// and so on; the file that would move past `<path>.<max_files>` is
45/// deleted. With `max_files(0)` the active file is deleted at rotation
46/// instead of kept.
47///
48/// Rotation happens between records, never inside one: the size check
49/// runs before each `write` call, and the built-in sinks write a whole
50/// record per call. A record larger than `max_bytes` is written to a
51/// fresh file on its own.
52///
53/// Use it as the writer of a [`super::WriterSink`] (aliased as
54/// [`super::RotatingFileSink`]) or of a [`super::NonBlocking`] sink.
55/// Behind a `NonBlocking` sink, records reach this writer in batches
56/// through the worker's write buffer (64 KiB by default), so a file can
57/// exceed `max_bytes` by up to that buffer size and period boundaries
58/// are honoured per batch; set the worker's `buffer_capacity(0)` for
59/// per-record precision.
60///
61/// # Failed rotation
62///
63/// The current file handle is flushed and closed before any rename, so
64/// rotation works on Windows as well as Unix. A rotation is all or
65/// nothing:
66///
67/// 1. the active file is moved to `<path>.rotating`. If that fails (on
68///    Windows, typically because another process holds the file open
69///    without delete sharing), nothing else has been touched;
70/// 2. the history is shifted up one slot, the oldest file going to
71///    `<path>.pruning` rather than being deleted;
72/// 3. `<path>.rotating` becomes `<path>.1`, and only then is
73///    `<path>.pruning` deleted.
74///
75/// If a rename in steps 2-3 fails, the renames already done are undone
76/// in reverse order. So a failed rotation never deletes or overwrites
77/// rotated history: every rotated file keeps its name and content. The
78/// writer then reopens the active file, keeps appending to it, retries
79/// after another `max_bytes` or at the next period, and reports the
80/// error from the next [`Write::flush`] call (which the logger passes
81/// to its error handler).
82///
83/// If an undo fails as well (a second fault, for example the directory
84/// becoming read-only mid-rotation), the rollback stops and the files
85/// keep whatever names they have at that point; still nothing is
86/// deleted. Records that were in the active file stay in
87/// `<path>.rotating`, the writer starts a new active file, and every
88/// later rotation attempt fails with [`io::ErrorKind::AlreadyExists`]
89/// naming that file (so it is never overwritten) until an operator
90/// moves it aside.
91///
92/// # Examples
93///
94#[cfg_attr(all(feature = "json"), doc = "```no_run")]
95#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
96/// use log_io::format::JsonFormat;
97/// use log_io::sink::{RotatingFile, Rotation};
98/// use log_io::{Level, Logger};
99///
100/// let file = RotatingFile::builder("logs/app.jsonl")
101///     .max_bytes(50 * 1024 * 1024)
102///     .rotation(Rotation::Daily)
103///     .max_files(14)
104///     .open()?;
105/// let logger = Logger::builder().level(Level::Info).writer(file, JsonFormat::new()).build();
106/// # Ok::<(), std::io::Error>(())
107/// ```
108pub struct RotatingFile {
109    path: PathBuf,
110    file: Option<BufWriter<File>>,
111    size: u64,
112    max_bytes: Option<u64>,
113    rotation: Rotation,
114    max_files: usize,
115    buffer_capacity: usize,
116    /// Unix permission bits for files this writer creates.
117    mode: Option<u32>,
118    /// Index of the UTC period the active file belongs to.
119    period: Option<u64>,
120    /// A rotation failure waiting to be reported by `flush`.
121    deferred_error: Option<io::Error>,
122    now: fn() -> SystemTime,
123}
124
125impl RotatingFile {
126    /// Start configuring a rotating file at `path`. Defaults: no size
127    /// limit, [`Rotation::Never`], keep 5 rotated files, 8 KiB buffer.
128    pub fn builder(path: impl AsRef<Path>) -> RotatingFileBuilder {
129        RotatingFileBuilder {
130            path: path.as_ref().to_path_buf(),
131            max_bytes: None,
132            rotation: Rotation::Never,
133            max_files: 5,
134            buffer_capacity: 8 * 1024,
135            mode: None,
136        }
137    }
138
139    /// The path of the active file.
140    #[must_use]
141    pub fn path(&self) -> &Path {
142        &self.path
143    }
144
145    /// Bytes written to the active file so far (including bytes that
146    /// existed when it was opened).
147    #[must_use]
148    pub fn current_size(&self) -> u64 {
149        self.size
150    }
151
152    /// Rotate immediately, regardless of size and time.
153    ///
154    /// # Errors
155    ///
156    /// Returns the I/O error from flushing, renaming, or reopening. On
157    /// error the writer keeps appending to the active file.
158    pub fn rotate_now(&mut self) -> io::Result<()> {
159        self.rotate()
160    }
161
162    fn rotated_path(&self, index: usize) -> PathBuf {
163        let mut name = OsString::from(self.path.as_os_str());
164        name.push(format!(".{index}"));
165        PathBuf::from(name)
166    }
167
168    fn current_period(&self) -> Option<u64> {
169        // Read the clock only when a schedule is configured: `now` costs
170        // tens of nanoseconds per record on some platforms.
171        self.rotation
172            .period_secs()
173            .map(|p| unix_secs((self.now)()) / p)
174    }
175
176    fn should_rotate(&mut self, incoming: usize) -> bool {
177        if self.size == 0 {
178            // Never produce an empty rotated file; just track the period.
179            self.period = self.current_period();
180            return false;
181        }
182        let too_big = self
183            .max_bytes
184            .is_some_and(|max| self.size.saturating_add(incoming as u64) > max);
185        too_big
186            || self
187                .period
188                .is_some_and(|open| self.current_period() != Some(open))
189    }
190
191    fn rotate(&mut self) -> io::Result<()> {
192        if let Some(mut writer) = self.file.take() {
193            if let Err(e) = writer.flush() {
194                self.file = Some(writer);
195                return Err(e);
196            }
197            // Close the handle before renaming: Windows refuses to
198            // rename a file this process still has open.
199            drop(writer);
200        }
201        let rotated = self.move_active_into_history();
202        let reopened = open_append(&self.path, self.buffer_capacity, self.mode);
203        match reopened {
204            Ok((writer, size)) => {
205                self.file = Some(writer);
206                // After a failed rotation `size` is the old file's
207                // length; count from zero anyway so the next attempt
208                // happens after another `max_bytes` rather than on every
209                // write.
210                self.size = if rotated.is_ok() { size } else { 0 };
211                self.period = self.current_period();
212                rotated
213            }
214            Err(e) => Err(e),
215        }
216    }
217
218    fn sibling(&self, suffix: &str) -> PathBuf {
219        let mut name = OsString::from(self.path.as_os_str());
220        name.push(suffix);
221        PathBuf::from(name)
222    }
223
224    /// Move the (closed) active file to `<path>.1`, shifting and pruning
225    /// the history, as one all-or-nothing step.
226    ///
227    /// The active file is first moved to a staging name, so the common
228    /// failure (another process holds the active file) is detected
229    /// before any history file is touched. The oldest file is moved to a
230    /// pruning name instead of being deleted, and deleted only after
231    /// every other rename has succeeded. If any rename fails, the ones
232    /// already done are undone in reverse order and the error is
233    /// returned: rotated history is never deleted or overwritten by a
234    /// rotation that did not complete.
235    fn move_active_into_history(&self) -> io::Result<()> {
236        if self.max_files == 0 {
237            // No history is kept: rotation discards the active file.
238            return remove_if_exists(&self.path);
239        }
240        let staging = self.sibling(STAGING_SUFFIX);
241        let pruning = self.sibling(PRUNING_SUFFIX);
242        if fs::symlink_metadata(&staging).is_ok() {
243            // Left behind by a rotation whose rollback also failed. It
244            // holds records; never overwrite it.
245            return Err(io::Error::new(
246                io::ErrorKind::AlreadyExists,
247                format!(
248                    "{} was left by an interrupted rotation and holds log records;                      move it aside to resume rotation",
249                    staging.display()
250                ),
251            ));
252        }
253        // Only a completed rotation leaves this file, and its content
254        // was already pruned by that rotation.
255        remove_if_exists(&pruning)?;
256
257        let mut moves = Vec::with_capacity(self.max_files + 2);
258        moves.push(Move::required(self.path.clone(), staging.clone()));
259        moves.push(Move::optional(
260            self.rotated_path(self.max_files),
261            pruning.clone(),
262        ));
263        for index in (1..self.max_files).rev() {
264            moves.push(Move::optional(
265                self.rotated_path(index),
266                self.rotated_path(index + 1),
267            ));
268        }
269        moves.push(Move::required(staging, self.rotated_path(1)));
270
271        let mut done = Vec::with_capacity(moves.len());
272        for step in &moves {
273            match rename_file(&step.from, &step.to) {
274                Ok(()) => done.push(step),
275                Err(e) if !step.required && e.kind() == io::ErrorKind::NotFound => {}
276                Err(e) => {
277                    roll_back(&done);
278                    return Err(e);
279                }
280            }
281        }
282        // The rotation is complete. If this fails, the pruned file stays
283        // under the pruning name and is removed by the next rotation.
284        remove_if_exists(&pruning)
285    }
286}
287
288/// Name the active file is moved to while the history is shifted.
289const STAGING_SUFFIX: &str = ".rotating";
290/// Name the oldest rotated file is moved to until the rotation is
291/// complete.
292const PRUNING_SUFFIX: &str = ".pruning";
293
294/// One rename of a rotation.
295struct Move {
296    from: PathBuf,
297    to: PathBuf,
298    /// `false` for history slots, which may legitimately be missing.
299    required: bool,
300}
301
302impl Move {
303    const fn required(from: PathBuf, to: PathBuf) -> Self {
304        Self {
305            from,
306            to,
307            required: true,
308        }
309    }
310
311    const fn optional(from: PathBuf, to: PathBuf) -> Self {
312        Self {
313            from,
314            to,
315            required: false,
316        }
317    }
318}
319
320/// Undo completed renames, newest first.
321///
322/// Each undo moves a file back to the name the forward rename vacated,
323/// and every later rename that targeted that name has already been
324/// undone. If an undo fails, stop: undoing an earlier rename could then
325/// replace a file that is still in the way. The files keep their
326/// shifted names (nothing is deleted), and the staging file left behind
327/// makes later rotations refuse to run until an operator steps in.
328fn roll_back(done: &[&Move]) {
329    for step in done.iter().rev() {
330        if rename_file(&step.to, &step.from).is_err() {
331            return;
332        }
333    }
334}
335
336impl Write for RotatingFile {
337    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
338        if self.should_rotate(buf.len()) {
339            if let Err(e) = self.rotate() {
340                // Keep the first failure; report it from `flush`.
341                if self.deferred_error.is_none() {
342                    self.deferred_error = Some(e);
343                }
344            }
345        }
346        let writer = match self.file.as_mut() {
347            Some(w) => w,
348            None => {
349                let (writer, size) = open_append(&self.path, self.buffer_capacity, self.mode)?;
350                self.size = size;
351                self.file.insert(writer)
352            }
353        };
354        writer.write_all(buf)?;
355        self.size = self.size.saturating_add(buf.len() as u64);
356        Ok(buf.len())
357    }
358
359    fn flush(&mut self) -> io::Result<()> {
360        if let Some(writer) = self.file.as_mut() {
361            writer.flush()?;
362        }
363        match self.deferred_error.take() {
364            Some(e) => Err(e),
365            None => Ok(()),
366        }
367    }
368}
369
370impl Drop for RotatingFile {
371    fn drop(&mut self) {
372        if let Some(writer) = self.file.as_mut() {
373            // Nowhere left to report an error from a destructor; the
374            // logger flushes (and reports) before dropping its sinks.
375            let _flush_in_drop = writer.flush();
376        }
377    }
378}
379
380impl core::fmt::Debug for RotatingFile {
381    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
382        f.debug_struct("RotatingFile")
383            .field("path", &self.path)
384            .field("size", &self.size)
385            .field("max_bytes", &self.max_bytes)
386            .field("rotation", &self.rotation)
387            .field("max_files", &self.max_files)
388            .finish_non_exhaustive()
389    }
390}
391
392/// Options for a [`RotatingFile`]. Create with [`RotatingFile::builder`].
393#[derive(Debug, Clone)]
394#[must_use = "call `open()` to create the writer"]
395pub struct RotatingFileBuilder {
396    path: PathBuf,
397    max_bytes: Option<u64>,
398    rotation: Rotation,
399    max_files: usize,
400    buffer_capacity: usize,
401    mode: Option<u32>,
402}
403
404impl RotatingFileBuilder {
405    /// Rotate before a write would take the active file past `bytes`.
406    pub const fn max_bytes(mut self, bytes: u64) -> Self {
407        self.max_bytes = Some(bytes);
408        self
409    }
410
411    /// Rotate on a UTC time schedule.
412    pub const fn rotation(mut self, rotation: Rotation) -> Self {
413        self.rotation = rotation;
414        self
415    }
416
417    /// How many rotated files to keep (`<path>.1` ..= `<path>.N`).
418    /// `0` keeps none.
419    pub const fn max_files(mut self, count: usize) -> Self {
420        self.max_files = count;
421        self
422    }
423
424    /// Size of the in-memory write buffer (`0` = unbuffered).
425    pub const fn buffer_capacity(mut self, bytes: usize) -> Self {
426        self.buffer_capacity = bytes;
427        self
428    }
429
430    /// Unix permission bits for every file this writer creates: the active
431    /// file and each new one after a rotation, such as `0o600` or `0o640`.
432    /// The process umask still applies, and an existing file keeps its
433    /// mode. Without this call files get `0o666` minus the umask, as
434    /// before 2.1. On Windows files inherit their directory's access
435    /// control list and the mode has no effect.
436    ///
437    /// # Examples
438    ///
439    /// ```no_run
440    /// use log_io::sink::{RotatingFile, Rotation};
441    ///
442    /// let file = RotatingFile::builder("app.log")
443    ///     .rotation(Rotation::Daily)
444    ///     .mode(0o640)
445    ///     .open()?;
446    /// # Ok::<(), std::io::Error>(())
447    /// ```
448    pub const fn mode(mut self, mode: u32) -> Self {
449        self.mode = Some(mode);
450        self
451    }
452
453    /// Open (or create) the active file in append mode.
454    ///
455    /// If the file already exists and was last modified in an earlier
456    /// period of the time schedule, it is rotated on the first write.
457    ///
458    /// # Errors
459    ///
460    /// Returns the I/O error from opening the file.
461    pub fn open(self) -> io::Result<RotatingFile> {
462        self.open_with_clock(SystemTime::now)
463    }
464
465    fn open_with_clock(self, now: fn() -> SystemTime) -> io::Result<RotatingFile> {
466        let (writer, size) = open_append(&self.path, self.buffer_capacity, self.mode)?;
467        let period = self.rotation.period_secs().map(|p| {
468            let started = if size > 0 {
469                writer
470                    .get_ref()
471                    .metadata()
472                    .and_then(|m| m.modified())
473                    .unwrap_or_else(|_| now())
474            } else {
475                now()
476            };
477            unix_secs(started) / p
478        });
479        Ok(RotatingFile {
480            path: self.path,
481            file: Some(writer),
482            size,
483            max_bytes: self.max_bytes,
484            rotation: self.rotation,
485            max_files: self.max_files,
486            buffer_capacity: self.buffer_capacity,
487            mode: self.mode,
488            period,
489            deferred_error: None,
490            now,
491        })
492    }
493}
494
495fn open_append(
496    path: &Path,
497    capacity: usize,
498    mode: Option<u32>,
499) -> io::Result<(BufWriter<File>, u64)> {
500    let mut options = OpenOptions::new();
501    let _options = options.create(true).append(true);
502    super::apply_mode(&mut options, mode);
503    let file = options.open(path)?;
504    let size = file.metadata()?.len();
505    Ok((BufWriter::with_capacity(capacity, file), size))
506}
507
508fn unix_secs(t: SystemTime) -> u64 {
509    t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
510}
511
512fn remove_if_exists(path: &Path) -> io::Result<()> {
513    match fs::remove_file(path) {
514        Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
515        _ => Ok(()),
516    }
517}
518
519fn rename_file(from: &Path, to: &Path) -> io::Result<()> {
520    #[cfg(test)]
521    if tests::rename_is_blocked(from, to) {
522        return Err(io::Error::new(
523            io::ErrorKind::PermissionDenied,
524            "rename blocked by the test",
525        ));
526    }
527    fs::rename(from, to)
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use std::sync::atomic::{AtomicU64, Ordering};
534
535    struct TempDir(PathBuf);
536
537    impl TempDir {
538        fn new(name: &str) -> Self {
539            let dir =
540                std::env::temp_dir().join(format!("log-io-rotate-{}-{name}", std::process::id()));
541            let _ = fs::remove_dir_all(&dir);
542            fs::create_dir_all(&dir).unwrap();
543            Self(dir)
544        }
545        fn file(&self, name: &str) -> PathBuf {
546            self.0.join(name)
547        }
548    }
549
550    impl Drop for TempDir {
551        fn drop(&mut self) {
552            let _ = fs::remove_dir_all(&self.0);
553        }
554    }
555
556    fn read(path: &Path) -> String {
557        fs::read_to_string(path).unwrap_or_default()
558    }
559
560    #[test]
561    fn test_rotating_file_rotates_by_size_and_keeps_max_files() {
562        let dir = TempDir::new("size");
563        let path = dir.file("app.log");
564        let mut w = RotatingFile::builder(&path)
565            .max_bytes(10)
566            .max_files(2)
567            .open()
568            .unwrap();
569        for record in ["aaaa1\n", "bbbb2\n", "cccc3\n", "dddd4\n"] {
570            w.write_all(record.as_bytes()).unwrap();
571        }
572        w.flush().unwrap();
573        assert_eq!(read(&path), "dddd4\n");
574        assert_eq!(read(&dir.file("app.log.1")), "cccc3\n");
575        assert_eq!(read(&dir.file("app.log.2")), "bbbb2\n");
576        assert!(
577            !dir.file("app.log.3").exists(),
578            "max_files must be enforced"
579        );
580    }
581
582    #[test]
583    fn test_rotating_file_never_splits_a_record() {
584        let dir = TempDir::new("whole");
585        let path = dir.file("a.log");
586        let mut w = RotatingFile::builder(&path).max_bytes(8).open().unwrap();
587        w.write_all(b"0123456789abcdef\n").unwrap();
588        w.write_all(b"xy\n").unwrap();
589        w.flush().unwrap();
590        assert_eq!(read(&dir.file("a.log.1")), "0123456789abcdef\n");
591        assert_eq!(read(&path), "xy\n");
592        assert_eq!(w.current_size(), 3);
593    }
594
595    #[test]
596    fn test_rotating_file_max_files_zero_discards_history() {
597        let dir = TempDir::new("zero");
598        let path = dir.file("z.log");
599        let mut w = RotatingFile::builder(&path)
600            .max_bytes(4)
601            .max_files(0)
602            .open()
603            .unwrap();
604        w.write_all(b"one\n").unwrap();
605        w.write_all(b"two\n").unwrap();
606        w.flush().unwrap();
607        assert_eq!(read(&path), "two\n");
608        assert!(!dir.file("z.log.1").exists());
609    }
610
611    #[test]
612    fn test_rotating_file_appends_to_existing_and_counts_its_size() {
613        let dir = TempDir::new("existing");
614        let path = dir.file("e.log");
615        fs::write(&path, "12345").unwrap();
616        let mut w = RotatingFile::builder(&path).max_bytes(8).open().unwrap();
617        assert_eq!(w.current_size(), 5);
618        w.write_all(b"6789\n").unwrap();
619        w.flush().unwrap();
620        assert_eq!(read(&dir.file("e.log.1")), "12345");
621        assert_eq!(read(&path), "6789\n");
622    }
623
624    static FAKE_NOW: AtomicU64 = AtomicU64::new(0);
625
626    fn fake_clock() -> SystemTime {
627        UNIX_EPOCH + std::time::Duration::from_secs(FAKE_NOW.load(Ordering::SeqCst))
628    }
629
630    #[test]
631    fn test_rotating_file_rotates_on_period_boundary() {
632        let dir = TempDir::new("time");
633        let path = dir.file("t.log");
634        FAKE_NOW.store(86_400 * 10 + 5, Ordering::SeqCst);
635        let mut w = RotatingFile::builder(&path)
636            .rotation(Rotation::Daily)
637            .open_with_clock(fake_clock)
638            .unwrap();
639        w.write_all(b"day10\n").unwrap();
640        FAKE_NOW.store(86_400 * 10 + 80_000, Ordering::SeqCst);
641        w.write_all(b"day10b\n").unwrap();
642        FAKE_NOW.store(86_400 * 11 + 1, Ordering::SeqCst);
643        w.write_all(b"day11\n").unwrap();
644        w.flush().unwrap();
645        assert_eq!(read(&dir.file("t.log.1")), "day10\nday10b\n");
646        assert_eq!(read(&path), "day11\n");
647    }
648
649    #[test]
650    fn test_rotating_file_rotate_now_and_accessors() {
651        let dir = TempDir::new("manual");
652        let path = dir.file("m.log");
653        let mut w = RotatingFile::builder(&path)
654            .rotation(Rotation::Hourly)
655            .buffer_capacity(0)
656            .open()
657            .unwrap();
658        assert_eq!(w.path(), path.as_path());
659        w.write_all(b"before\n").unwrap();
660        w.rotate_now().unwrap();
661        w.write_all(b"after\n").unwrap();
662        w.flush().unwrap();
663        assert_eq!(read(&dir.file("m.log.1")), "before\n");
664        assert_eq!(read(&path), "after\n");
665        assert!(format!("{w:?}").contains("Hourly"));
666        assert_eq!(Rotation::default(), Rotation::Never);
667        assert_eq!(Rotation::Minutely.period_secs(), Some(60));
668    }
669
670    #[test]
671    fn test_rotating_file_prune_failure_is_reported_once_and_keeps_records() {
672        let dir = TempDir::new("fail");
673        let path = dir.file("f.log");
674        let mut w = RotatingFile::builder(&path)
675            .max_bytes(4)
676            .max_files(1)
677            .open()
678            .unwrap();
679        w.write_all(b"one\n").unwrap();
680        // A directory in the oldest slot is moved to the pruning name
681        // but cannot be removed with `remove_file` on any platform.
682        fs::create_dir(dir.file("f.log.1")).unwrap();
683        w.write_all(b"two\n").unwrap();
684        assert!(w.flush().is_err(), "rotation failure must surface on flush");
685        assert!(w.flush().is_ok(), "the error is reported once");
686        assert_eq!(read(&dir.file("f.log.1")), "one\n");
687        assert_eq!(read(&path), "two\n");
688        assert!(dir.file("f.log.pruning").is_dir());
689        // The leftover still cannot be removed, so the next rotation is
690        // refused before anything moves.
691        w.write_all(b"three\n").unwrap();
692        assert!(w.flush().is_err());
693        assert_eq!(read(&dir.file("f.log.1")), "one\n");
694        assert_eq!(read(&path), "two\nthree\n", "no record may be lost");
695    }
696
697    #[test]
698    fn test_rotating_file_rotates_at_exactly_max_bytes_not_before() {
699        let dir = TempDir::new("edge");
700        let path = dir.file("edge.log");
701        let mut w = RotatingFile::builder(&path)
702            .max_bytes(10)
703            .buffer_capacity(0)
704            .open()
705            .unwrap();
706        w.write_all(b"01234").unwrap();
707        w.write_all(b"56789").unwrap();
708        assert!(!dir.file("edge.log.1").exists(), "exactly max_bytes fits");
709        w.write_all(b"a").unwrap();
710        assert_eq!(read(&dir.file("edge.log.1")), "0123456789");
711        assert_eq!(read(&path), "a");
712    }
713
714    #[test]
715    fn test_rotating_file_drop_flushes_buffered_records() {
716        let dir = TempDir::new("drop");
717        let path = dir.file("d.log");
718        let mut w = RotatingFile::builder(&path).open().unwrap();
719        w.write_all(b"buffered\n").unwrap();
720        assert_eq!(read(&path), "", "still in the write buffer");
721        drop(w);
722        assert_eq!(read(&path), "buffered\n");
723    }
724
725    #[test]
726    fn test_rotating_file_never_reads_the_clock_without_a_schedule() {
727        fn exploding_clock() -> SystemTime {
728            panic!("the clock must not be read for Rotation::Never");
729        }
730        let dir = TempDir::new("noclock");
731        let path = dir.file("n.log");
732        let mut w = RotatingFile::builder(&path)
733            .max_bytes(4)
734            .open_with_clock(exploding_clock)
735            .unwrap();
736        w.write_all(b"one\n").unwrap();
737        w.write_all(b"two\n").unwrap();
738        w.flush().unwrap();
739        assert_eq!(read(&dir.file("n.log.1")), "one\n");
740    }
741
742    thread_local! {
743        /// Renames (from, to) that fail with `PermissionDenied`, to
744        /// simulate on every platform what another process holding a
745        /// file does on Windows.
746        static BLOCKED: core::cell::RefCell<Vec<(PathBuf, PathBuf)>> =
747            const { core::cell::RefCell::new(Vec::new()) };
748    }
749
750    pub(super) fn rename_is_blocked(from: &Path, to: &Path) -> bool {
751        BLOCKED.with(|b| b.borrow().iter().any(|(f, t)| f == from && t == to))
752    }
753
754    fn block_rename(from: PathBuf, to: PathBuf) {
755        BLOCKED.with(|b| b.borrow_mut().push((from, to)));
756    }
757
758    fn unblock_renames() {
759        BLOCKED.with(|b| b.borrow_mut().clear());
760    }
761
762    /// Writer with `max_bytes(5)`, `max_files(3)`, the active file
763    /// holding `11111` and history `.1..=.3` seeded.
764    fn seeded_writer(dir: &TempDir, base: &str) -> RotatingFile {
765        let mut w = RotatingFile::builder(dir.file(base))
766            .max_bytes(5)
767            .max_files(3)
768            .buffer_capacity(0)
769            .open()
770            .unwrap();
771        w.write_all(b"11111").unwrap();
772        seed_history(dir, base, 3);
773        w
774    }
775
776    fn rotation_files(dir: &TempDir) -> Vec<String> {
777        let mut names: Vec<String> = fs::read_dir(&dir.0)
778            .unwrap()
779            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
780            .collect();
781        names.sort();
782        names
783    }
784
785    #[test]
786    fn test_rotating_file_blocked_active_rename_keeps_history_on_every_platform() {
787        let dir = TempDir::new("blocked-active");
788        let mut w = seeded_writer(&dir, "a.log");
789        block_rename(dir.file("a.log"), dir.file("a.log.rotating"));
790        for i in 0..6 {
791            w.write_all(format!("r{i}xxx").as_bytes()).unwrap();
792            let _reported = w.flush();
793        }
794        unblock_renames();
795        assert_history_intact(&dir, "a.log", 3);
796        assert_eq!(
797            read(&dir.file("a.log")),
798            "11111r0xxxr1xxxr2xxxr3xxxr4xxxr5xxx"
799        );
800        assert_eq!(
801            rotation_files(&dir),
802            ["a.log", "a.log.1", "a.log.2", "a.log.3"]
803        );
804    }
805
806    #[test]
807    fn test_rotating_file_blocked_history_rename_rolls_back() {
808        let dir = TempDir::new("blocked-history");
809        let mut w = seeded_writer(&dir, "h.log");
810        // The shift moves `.3` to the pruning name and then fails on
811        // `.2 -> .3`; both the prune and the staging move are undone.
812        block_rename(dir.file("h.log.2"), dir.file("h.log.3"));
813        w.write_all(b"r0xxx").unwrap();
814        let err = w.flush().unwrap_err();
815        assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
816        unblock_renames();
817        assert_history_intact(&dir, "h.log", 3);
818        assert_eq!(read(&dir.file("h.log")), "11111r0xxx");
819        assert_eq!(
820            rotation_files(&dir),
821            ["h.log", "h.log.1", "h.log.2", "h.log.3"]
822        );
823    }
824
825    #[test]
826    fn test_rotating_file_blocked_final_rename_rolls_back() {
827        let dir = TempDir::new("blocked-final");
828        let mut w = seeded_writer(&dir, "s.log");
829        block_rename(dir.file("s.log.rotating"), dir.file("s.log.1"));
830        w.write_all(b"r0xxx").unwrap();
831        assert!(w.flush().is_err());
832        unblock_renames();
833        assert_history_intact(&dir, "s.log", 3);
834        assert_eq!(read(&dir.file("s.log")), "11111r0xxx");
835        // Unblocked, the next rotation completes.
836        w.rotate_now().unwrap();
837        w.write_all(b"r1xxx").unwrap();
838        w.flush().unwrap();
839        assert_eq!(read(&dir.file("s.log")), "r1xxx");
840        assert_eq!(read(&dir.file("s.log.1")), "11111r0xxx");
841        assert_eq!(read(&dir.file("s.log.2")), "OLD1\n");
842        assert_eq!(read(&dir.file("s.log.3")), "OLD2\n");
843        assert_eq!(
844            rotation_files(&dir),
845            ["s.log", "s.log.1", "s.log.2", "s.log.3"],
846            "OLD3 is pruned only by the rotation that completed"
847        );
848    }
849
850    #[test]
851    fn test_rotating_file_failed_rollback_deletes_nothing_and_stops_rotating() {
852        let dir = TempDir::new("blocked-rollback");
853        let mut w = seeded_writer(&dir, "r.log");
854        // Forward: the final staging rename fails. Rollback: undoing
855        // `.1 -> .2` fails too, so the rollback stops there.
856        block_rename(dir.file("r.log.rotating"), dir.file("r.log.1"));
857        block_rename(dir.file("r.log.2"), dir.file("r.log.1"));
858        w.write_all(b"r0xxx").unwrap();
859        assert!(w.flush().is_err());
860        unblock_renames();
861        let mut contents: Vec<String> = rotation_files(&dir)
862            .iter()
863            .map(|name| read(&dir.file(name)))
864            .collect();
865        contents.sort();
866        // Every record is still on disk under some name.
867        assert_eq!(contents, ["11111", "OLD1\n", "OLD2\n", "OLD3\n", "r0xxx"]);
868        assert_eq!(read(&dir.file("r.log.rotating")), "11111");
869        // With records stranded in the staging file, rotation refuses to
870        // run rather than overwrite it, and the new file keeps growing.
871        w.write_all(b"r1xxx").unwrap();
872        w.write_all(b"r2xxx").unwrap();
873        let err = w.flush().unwrap_err();
874        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
875        assert!(err.to_string().contains("r.log.rotating"), "{err}");
876        assert_eq!(read(&dir.file("r.log.rotating")), "11111");
877        assert_eq!(read(&dir.file("r.log")), "r0xxxr1xxxr2xxx");
878    }
879
880    fn seed_history(dir: &TempDir, base: &str, count: usize) {
881        for index in 1..=count {
882            fs::write(
883                dir.file(&format!("{base}.{index}")),
884                format!(
885                    "OLD{index}
886"
887                ),
888            )
889            .unwrap();
890        }
891    }
892
893    fn assert_history_intact(dir: &TempDir, base: &str, count: usize) {
894        for index in 1..=count {
895            assert_eq!(
896                read(&dir.file(&format!("{base}.{index}"))),
897                format!(
898                    "OLD{index}
899"
900                ),
901                "{base}.{index} must survive a failed rotation"
902            );
903        }
904    }
905
906    /// LOG-R01: a rename blocked by another process (Windows opens
907    /// without `FILE_SHARE_DELETE`) must not cost any rotated history,
908    /// no matter how often rotation is retried.
909    #[cfg(windows)]
910    #[test]
911    fn test_rotating_file_held_active_file_keeps_history_on_windows() {
912        use std::os::windows::fs::OpenOptionsExt;
913        const FILE_SHARE_READ: u32 = 0x1;
914        const FILE_SHARE_WRITE: u32 = 0x2;
915        let dir = TempDir::new("held");
916        let path = dir.file("held.log");
917        let mut w = RotatingFile::builder(&path)
918            .max_bytes(5)
919            .max_files(3)
920            .buffer_capacity(0)
921            .open()
922            .unwrap();
923        w.write_all(b"11111").unwrap();
924        seed_history(&dir, "held.log", 3);
925        let holder = OpenOptions::new()
926            .read(true)
927            .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
928            .open(&path)
929            .unwrap();
930        let mut failures = 0;
931        for i in 0..6 {
932            w.write_all(format!("r{i}xxx").as_bytes()).unwrap();
933            if w.flush().is_err() {
934                failures += 1;
935            }
936        }
937        drop(holder);
938        assert!(failures > 0, "the blocked rotation must be reported");
939        assert_history_intact(&dir, "held.log", 3);
940        assert_eq!(read(&path), "11111r0xxxr1xxxr2xxxr3xxxr4xxxr5xxx");
941        assert!(!dir.file("held.log.4").exists());
942        // Once the other handle is gone, rotation resumes normally.
943        w.rotate_now().unwrap();
944        assert_eq!(
945            read(&dir.file("held.log.1")),
946            "11111r0xxxr1xxxr2xxxr3xxxr4xxxr5xxx"
947        );
948        assert_eq!(
949            read(&dir.file("held.log.2")),
950            "OLD1
951"
952        );
953        assert_eq!(
954            read(&dir.file("held.log.3")),
955            "OLD2
956"
957        );
958        assert!(!dir.file("held.log.4").exists(), "max_files still applies");
959    }
960
961    #[cfg(unix)]
962    #[test]
963    fn test_rotating_mode_applies_to_active_and_rotated_files() {
964        use std::os::unix::fs::PermissionsExt;
965        let dir = TempDir::new("mode");
966        let mut w = RotatingFile::builder(dir.file("app.log"))
967            .max_bytes(5)
968            .max_files(2)
969            .buffer_capacity(0)
970            .mode(0o600)
971            .open()
972            .unwrap();
973        w.write_all(b"11111").unwrap();
974        w.write_all(b"22222").unwrap(); // rotates: a new active file is created
975        w.flush().unwrap();
976        for name in ["app.log", "app.log.1"] {
977            let mode = fs::metadata(dir.file(name)).unwrap().permissions().mode() & 0o777;
978            assert_eq!(mode, 0o600, "{name}");
979        }
980    }
981}