1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14#[non_exhaustive]
15pub enum Rotation {
16 #[default]
19 Never,
20 Minutely,
23 Hourly,
25 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#[cfg_attr(all(feature = "json"), doc = "```no_run")]
95#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
96pub 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 mode: Option<u32>,
118 period: Option<u64>,
120 deferred_error: Option<io::Error>,
122 now: fn() -> SystemTime,
123}
124
125impl RotatingFile {
126 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 #[must_use]
141 pub fn path(&self) -> &Path {
142 &self.path
143 }
144
145 #[must_use]
148 pub fn current_size(&self) -> u64 {
149 self.size
150 }
151
152 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 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 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 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 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 fn move_active_into_history(&self) -> io::Result<()> {
236 if self.max_files == 0 {
237 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 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 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 remove_if_exists(&pruning)
285 }
286}
287
288const STAGING_SUFFIX: &str = ".rotating";
290const PRUNING_SUFFIX: &str = ".pruning";
293
294struct Move {
296 from: PathBuf,
297 to: PathBuf,
298 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
320fn 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 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 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#[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 pub const fn max_bytes(mut self, bytes: u64) -> Self {
407 self.max_bytes = Some(bytes);
408 self
409 }
410
411 pub const fn rotation(mut self, rotation: Rotation) -> Self {
413 self.rotation = rotation;
414 self
415 }
416
417 pub const fn max_files(mut self, count: usize) -> Self {
420 self.max_files = count;
421 self
422 }
423
424 pub const fn buffer_capacity(mut self, bytes: usize) -> Self {
426 self.buffer_capacity = bytes;
427 self
428 }
429
430 pub const fn mode(mut self, mode: u32) -> Self {
449 self.mode = Some(mode);
450 self
451 }
452
453 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 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 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 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 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 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 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 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 assert_eq!(contents, ["11111", "OLD1\n", "OLD2\n", "OLD3\n", "r0xxx"]);
868 assert_eq!(read(&dir.file("r.log.rotating")), "11111");
869 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 #[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 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(); 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}