rolling_file_opt/
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//! Log files structures(with `log` as folder and `log.log` as prefix):
6//! - log.log `(a symbol link always points to the latest one log file)`
7//! - log.log.yyyymmdd.hhmmss `(e.g. log.log.20240520.010101)`
8//! - ..
9
10//! This is useful to combine with the tracing crate and
11//! tracing_appender::non_blocking::NonBlocking -- use it
12//! as an alternative to tracing_appender::rolling::RollingFileAppender.
13//!
14//! # Examples
15//!
16//! ```rust
17//! # fn docs() {
18//! # use rolling_file::*;
19//! let file_appender = BasicRollingFileAppender::new(
20//!     "./log",
21//!     "log.log",
22//!     RollingConditionBasic::new().daily(),
23//!     9
24//! ).unwrap();
25//! # }
26//! ```
27#![deny(warnings)]
28
29use chrono::prelude::*;
30use std::{
31    convert::TryFrom,
32    fs::{self, File, OpenOptions},
33    io::{self, BufWriter, Write},
34    path::Path,
35};
36use symlink::{remove_symlink_auto, symlink_auto};
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    pub fn minutely(mut self) -> RollingConditionBasic {
116        self.frequency_opt = Some(RollingFrequency::EveryMinute);
117        self
118    }
119
120    /// Sets a condition to rollover when a certain size is reached
121    pub fn max_size(mut self, x: u64) -> RollingConditionBasic {
122        self.max_size_opt = Some(x);
123        self
124    }
125}
126
127impl Default for RollingConditionBasic {
128    fn default() -> Self {
129        RollingConditionBasic::new().frequency(RollingFrequency::EveryDay)
130    }
131}
132
133impl RollingCondition for RollingConditionBasic {
134    fn should_rollover(&mut self, now: &DateTime<Local>, current_filesize: u64) -> bool {
135        let mut rollover = false;
136        if let Some(frequency) = self.frequency_opt.as_ref() {
137            if let Some(last_write) = self.last_write_opt.as_ref() {
138                if frequency.equivalent_datetime(now) != frequency.equivalent_datetime(last_write) {
139                    rollover = true;
140                }
141            }
142        }
143        if let Some(max_size) = self.max_size_opt.as_ref() {
144            if current_filesize >= *max_size {
145                rollover = true;
146            }
147        }
148        self.last_write_opt = Some(*now);
149        rollover
150    }
151}
152
153/// Writes data to a file, and "rolls over" to preserve older data in
154/// a separate set of files. Old files have a Debian-style naming scheme
155/// where we have base_filename, base_filename.1, ..., base_filename.N
156/// where N is the maximum number of rollover files to keep.
157#[derive(Debug)]
158pub struct RollingFileAppender<RC>
159where
160    RC: RollingCondition,
161{
162    condition: RC,
163    folder: String,
164    prefix: String,
165    max_files: usize,
166    buffer_capacity: Option<usize>,
167    current_filesize: u64,
168    writer_opt: Option<BufWriter<File>>,
169}
170
171impl<RC> RollingFileAppender<RC>
172where
173    RC: RollingCondition,
174{
175    /// Creates a new rolling file appender with the given condition.
176    /// The parent directory of the base path must already exist.
177    pub fn new(folder: &str, prefix: &str, condition: RC, max_files: usize) -> io::Result<RollingFileAppender<RC>> {
178        Self::_new(folder, prefix, condition, max_files, None)
179    }
180
181    /// Creates a new rolling file appender with the given condition and write buffer capacity.
182    /// The parent directory of the base path must already exist.
183    pub fn new_with_buffer_capacity(
184        folder: &str,
185        prefix: &str,
186        condition: RC,
187        max_files: usize,
188        buffer_capacity: usize,
189    ) -> io::Result<RollingFileAppender<RC>> {
190        Self::_new(folder, prefix, condition, max_files, Some(buffer_capacity))
191    }
192
193    fn _new(
194        folder: &str,
195        prefix: &str,
196        condition: RC,
197        max_files: usize,
198        buffer_capacity: Option<usize>,
199    ) -> io::Result<RollingFileAppender<RC>> {
200        let folder = folder.to_string();
201        let prefix = prefix.to_string();
202        let mut rfa = RollingFileAppender {
203            condition,
204            folder,
205            prefix,
206            max_files,
207            buffer_capacity,
208            current_filesize: 0,
209            writer_opt: None,
210        };
211        // Fail if we can't open the file initially...
212        rfa.open_writer_if_needed(&Local::now())?;
213        Ok(rfa)
214    }
215
216    fn check_and_remove_log_file(&mut self) -> io::Result<()> {
217        let files = std::fs::read_dir(&self.folder)?;
218
219        let mut log_files = vec![];
220        for f in files.flatten() {
221            let fname = f.file_name().to_string_lossy().to_string();
222            if fname.starts_with(&self.prefix) && fname != self.prefix {
223                log_files.push(fname);
224            }
225        }
226
227        log_files.sort_by(|a, b| b.cmp(a));
228
229        if log_files.len() > self.max_files {
230            for f in log_files.drain(self.max_files..) {
231                let p = Path::new(&self.folder).join(f);
232                if let Err(e) = fs::remove_file(&p) {
233                    tracing::error!("WARNING: Failed to remove old logfile {}: {}", p.to_string_lossy(), e);
234                }
235            }
236        }
237        Ok(())
238    }
239
240    /// Forces a rollover to happen immediately.
241    pub fn rollover(&mut self, now: &DateTime<Local>) -> io::Result<()> {
242        // Before closing, make sure all data is flushed successfully.
243        self.flush()?;
244        // We must close the current file before rotating files
245        self.writer_opt.take();
246        self.current_filesize = 0;
247        self.open_writer_if_needed(now)
248    }
249
250    /// Returns a reference to the rolling condition
251    pub fn condition_ref(&self) -> &RC {
252        &self.condition
253    }
254
255    /// Returns a mutable reference to the rolling condition, possibly to mutate its state dynamically.
256    pub fn condition_mut(&mut self) -> &mut RC {
257        &mut self.condition
258    }
259
260    fn new_file_name(&self, now: &DateTime<Local>) -> String {
261        let data_str = now.format("%Y%m%d.%H%M%S").to_string();
262        format!("{}.{}", self.prefix, data_str)
263    }
264
265    /// Opens a writer for the current file.
266    fn open_writer_if_needed(&mut self, now: &DateTime<Local>) -> io::Result<()> {
267        if self.writer_opt.is_none() {
268            let p = self.new_file_name(now);
269            let new_file_path = std::path::Path::new(&self.folder).join(&p);
270            if std::fs::metadata(&self.folder).is_err() {
271                std::fs::create_dir_all(&self.folder)?;
272            }
273            let f = OpenOptions::new().append(true).create(true).open(&new_file_path)?;
274            self.writer_opt = Some(if let Some(capacity) = self.buffer_capacity {
275                BufWriter::with_capacity(capacity, f)
276            } else {
277                BufWriter::new(f)
278            });
279            // make a soft link to latest file
280            {
281                let folder = std::path::Path::new(&self.folder);
282                if let Ok(path) = folder.canonicalize() {
283                    let latest_log_symlink = path.join(&self.prefix);
284                    let _ = remove_symlink_auto(folder.join(&self.prefix));
285                    let _ = symlink_auto(new_file_path.canonicalize().unwrap(), latest_log_symlink);
286                }
287            }
288            self.current_filesize = fs::metadata(&p).map_or(0, |m| m.len());
289            self.check_and_remove_log_file()?;
290        }
291        Ok(())
292    }
293
294    /// Writes data using the given datetime to calculate the rolling condition
295    pub fn write_with_datetime(&mut self, buf: &[u8], now: &DateTime<Local>) -> io::Result<usize> {
296        if self.condition.should_rollover(now, self.current_filesize) {
297            if let Err(e) = self.rollover(now) {
298                // If we can't rollover, just try to continue writing anyway
299                // (better than missing data).
300                // This will likely used to implement logging, so
301                // avoid using log::warn and log to stderr directly
302                eprintln!("WARNING: Failed to rotate logfile  {}", e);
303            }
304        }
305        self.open_writer_if_needed(now)?;
306        if let Some(writer) = self.writer_opt.as_mut() {
307            let buf_len = buf.len();
308            writer.write_all(buf).map(|_| {
309                self.current_filesize += u64::try_from(buf_len).unwrap_or(u64::MAX);
310                buf_len
311            })
312        } else {
313            Err(io::Error::new(
314                io::ErrorKind::Other,
315                "unexpected condition: writer is missing",
316            ))
317        }
318    }
319}
320
321impl<RC> io::Write for RollingFileAppender<RC>
322where
323    RC: RollingCondition,
324{
325    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
326        let now = Local::now();
327        self.write_with_datetime(buf, &now)
328    }
329
330    fn flush(&mut self) -> io::Result<()> {
331        if let Some(writer) = self.writer_opt.as_mut() {
332            writer.flush()?;
333        }
334        Ok(())
335    }
336}
337
338/// A rolling file appender with a rolling condition based on date/time or size.
339pub type BasicRollingFileAppender = RollingFileAppender<RollingConditionBasic>;
340
341#[cfg(test)]
342mod t {
343    #[test]
344    fn test_number_of_log_files() {
345        use super::*;
346        let folder = "./log";
347        let prefix = "log.log";
348
349        let _ = std::fs::remove_dir_all(folder);
350        std::fs::create_dir(folder).unwrap();
351
352        let condition = RollingConditionBasic::new().hourly();
353        let max_files = 3;
354        let mut rfa = RollingFileAppender::new(folder, prefix, condition, max_files).unwrap();
355        rfa.write_with_datetime(b"Line 1\n", &Local.with_ymd_and_hms(2021, 3, 30, 1, 2, 3).unwrap())
356            .unwrap();
357        rfa.write_with_datetime(b"Line 2\n", &Local.with_ymd_and_hms(2021, 3, 30, 2, 3, 0).unwrap())
358            .unwrap();
359        rfa.write_with_datetime(b"Line 3\n", &Local.with_ymd_and_hms(2021, 3, 31, 1, 4, 0).unwrap())
360            .unwrap();
361        rfa.write_with_datetime(b"Line 4\n", &Local.with_ymd_and_hms(2021, 5, 31, 1, 4, 0).unwrap())
362            .unwrap();
363        rfa.write_with_datetime(b"Line 5\n", &Local.with_ymd_and_hms(2022, 5, 31, 2, 4, 0).unwrap())
364            .unwrap();
365        rfa.flush().unwrap();
366        let files = std::fs::read_dir(folder).unwrap();
367        let mut log_files = vec![];
368        for f in files {
369            if let Ok(f) = f {
370                let fname = f.file_name().to_string_lossy().to_string();
371                if fname.starts_with(prefix) && fname != "log.log" {
372                    log_files.push(fname);
373                }
374            }
375        }
376        assert_eq!(log_files.len(), max_files);
377    }
378}