simple_file_rotation/
lib.rs1pub use error::{FileRotationError, Result};
22use std::path::{is_separator, Path, PathBuf};
23
24mod error;
25
26pub struct FileRotation {
27 max_old_files: Option<usize>,
28 file: PathBuf,
29 extension: String,
30}
31
32impl FileRotation {
34 pub fn new(file: impl AsRef<Path>) -> Self {
35 Self {
36 file: file.as_ref().to_path_buf(),
37 max_old_files: None,
38 extension: "log".to_string(),
39 }
40 }
41
42 #[must_use]
44 pub fn max_old_files(mut self, max_old_files: usize) -> Self {
45 self.max_old_files = Some(max_old_files);
46 self
47 }
48
49 pub fn file_extension(mut self, extension: impl ToString) -> Self {
51 self.extension = extension.to_string();
52 self
53 }
54
55 pub fn rotate(self) -> Result<()> {
56 let Self {
57 max_old_files,
58 file,
59 extension,
60 } = self;
61
62 let is_dir = file
63 .to_str()
64 .and_then(|path| path.chars().last())
65 .map(is_separator)
66 .unwrap_or(false);
67
68 if is_dir {
69 return Err(FileRotationError::NotAFile(file));
70 }
71
72 let data_file = match file.extension() {
74 Some(_) => file,
75 None => file.with_extension(&extension),
76 };
77
78 let data_file_name = match data_file.file_name() {
79 Some(data_file_name) => data_file_name,
80 _ => return Err(FileRotationError::NotAFile(data_file)),
81 };
82
83 let data_file_dir = data_file
84 .parent()
85 .and_then(|p| {
86 let dir = p.to_path_buf();
87 if dir.to_string_lossy().is_empty() {
88 None
89 } else {
90 Some(dir)
91 }
92 })
93 .unwrap_or_else(|| PathBuf::from("."));
94
95 let mut rotations = Vec::new();
96 for entry in (std::fs::read_dir(&data_file_dir)?).flatten() {
97 let direntry_pathbuf = entry.path();
98
99 let file_name = entry.file_name();
100 if file_name == data_file_name {
101 rotations.push((
102 direntry_pathbuf.clone(),
103 data_file_name
104 .to_string_lossy()
105 .replace(&format!(".{}", &extension), &format!(".1.{}", &extension)),
106 ));
107 }
108
109 let data_file_name = data_file_name.to_string_lossy();
110 let file_name = file_name.to_string_lossy();
111 let parts = file_name.split('.').collect::<Vec<_>>();
112 match parts[..] {
113 [prefix, n, ext] if !prefix.is_empty() && data_file_name.starts_with(prefix) => {
114 if let Ok(n) = n.parse::<usize>() {
115 let new_name = format!("{prefix}.{}.{ext}", n + 1);
116 rotations.push((direntry_pathbuf, new_name));
117 }
118 }
119 _ => continue,
120 }
121 }
122
123 rotations.sort_by_key(|(_, new_name)| new_name.to_string());
124
125 if let Some(max_old_files) = max_old_files {
126 while rotations.len() > max_old_files {
127 if let Some((data_file, _)) = rotations.pop() {
128 if let Err(err) = std::fs::remove_file(data_file.clone()) {
129 eprintln!(
130 "Rotating logs: cannot remove file {}: {err}",
131 data_file.display()
132 );
133 }
134 }
135 }
136 }
137
138 for (entry, new_file_name) in rotations.into_iter().rev() {
139 if let Err(err) = std::fs::rename(entry.clone(), data_file_dir.join(new_file_name)) {
140 eprintln!("Error rotating file {entry:?}: {err}");
141 }
142 }
143
144 Ok(())
145 }
146}