1#![warn(
32 missing_docs,
33 missing_debug_implementations,
34 missing_copy_implementations
35)]
36
37use std::borrow::Cow;
38use std::fs;
39use std::io::{self, prelude::*};
40use std::num::NonZeroUsize;
41use std::path::{Path, PathBuf};
42use std::time::Duration;
43
44#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
46#[non_exhaustive]
47pub enum RotationPeriod {
48 Lines(usize),
50
51 Bytes(usize),
59
60 Interval(Duration),
66
67 Manual,
71}
72
73mod rotation_tracker;
74use rotation_tracker::RotationTracker;
75
76#[derive(Debug)]
80pub struct RotatingFile {
81 name: Cow<'static, str>,
82 directory: PathBuf,
83 rotation_tracker: RotationTracker,
84 max_index: usize,
85
86 compression: Compression,
87 current_file: Option<fs::File>,
88}
89
90#[derive(Clone, Copy, Debug)]
96pub enum Compression {
97 None,
99 Zstd {
101 level: i32,
103 },
104}
105
106impl RotatingFile {
107 pub fn new<Name, Directory>(
110 name: Name,
111 directory: Directory,
112 rotate_every: RotationPeriod,
113 max_files: NonZeroUsize,
114 compression: Compression,
115 ) -> Self
116 where
117 Name: Into<Cow<'static, str>>,
118 Directory: Into<PathBuf>,
119 {
120 Self {
121 name: name.into(),
122 directory: directory.into(),
123 rotation_tracker: RotationTracker::from(rotate_every),
124 max_index: max_files.get() - 1,
125 compression,
126 current_file: None,
127 }
128 }
129
130 fn should_rotate(&self) -> bool {
131 self.current_file.is_none() || self.rotation_tracker.should_rotate()
133 }
134
135 fn logfile_index<P: AsRef<Path>>(&self, path: P) -> Option<usize> {
138 let mut parts = path.as_ref().file_name()?.to_str()?.split('.');
139 match parts.next_back() {
140 Some("zstd") => {
141 if parts.next_back() != Some("log") {
142 return None;
143 }
144 }
145 Some("log") => {}
146 Some(..) | None => return None,
147 }
148 parts.next_back()?.parse().ok()
149 }
150
151 fn increment_index(&self, index: usize) -> io::Result<()> {
153 let path = self.make_filepath(index);
154 let dst = self.make_filepath(index + 1);
155 debug_assert!(!dst.exists());
156 match self.compression {
159 Compression::Zstd { level } if index == 0 => {
160 zstd::stream::copy_encode(fs::File::open(&path)?, fs::File::create(dst)?, level)?;
161 fs::remove_file(&path)?;
162 Ok(())
163 }
164
165 Compression::None | Compression::Zstd { .. } => fs::rename(path, dst),
166 }
167 }
168
169 fn make_filepath(&self, index: usize) -> PathBuf {
170 self.directory.join(format!(
171 "{}.{}.{}",
172 self.name,
173 index,
174 match self.compression {
175 Compression::Zstd { .. } if index != 0 => "log.zstd",
176 Compression::None | Compression::Zstd { .. } => "log",
177 }
178 ))
179 }
180
181 fn create_file(&self) -> io::Result<fs::File> {
182 let max_found_index = itertools::process_results(fs::read_dir(&self.directory)?, |dir| {
184 dir.into_iter()
185 .filter_map(|entry| self.logfile_index(entry.path()))
186 .max()
187 })?;
188
189 if let Some(mut max_found_index) = max_found_index {
191 if max_found_index >= self.max_index {
193 (self.max_index..=max_found_index)
195 .try_for_each(|index| fs::remove_file(self.make_filepath(index)))?;
196
197 max_found_index = self.max_index.saturating_sub(1);
202 }
203
204 if self.max_index != 0 {
206 (0..=max_found_index)
210 .rev()
211 .try_for_each(|index| self.increment_index(index))?;
212 }
213 }
214
215 fs::OpenOptions::new()
218 .create_new(true)
219 .write(true)
220 .open(self.make_filepath(0))
221 }
222
223 fn current_file(&mut self) -> io::Result<&mut fs::File> {
224 if self.should_rotate() {
225 self.rotate()?;
226 }
227
228 Ok(self
229 .current_file
230 .as_mut()
231 .expect("should've been created before"))
232 }
233
234 pub fn rotate(&mut self) -> io::Result<()> {
244 self.current_file = Some(self.create_file()?);
245 self.rotation_tracker.reset();
246 Ok(())
247 }
248}
249
250impl Write for RotatingFile {
251 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
252 let written = self.current_file()?.write(buf)?;
253 self.rotation_tracker.wrote(&buf[..written]);
254 Ok(written)
255 }
256
257 fn flush(&mut self) -> io::Result<()> {
258 self.current_file()?.flush()
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use std::fs;
265 use std::num::NonZeroUsize;
266 use std::path::Path;
267
268 use proptest::prelude::*;
269
270 use super::{RotatingFile, RotationPeriod};
271
272 #[track_caller]
273 fn assert_contains_files<P: AsRef<Path>>(
274 directory: P,
275 num: usize,
276 ) -> Result<(), TestCaseError> {
277 let p = directory.as_ref();
278 prop_assert_eq!(
279 fs::read_dir(p).unwrap().count(),
280 num,
281 "Directory {:?} did not contain {} file(s) (at {})",
282 p,
283 num,
284 std::panic::Location::caller()
285 );
286 Ok(())
287 }
288
289 proptest! {
290 #![proptest_config(ProptestConfig {
291 cases: 15,
292 ..ProptestConfig::default()
293 })]
294
295 #[test]
296 fn test_max_files(name in "[a-zA-Z_-]+", n in 1..25usize) {
297 let directory = tempfile::tempdir().unwrap();
298
299 let mut file = RotatingFile::new(
300 name,
301 directory.path().to_owned(),
302 RotationPeriod::Manual,
303 NonZeroUsize::new(n).unwrap(),
304 crate::Compression::None
305 );
306
307 assert_contains_files(&directory, 0)?;
308 for i in 0..n {
309 file.rotate().unwrap();
310 assert_contains_files(&directory, i+1)?;
311 }
312
313 for _ in 0..n {
314 assert_contains_files(&directory, n)?;
315 file.rotate().unwrap();
316 }
317 }
318
319 #[test]
320 fn test_roundtrip_uncompressed(name in "[a-zA-Z_-]+", data: Vec<u8>) {
321 use std::io::prelude::*;
322
323 let directory = tempfile::tempdir().unwrap();
324 let mut file = RotatingFile::new(
325 name,
326 directory.path().to_owned(),
327 RotationPeriod::Manual,
328 NonZeroUsize::new(10).unwrap(),
329 crate::Compression::None
330 );
331 file.write_all(&data).unwrap();
332 file.rotate().unwrap();
333 file.write_all(&data).unwrap();
334 drop(file);
335
336 for entry in fs::read_dir(&directory).unwrap().map(Result::unwrap) {
337 let path = entry.path();
338 let read = fs::read(path).unwrap();
339 prop_assert_eq!(&read, &data);
340 }
341 }
342
343 #[test]
344 fn test_roundtrip_zstd(name in "[a-zA-Z_-]+", n in 1..25usize, level in 0..21, data: Vec<u8>) {
345 use std::io::prelude::*;
346
347 let directory = tempfile::tempdir().unwrap();
348 let mut file = RotatingFile::new(
349 name,
350 directory.path().to_owned(),
351 RotationPeriod::Manual,
352 NonZeroUsize::new(n * 10).unwrap(),
353 crate::Compression::Zstd { level }
354 );
355 file.write_all(&data).unwrap();
356 for i in 0..n {
357 assert_contains_files(&directory, i + 1)?;
358 file.rotate().unwrap();
359 file.write_all(&data).unwrap();
360 }
361 assert_contains_files(&directory, n + 1)?;
362 drop(file);
363
364 for entry in fs::read_dir(&directory).unwrap().map(Result::unwrap) {
365 let path = entry.path();
366 let read = fs::read(&path).unwrap();
367 if path.file_stem().unwrap().to_string_lossy().ends_with(".0") {
368 prop_assert_eq!(path.extension().unwrap().to_string_lossy(), "log");
369 prop_assert_eq!(&read, &data);
370 } else {
371 prop_assert_eq!(path.extension().unwrap().to_string_lossy(), "zstd");
372 let read = zstd::decode_all(std::io::Cursor::new(read)).unwrap();
373 prop_assert_eq!(&read, &data);
374 }
375 }
376 }
377 }
378}