rolling_file/
lib.rs

1//! A rolling file appender with customizable rolling conditions.
2//! Includes built-in support for rolling conditions on date/time
3//! (daily, hourly, every minute) and/or size.
4//!
5//! Follows a Debian-style naming convention for logfiles,
6//! using basename, basename.1, ..., basename.N where N is
7//! the maximum number of allowed historical logfiles.
8//!
9//! This is useful to combine with the tracing crate and
10//! tracing_appender::non_blocking::NonBlocking -- use it
11//! as an alternative to tracing_appender::rolling::RollingFileAppender.
12//!
13//! # Examples
14//!
15//! ```rust
16//! # fn docs() {
17//! # use rolling_file::*;
18//! let file_appender = BasicRollingFileAppender::new(
19//!     "/var/log/myprogram",
20//!     RollingConditionBasic::new().daily(),
21//!     9
22//! ).unwrap();
23//! # }
24//! ```
25#![deny(warnings)]
26
27use chrono::prelude::*;
28use std::{
29    convert::TryFrom,
30    ffi::OsString,
31    fs,
32    fs::{File, OpenOptions},
33    io,
34    io::{BufWriter, Write},
35    path::Path,
36};
37
38/// Determines when a file should be "rolled over".
39pub trait RollingCondition {
40    /// Determine and return whether or not the file should be rolled over.
41    fn should_rollover(&mut self, now: &DateTime<Local>, current_filesize: u64) -> bool;
42}
43
44/// Determines how often a file should be rolled over
45#[derive(Copy, Clone, Debug, Eq, PartialEq)]
46pub enum RollingFrequency {
47    EveryDay,
48    EveryHour,
49    EveryMinute,
50}
51
52impl RollingFrequency {
53    /// Calculates a datetime that will be different if data should be in
54    /// different files.
55    pub fn equivalent_datetime(&self, dt: &DateTime<Local>) -> DateTime<Local> {
56        match self {
57            RollingFrequency::EveryDay => Local
58                .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 0, 0, 0)
59                .unwrap(),
60            RollingFrequency::EveryHour => Local
61                .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), 0, 0)
62                .unwrap(),
63            RollingFrequency::EveryMinute => Local
64                .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), dt.minute(), 0)
65                .unwrap(),
66        }
67    }
68}
69
70/// Implements a rolling condition based on a certain frequency
71/// and/or a size limit. The default condition is to rotate daily.
72///
73/// # Examples
74///
75/// ```rust
76/// use rolling_file::*;
77/// let c = RollingConditionBasic::new().daily();
78/// let c = RollingConditionBasic::new().hourly().max_size(1024 * 1024);
79/// ```
80#[derive(Copy, Clone, Debug, Eq, PartialEq)]
81pub struct RollingConditionBasic {
82    last_write_opt: Option<DateTime<Local>>,
83    frequency_opt: Option<RollingFrequency>,
84    max_size_opt: Option<u64>,
85}
86
87impl RollingConditionBasic {
88    /// Constructs a new struct that does not yet have any condition set.
89    pub fn new() -> RollingConditionBasic {
90        RollingConditionBasic {
91            last_write_opt: None,
92            frequency_opt: None,
93            max_size_opt: None,
94        }
95    }
96
97    /// Sets a condition to rollover on the given frequency
98    pub fn frequency(mut self, x: RollingFrequency) -> RollingConditionBasic {
99        self.frequency_opt = Some(x);
100        self
101    }
102
103    /// Sets a condition to rollover when the date changes
104    pub fn daily(mut self) -> RollingConditionBasic {
105        self.frequency_opt = Some(RollingFrequency::EveryDay);
106        self
107    }
108
109    /// Sets a condition to rollover when the date or hour changes
110    pub fn hourly(mut self) -> RollingConditionBasic {
111        self.frequency_opt = Some(RollingFrequency::EveryHour);
112        self
113    }
114
115    /// Sets a condition to rollover when a certain size is reached
116    pub fn max_size(mut self, x: u64) -> RollingConditionBasic {
117        self.max_size_opt = Some(x);
118        self
119    }
120}
121
122impl Default for RollingConditionBasic {
123    fn default() -> Self {
124        RollingConditionBasic::new().frequency(RollingFrequency::EveryDay)
125    }
126}
127
128impl RollingCondition for RollingConditionBasic {
129    fn should_rollover(&mut self, now: &DateTime<Local>, current_filesize: u64) -> bool {
130        let mut rollover = false;
131        if let Some(frequency) = self.frequency_opt.as_ref() {
132            if let Some(last_write) = self.last_write_opt.as_ref() {
133                if frequency.equivalent_datetime(now) != frequency.equivalent_datetime(last_write) {
134                    rollover = true;
135                }
136            }
137        }
138        if let Some(max_size) = self.max_size_opt.as_ref() {
139            if current_filesize >= *max_size {
140                rollover = true;
141            }
142        }
143        self.last_write_opt = Some(*now);
144        rollover
145    }
146}
147
148/// Writes data to a file, and "rolls over" to preserve older data in
149/// a separate set of files. Old files have a Debian-style naming scheme
150/// where we have base_filename, base_filename.1, ..., base_filename.N
151/// where N is the maximum number of rollover files to keep.
152#[derive(Debug)]
153pub struct RollingFileAppender<RC>
154where
155    RC: RollingCondition,
156{
157    condition: RC,
158    base_filename: OsString,
159    max_files: usize,
160    buffer_capacity: Option<usize>,
161    current_filesize: u64,
162    writer_opt: Option<BufWriter<File>>,
163}
164
165impl<RC> RollingFileAppender<RC>
166where
167    RC: RollingCondition,
168{
169    /// Creates a new rolling file appender with the given condition.
170    /// The parent directory of the base path must already exist.
171    pub fn new<P>(path: P, condition: RC, max_files: usize) -> io::Result<RollingFileAppender<RC>>
172    where
173        P: AsRef<Path>,
174    {
175        Self::_new(path, condition, max_files, None)
176    }
177
178    /// Creates a new rolling file appender with the given condition and write buffer capacity.
179    /// The parent directory of the base path must already exist.
180    pub fn new_with_buffer_capacity<P>(
181        path: P,
182        condition: RC,
183        max_files: usize,
184        buffer_capacity: usize,
185    ) -> io::Result<RollingFileAppender<RC>>
186    where
187        P: AsRef<Path>,
188    {
189        Self::_new(path, condition, max_files, Some(buffer_capacity))
190    }
191
192    fn _new<P>(
193        path: P,
194        condition: RC,
195        max_files: usize,
196        buffer_capacity: Option<usize>,
197    ) -> io::Result<RollingFileAppender<RC>>
198    where
199        P: AsRef<Path>,
200    {
201        let mut rfa = RollingFileAppender {
202            condition,
203            base_filename: path.as_ref().as_os_str().to_os_string(),
204            max_files,
205            buffer_capacity,
206            current_filesize: 0,
207            writer_opt: None,
208        };
209        // Fail if we can't open the file initially...
210        rfa.open_writer_if_needed()?;
211        Ok(rfa)
212    }
213
214    /// Determines the final filename, where n==0 indicates the current file
215    fn filename_for(&self, n: usize) -> OsString {
216        let mut f = self.base_filename.clone();
217        if n > 0 {
218            f.push(OsString::from(format!(".{}", n)))
219        }
220        f
221    }
222
223    /// Rotates old files to make room for a new one.
224    /// This may result in the deletion of the oldest file
225    fn rotate_files(&mut self) -> io::Result<()> {
226        // ignore any failure removing the oldest file (may not exist)
227        let _ = fs::remove_file(self.filename_for(self.max_files.max(1)));
228        let mut r = Ok(());
229        for i in (0..self.max_files.max(1)).rev() {
230            let rotate_from = self.filename_for(i);
231            let rotate_to = self.filename_for(i + 1);
232            if let Err(e) = fs::rename(rotate_from, rotate_to).or_else(|e| match e.kind() {
233                io::ErrorKind::NotFound => Ok(()),
234                _ => Err(e),
235            }) {
236                // capture the error, but continue the loop,
237                // to maximize ability to rename everything
238                r = Err(e);
239            }
240        }
241        r
242    }
243
244    /// Forces a rollover to happen immediately.
245    pub fn rollover(&mut self) -> io::Result<()> {
246        // Before closing, make sure all data is flushed successfully.
247        self.flush()?;
248        // We must close the current file before rotating files
249        self.writer_opt.take();
250        self.current_filesize = 0;
251        self.rotate_files()?;
252        self.open_writer_if_needed()
253    }
254
255    /// Returns a reference to the rolling condition
256    pub fn condition_ref(&self) -> &RC {
257        &self.condition
258    }
259
260    /// Returns a mutable reference to the rolling condition, possibly to mutate its state dynamically.
261    pub fn condition_mut(&mut self) -> &mut RC {
262        &mut self.condition
263    }
264
265    /// Opens a writer for the current file.
266    fn open_writer_if_needed(&mut self) -> io::Result<()> {
267        if self.writer_opt.is_none() {
268            let p = self.filename_for(0);
269            let f = OpenOptions::new().append(true).create(true).open(&p)?;
270            self.writer_opt = Some(if let Some(capacity) = self.buffer_capacity {
271                BufWriter::with_capacity(capacity, f)
272            } else {
273                BufWriter::new(f)
274            });
275            self.current_filesize = fs::metadata(&p).map_or(0, |m| m.len());
276        }
277        Ok(())
278    }
279
280    /// Writes data using the given datetime to calculate the rolling condition
281    pub fn write_with_datetime(&mut self, buf: &[u8], now: &DateTime<Local>) -> io::Result<usize> {
282        if self.condition.should_rollover(now, self.current_filesize) {
283            if let Err(e) = self.rollover() {
284                // If we can't rollover, just try to continue writing anyway
285                // (better than missing data).
286                // This will likely used to implement logging, so
287                // avoid using log::warn and log to stderr directly
288                eprintln!(
289                    "WARNING: Failed to rotate logfile {}: {}",
290                    self.base_filename.to_string_lossy(),
291                    e
292                );
293            }
294        }
295        self.open_writer_if_needed()?;
296        if let Some(writer) = self.writer_opt.as_mut() {
297            let buf_len = buf.len();
298            writer.write_all(buf).map(|_| {
299                self.current_filesize += u64::try_from(buf_len).unwrap_or(u64::MAX);
300                buf_len
301            })
302        } else {
303            Err(io::Error::new(
304                io::ErrorKind::Other,
305                "unexpected condition: writer is missing",
306            ))
307        }
308    }
309}
310
311impl<RC> io::Write for RollingFileAppender<RC>
312where
313    RC: RollingCondition,
314{
315    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
316        let now = Local::now();
317        self.write_with_datetime(buf, &now)
318    }
319
320    fn flush(&mut self) -> io::Result<()> {
321        if let Some(writer) = self.writer_opt.as_mut() {
322            writer.flush()?;
323        }
324        Ok(())
325    }
326}
327
328/// A rolling file appender with a rolling condition based on date/time or size.
329pub type BasicRollingFileAppender = RollingFileAppender<RollingConditionBasic>;
330
331// LCOV_EXCL_START
332#[cfg(test)]
333mod t {
334    use super::*;
335
336    struct Context {
337        _tempdir: tempfile::TempDir,
338        rolling: BasicRollingFileAppender,
339    }
340
341    impl Context {
342        #[track_caller]
343        fn verify_contains(&self, needle: &str, n: usize) {
344            let heystack = self.read(n);
345            if !heystack.contains(needle) {
346                panic!("file {:?} did not contain expected contents {}", self.path(n), needle);
347            }
348        }
349
350        #[track_caller]
351        fn verify_not_contains(&self, needle: &str, n: usize) {
352            let heystack = self.read(n);
353            if heystack.contains(needle) {
354                panic!("file {:?} DID contain expected contents {}", self.path(n), needle);
355            }
356        }
357
358        fn flush(&mut self) {
359            self.rolling.flush().unwrap();
360        }
361
362        fn read(&self, n: usize) -> String {
363            fs::read_to_string(self.path(n)).unwrap()
364        }
365
366        fn path(&self, n: usize) -> OsString {
367            self.rolling.filename_for(n)
368        }
369    }
370
371    fn build_context(condition: RollingConditionBasic, max_files: usize, buffer_capacity: Option<usize>) -> Context {
372        let tempdir = tempfile::tempdir().unwrap();
373        let rolling = match buffer_capacity {
374            None => BasicRollingFileAppender::new(tempdir.path().join("test.log"), condition, max_files).unwrap(),
375            Some(capacity) => BasicRollingFileAppender::new_with_buffer_capacity(
376                tempdir.path().join("test.log"),
377                condition,
378                max_files,
379                capacity,
380            )
381            .unwrap(),
382        };
383        Context {
384            _tempdir: tempdir,
385            rolling,
386        }
387    }
388
389    #[test]
390    fn frequency_every_day() {
391        let mut c = build_context(RollingConditionBasic::new().daily(), 9, None);
392        c.rolling
393            .write_with_datetime(b"Line 1\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
394            .unwrap();
395        c.rolling
396            .write_with_datetime(b"Line 2\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 3, 0).unwrap())
397            .unwrap();
398        c.rolling
399            .write_with_datetime(b"Line 3\n", &Local.with_ymd_and_hms(2021, 3, 31, 1, 4, 0).unwrap())
400            .unwrap();
401        c.rolling
402            .write_with_datetime(b"Line 4\n", &Local.with_ymd_and_hms(2021, 5, 31, 1, 4, 0).unwrap())
403            .unwrap();
404        c.rolling
405            .write_with_datetime(b"Line 5\n", &Local.with_ymd_and_hms(2022, 5, 31, 1, 4, 0).unwrap())
406            .unwrap();
407        assert!(!AsRef::<Path>::as_ref(&c.rolling.filename_for(4)).exists());
408        c.flush();
409        c.verify_contains("Line 1", 3);
410        c.verify_contains("Line 2", 3);
411        c.verify_contains("Line 3", 2);
412        c.verify_contains("Line 4", 1);
413        c.verify_contains("Line 5", 0);
414    }
415
416    #[test]
417    fn frequency_every_day_limited_files() {
418        let mut c = build_context(RollingConditionBasic::new().daily(), 2, None);
419        c.rolling
420            .write_with_datetime(b"Line 1\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
421            .unwrap();
422        c.rolling
423            .write_with_datetime(b"Line 2\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 3, 0).unwrap())
424            .unwrap();
425        c.rolling
426            .write_with_datetime(b"Line 3\n", &Local.with_ymd_and_hms(2021, 3, 31, 1, 4, 0).unwrap())
427            .unwrap();
428        c.rolling
429            .write_with_datetime(b"Line 4\n", &Local.with_ymd_and_hms(2021, 5, 31, 1, 4, 0).unwrap())
430            .unwrap();
431        c.rolling
432            .write_with_datetime(b"Line 5\n", &Local.with_ymd_and_hms(2022, 5, 31, 1, 4, 0).unwrap())
433            .unwrap();
434        assert!(!AsRef::<Path>::as_ref(&c.rolling.filename_for(4)).exists());
435        assert!(!AsRef::<Path>::as_ref(&c.rolling.filename_for(3)).exists());
436        c.flush();
437        c.verify_contains("Line 3", 2);
438        c.verify_contains("Line 4", 1);
439        c.verify_contains("Line 5", 0);
440    }
441
442    #[test]
443    fn frequency_every_hour() {
444        let mut c = build_context(RollingConditionBasic::new().hourly(), 9, None);
445        c.rolling
446            .write_with_datetime(b"Line 1\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
447            .unwrap();
448        c.rolling
449            .write_with_datetime(b"Line 2\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 3, 2).unwrap())
450            .unwrap();
451        c.rolling
452            .write_with_datetime(b"Line 3\n", &Local.with_ymd_and_hms(2021, 3, 30, 2, 1, 0).unwrap())
453            .unwrap();
454        c.rolling
455            .write_with_datetime(b"Line 4\n", &Local.with_ymd_and_hms(2021, 3, 31, 2, 1, 0).unwrap())
456            .unwrap();
457        assert!(!AsRef::<Path>::as_ref(&c.rolling.filename_for(3)).exists());
458        c.flush();
459        c.verify_contains("Line 1", 2);
460        c.verify_contains("Line 2", 2);
461        c.verify_contains("Line 3", 1);
462        c.verify_contains("Line 4", 0);
463    }
464
465    #[test]
466    fn frequency_every_minute() {
467        let mut c = build_context(
468            RollingConditionBasic::new().frequency(RollingFrequency::EveryMinute),
469            9,
470            None,
471        );
472        c.rolling
473            .write_with_datetime(b"Line 1\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
474            .unwrap();
475        c.rolling
476            .write_with_datetime(b"Line 2\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
477            .unwrap();
478        c.rolling
479            .write_with_datetime(b"Line 3\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 4).unwrap())
480            .unwrap();
481        c.rolling
482            .write_with_datetime(b"Line 4\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 3, 0).unwrap())
483            .unwrap();
484        c.rolling
485            .write_with_datetime(b"Line 5\n", &Local.with_ymd_and_hms(2021, 3, 30, 2, 3, 0).unwrap())
486            .unwrap();
487        c.rolling
488            .write_with_datetime(b"Line 6\n", &Local.with_ymd_and_hms(2022, 3, 30, 2, 3, 0).unwrap())
489            .unwrap();
490        assert!(!AsRef::<Path>::as_ref(&c.rolling.filename_for(4)).exists());
491        c.flush();
492        c.verify_contains("Line 1", 3);
493        c.verify_contains("Line 2", 3);
494        c.verify_contains("Line 3", 3);
495        c.verify_contains("Line 4", 2);
496        c.verify_contains("Line 5", 1);
497        c.verify_contains("Line 6", 0);
498    }
499
500    #[test]
501    fn max_size() {
502        let mut c = build_context(RollingConditionBasic::new().max_size(10), 9, None);
503        c.rolling
504            .write_with_datetime(b"12345", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
505            .unwrap();
506        c.rolling
507            .write_with_datetime(b"6789", &Local.with_ymd_and_hms(2021, 3, 30, 1, 3, 3).unwrap())
508            .unwrap();
509        c.rolling
510            .write_with_datetime(b"0", &Local.with_ymd_and_hms(2021, 3, 30, 2, 3, 3).unwrap())
511            .unwrap();
512        c.rolling
513            .write_with_datetime(
514                b"abcdefghijklmn",
515                &Local.with_ymd_and_hms(2021, 3, 31, 2, 3, 3).unwrap(),
516            )
517            .unwrap();
518        c.rolling
519            .write_with_datetime(b"ZZZ", &Local.with_ymd_and_hms(2022, 3, 31, 1, 2, 3).unwrap())
520            .unwrap();
521        assert!(!AsRef::<Path>::as_ref(&c.rolling.filename_for(3)).exists());
522        c.flush();
523        c.verify_contains("1234567890", 2);
524        c.verify_contains("abcdefghijklmn", 1);
525        c.verify_contains("ZZZ", 0);
526    }
527
528    #[test]
529    fn max_size_existing() {
530        let mut c = build_context(RollingConditionBasic::new().max_size(10), 9, None);
531        c.rolling
532            .write_with_datetime(b"12345", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
533            .unwrap();
534        // close the file and make sure that it can re-open it, and that it
535        // resets the file size properly.
536        c.rolling.writer_opt.take();
537        c.rolling.current_filesize = 0;
538        c.rolling
539            .write_with_datetime(b"6789", &Local.with_ymd_and_hms(2021, 3, 30, 1, 3, 3).unwrap())
540            .unwrap();
541        c.rolling
542            .write_with_datetime(b"0", &Local.with_ymd_and_hms(2021, 3, 30, 2, 3, 3).unwrap())
543            .unwrap();
544        c.rolling
545            .write_with_datetime(
546                b"abcdefghijklmn",
547                &Local.with_ymd_and_hms(2021, 3, 31, 2, 3, 3).unwrap(),
548            )
549            .unwrap();
550        c.rolling
551            .write_with_datetime(b"ZZZ", &Local.with_ymd_and_hms(2022, 3, 31, 1, 2, 3).unwrap())
552            .unwrap();
553        assert!(!AsRef::<Path>::as_ref(&c.rolling.filename_for(3)).exists());
554        c.flush();
555        c.verify_contains("1234567890", 2);
556        c.verify_contains("abcdefghijklmn", 1);
557        c.verify_contains("ZZZ", 0);
558    }
559
560    #[test]
561    fn daily_and_max_size() {
562        let mut c = build_context(RollingConditionBasic::new().daily().max_size(10), 9, None);
563        c.rolling
564            .write_with_datetime(b"12345", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
565            .unwrap();
566        c.rolling
567            .write_with_datetime(b"6789", &Local.with_ymd_and_hms(2021, 3, 30, 2, 3, 3).unwrap())
568            .unwrap();
569        c.rolling
570            .write_with_datetime(b"0", &Local.with_ymd_and_hms(2021, 3, 31, 2, 3, 3).unwrap())
571            .unwrap();
572        c.rolling
573            .write_with_datetime(
574                b"abcdefghijklmn",
575                &Local.with_ymd_and_hms(2021, 3, 31, 3, 3, 3).unwrap(),
576            )
577            .unwrap();
578        c.rolling
579            .write_with_datetime(b"ZZZ", &Local.with_ymd_and_hms(2021, 3, 31, 4, 4, 4).unwrap())
580            .unwrap();
581        assert!(!AsRef::<Path>::as_ref(&c.rolling.filename_for(3)).exists());
582        c.flush();
583        c.verify_contains("123456789", 2);
584        c.verify_contains("0abcdefghijklmn", 1);
585        c.verify_contains("ZZZ", 0);
586    }
587
588    #[test]
589    fn default_buffer_capacity() {
590        let c = build_context(RollingConditionBasic::new().daily(), 9, None);
591        // currently capacity should be 8192; but it may change (ref: https://doc.rust-lang.org/std/io/struct.BufWriter.html#method.new)
592        // so we can't hard code and there's no way to get default capacity other than creating a dummy one...
593        let default_capacity = BufWriter::new(tempfile::tempfile().unwrap()).capacity();
594        if default_capacity != 8192 {
595            eprintln!(
596                "WARN: it seems std's default capacity is changed from 8192 to {}",
597                default_capacity
598            );
599        }
600        assert_eq!(c.rolling.writer_opt.map(|b| b.capacity()), Some(default_capacity));
601    }
602
603    #[test]
604    fn large_buffer_capacity_and_flush() {
605        let mut c = build_context(RollingConditionBasic::new().daily(), 9, Some(100_000));
606        assert_eq!(c.rolling.writer_opt.as_ref().map(|b| b.capacity()), Some(100_000));
607        c.verify_not_contains("12345", 0);
608
609        // implicit flush only after capacity is reached
610        loop {
611            c.rolling
612                .write_with_datetime(b"dummy", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
613                .unwrap();
614            if c.rolling.current_filesize <= 100_000 {
615                c.verify_not_contains("dummy", 0);
616            } else {
617                break;
618            }
619        }
620        c.verify_contains("dummy", 0);
621
622        // explicit flush
623        c.verify_not_contains("12345", 0);
624        c.rolling
625            .write_with_datetime(b"12345", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
626            .unwrap();
627        c.flush();
628        c.verify_contains("12345", 0);
629    }
630}
631// LCOV_EXCL_STOP