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