file_rotator/
lib.rs

1//! Simple crate that allows easy usage of rotating logfiles by faking being a
2//! single [`std::io::Write`] implementor
3//!
4//! # Alright, sure, but what's a rotating logfile?
5//!
6//! Well, imagine we are logging *a lot*, and after a while we use up all our disk space with logs.
7//! We don't want this, nobody wants this, so how do we solve it?
8//!
9//! We'll introduce the concept of changing what file we log to periodically, or in other words, we'll
10//! *rotate* our log files so that we don't generate too much stored logging.
11//!
12//! One of the concepts that is involved in rotation is a limit to how many log files can exist at once.
13//!
14//! # Examples
15//!
16//! To demostrate what was said above, here's to create a file which rotates every day, storing up to a week of logs
17//! in `/logs`
18//!
19//! ```rust
20//! # use std::{time::Duration, num::NonZeroUsize};
21//! # use file_rotator::{RotationPeriod, RotatingFile, Compression};
22//! RotatingFile::new(
23//!     "loggylog",
24//!     "/logs",
25//!     RotationPeriod::Interval(Duration::from_secs(60 * 60 * 24)),
26//!     NonZeroUsize::new(7).unwrap(),
27//!     Compression::None,
28//! );
29//! ```
30
31#![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/// A specifier for how often we should rotate files
45#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
46#[non_exhaustive]
47pub enum RotationPeriod {
48    /// Rotate every N line terminator bytes (0x0a, b'\n')
49    Lines(usize),
50
51    /// Rotate every N bytes successfully written
52    ///
53    /// This does not count bytes that are not written to the underlying file
54    /// (when the given buffer's len does not match with the return value of
55    /// [`io::Write::write`])
56    ///
57    /// [`io::Write::write`]: https://doc.rust-lang.org/std/io/trait.Write.html#tymethod.write
58    Bytes(usize),
59
60    /// Rotate every time N amount of time passes
61    ///
62    /// This is calculated on every write and is based on comparing two [`Instant::now`] return values
63    ///
64    /// [`Instant::now`]: https://doc.rust-lang.org/std/time/struct.Instant.html#method.now
65    Interval(Duration),
66
67    /// Rotate only via [`RotatingFile::rotate`]
68    ///
69    /// [`RotatingFile::rotate']: struct.RotatingFile.html#method.rotate
70    Manual,
71}
72
73mod rotation_tracker;
74use rotation_tracker::RotationTracker;
75
76/// As per the name, a rotating file
77///
78/// Handles being a fake file which will automagicaly rotate as bytes are written into it
79#[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/// What compression algorithm should be used?
91///
92/// The current log file (`NAME.0.log`) is always written uncompressed; once its time to rotate
93/// out, compression will be applied. Depending on compression type, an extra extension might be
94/// added.
95#[derive(Clone, Copy, Debug)]
96pub enum Compression {
97    /// No compression, just bytes to disk.
98    None,
99    /// Zstd compression.
100    Zstd {
101        /// What level of compression should be used? As per the zstd crate's docs, zero means default.
102        level: i32,
103    },
104}
105
106impl RotatingFile {
107    /// Create a new rotating file with the given base name, in the given directory, rotating every
108    /// given period and with a max of a given number of files
109    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        // If we have no current file, it's probably best if we make one :p
132        self.current_file.is_none() || self.rotation_tracker.should_rotate()
133    }
134
135    // Paths are split into three parts: NAME.INDEX.EXTENSION
136    // NAME is user-defined and unimportant to us, while EXTENSION must be either log or log.zstd
137    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    // Increment a log file's index component by one by moving it, compressing if necessary
152    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        // If we're rotating out the current log file, we must compress it. Otherwise, everything's
157        // already compressed and we can just rotate
158        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's survey the directory and find out what's the biggest index in there
183        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 we've found any logs, let's make sure we stay under `self.max_index` files.
190        if let Some(mut max_found_index) = max_found_index {
191            // First, let's check if we have the maximum amount of logs available (or maybe even more!)
192            if max_found_index >= self.max_index {
193                // If so, let's remove all of the ones >=self.max_index so that we can make room for one more
194                (self.max_index..=max_found_index)
195                    .try_for_each(|index| fs::remove_file(self.make_filepath(index)))?;
196
197                // We'll need to update our `max_found_index` to avoid trying to
198                // move stuff that isn't there, but we'll use a saturating
199                // subtraction to handle the case where self.max_index == 0
200                // (only one logfile ever)
201                max_found_index = self.max_index.saturating_sub(1);
202            }
203
204            // If we've got a non-zero max index, we've got files to shuffle around!
205            if self.max_index != 0 {
206                // Increment all the remaining log files' indices so that we have
207                // room for a new one with index 0. Make sure that we do this in reverse order so
208                // we don't trample anything!
209                (0..=max_found_index)
210                    .rev()
211                    .try_for_each(|index| self.increment_index(index))?;
212            }
213        }
214
215        // Make sure we pass `create_new` so that nobody tries to be sneaky and
216        // place a file under us
217        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    /// Manually rotate the file out
235    ///
236    /// This is the only way that a file whose `rotation_period` is [`RotationPeriod::Manual`] can rotate.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error if one is encountered during creation of the new logfile.
241    ///
242    /// [`RotationPeriod::Manual`]: enum.RotationPeriod.html#variant.Manual
243    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}