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