file_rotate/
suffix.rs

1//! Suffix schemes determine the suffix of rotated files
2//!
3//! This behaviour is fully extensible through the [SuffixScheme] trait, and two behaviours are
4//! provided: [AppendCount] and [AppendTimestamp]
5//!
6use super::now;
7use crate::SuffixInfo;
8use chrono::{format::ParseErrorKind, offset::Local, Duration, NaiveDateTime};
9use std::{
10    cmp::Ordering,
11    collections::BTreeSet,
12    io,
13    path::{Path, PathBuf},
14};
15
16/// Representation of a suffix
17/// `Ord + PartialOrd`: sort by age of the suffix. Most recent first (smallest).
18pub trait Representation: Ord + ToString + Eq + Clone + std::fmt::Debug {
19    /// Create path
20    fn to_path(&self, basepath: &Path) -> PathBuf {
21        PathBuf::from(format!("{}.{}", basepath.display(), self.to_string()))
22    }
23}
24
25/// How to move files: How to rename, when to delete.
26pub trait SuffixScheme {
27    /// The representation of suffixes that this suffix scheme uses.
28    /// E.g. if the suffix is a number, you can use `usize`.
29    type Repr: Representation;
30
31    /// `file-rotate` calls this function when the file at `suffix` needs to be rotated, and moves the log file
32    /// accordingly. Thus, this function should not move any files itself.
33    ///
34    /// If `suffix` is `None`, it means it's the main log file (with path equal to just `basepath`)
35    /// that is being rotated.
36    ///
37    /// Returns the target suffix that the log file should be moved to.
38    /// If the target suffix already exists, `rotate_file` is called again with `suffix` set to the
39    /// target suffix.  Thus it cascades files by default, and if this is not desired, it's up to
40    /// `rotate_file` to return a suffix that does not already exist on disk.
41    ///
42    /// `newest_suffix` is provided just in case it's useful (depending on the particular suffix scheme, it's not always useful)
43    fn rotate_file(
44        &mut self,
45        basepath: &Path,
46        newest_suffix: Option<&Self::Repr>,
47        suffix: &Option<Self::Repr>,
48    ) -> io::Result<Self::Repr>;
49
50    /// Parse suffix from string.
51    fn parse(&self, suffix: &str) -> Option<Self::Repr>;
52
53    /// Whether either the suffix or the chronological file number indicates that the file is old
54    /// and should be deleted, depending of course on the file limit.
55    /// `file_number` starts at 0 for the most recent suffix.
56    fn too_old(&self, suffix: &Self::Repr, file_number: usize) -> bool;
57
58    /// Find all files in the basepath.parent() directory that has path equal to basepath + a valid
59    /// suffix. Return sorted collection - sorted from most recent to oldest based on the
60    /// [Ord] implementation of `Self::Repr`.
61    fn scan_suffixes(&self, basepath: &Path) -> BTreeSet<SuffixInfo<Self::Repr>> {
62        let mut suffixes = BTreeSet::new();
63        let filename_prefix = basepath
64            .file_name()
65            .expect("basepath.file_name()")
66            .to_string_lossy();
67
68        // We need the parent directory of the given basepath, but this should also work when the path
69        // only has one segment. Thus we prepend the current working dir if the path is relative:
70        let basepath = if basepath.is_relative() {
71            let mut path = std::env::current_dir().unwrap();
72            path.push(basepath);
73            path
74        } else {
75            basepath.to_path_buf()
76        };
77
78        let parent = basepath.parent().unwrap();
79
80        let filenames = std::fs::read_dir(parent)
81            .unwrap()
82            .filter_map(|entry| entry.ok())
83            .filter(|entry| entry.path().is_file())
84            .map(|entry| entry.file_name());
85        for filename in filenames {
86            let filename = filename.to_string_lossy();
87            if !filename.starts_with(&*filename_prefix) {
88                continue;
89            }
90            let (filename, compressed) = prepare_filename(&*filename);
91            let suffix_str = filename.strip_prefix(&format!("{}.", filename_prefix));
92            if let Some(suffix) = suffix_str.and_then(|s| self.parse(s)) {
93                suffixes.insert(SuffixInfo { suffix, compressed });
94            }
95        }
96        suffixes
97    }
98}
99fn prepare_filename(path: &str) -> (&str, bool) {
100    path.strip_suffix(".gz")
101        .map(|x| (x, true))
102        .unwrap_or((path, false))
103}
104
105/// Append a number when rotating the file.
106/// The greater the number, the older. The oldest files are deleted.
107pub struct AppendCount {
108    max_files: usize,
109}
110
111impl AppendCount {
112    /// New suffix scheme, deleting files when the number of rotated files (i.e. excluding the main
113    /// file) exceeds `max_files`.
114    /// For example, if `max_files` is 3, then the files `log`, `log.1`, `log.2`, `log.3` may exist
115    /// but not `log.4`. In other words, `max_files` determines the largest possible suffix number.
116    pub fn new(max_files: usize) -> Self {
117        Self { max_files }
118    }
119}
120
121impl Representation for usize {}
122impl SuffixScheme for AppendCount {
123    type Repr = usize;
124    fn rotate_file(
125        &mut self,
126        _basepath: &Path,
127        _: Option<&usize>,
128        suffix: &Option<usize>,
129    ) -> io::Result<usize> {
130        Ok(match suffix {
131            Some(suffix) => suffix + 1,
132            None => 1,
133        })
134    }
135    fn parse(&self, suffix: &str) -> Option<usize> {
136        suffix.parse::<usize>().ok()
137    }
138    fn too_old(&self, _suffix: &usize, file_number: usize) -> bool {
139        file_number >= self.max_files
140    }
141}
142
143/// Add timestamp from:
144pub enum DateFrom {
145    /// Date yesterday, to represent the timestamps within the log file.
146    DateYesterday,
147    /// Date from hour ago, useful with rotate hourly.
148    DateHourAgo,
149    /// Date from now.
150    Now,
151}
152
153/// Append current timestamp as suffix when rotating files.
154/// If the timestamp already exists, an additional number is appended.
155///
156/// Current limitations:
157///  - Neither `format` nor the base filename can include the character `"."`.
158///  - The `format` should ensure that the lexical and chronological orderings are the same
159pub struct AppendTimestamp {
160    /// The format of the timestamp suffix
161    pub format: &'static str,
162    /// The file limit, e.g. when to delete an old file - by age (given by suffix) or by number of files
163    pub file_limit: FileLimit,
164    /// Add timestamp from DateFrom
165    pub date_from: DateFrom,
166}
167
168impl AppendTimestamp {
169    /// With format `"%Y%m%dT%H%M%S"`
170    pub fn default(file_limit: FileLimit) -> Self {
171        Self {
172            format: "%Y%m%dT%H%M%S",
173            file_limit,
174            date_from: DateFrom::Now,
175        }
176    }
177    /// Create new AppendTimestamp suffix scheme
178    pub fn with_format(format: &'static str, file_limit: FileLimit, date_from: DateFrom) -> Self {
179        Self {
180            format,
181            file_limit,
182            date_from,
183        }
184    }
185}
186
187/// Structured representation of the suffixes of AppendTimestamp.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct TimestampSuffix {
190    /// The timestamp
191    pub timestamp: String,
192    /// Optional number suffix if two timestamp suffixes are the same
193    pub number: Option<usize>,
194}
195impl Representation for TimestampSuffix {}
196impl Ord for TimestampSuffix {
197    fn cmp(&self, other: &Self) -> Ordering {
198        // Most recent = smallest (opposite as the timestamp Ord)
199        // Smallest = most recent. Thus, biggest timestamp first. And then biggest number
200        match other.timestamp.cmp(&self.timestamp) {
201            Ordering::Equal => other.number.cmp(&self.number),
202            unequal => unequal,
203        }
204    }
205}
206impl PartialOrd for TimestampSuffix {
207    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
208        Some(self.cmp(other))
209    }
210}
211impl std::fmt::Display for TimestampSuffix {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
213        match self.number {
214            Some(n) => write!(f, "{}.{}", self.timestamp, n),
215            None => write!(f, "{}", self.timestamp),
216        }
217    }
218}
219
220impl SuffixScheme for AppendTimestamp {
221    type Repr = TimestampSuffix;
222
223    fn rotate_file(
224        &mut self,
225        _basepath: &Path,
226        newest_suffix: Option<&TimestampSuffix>,
227        suffix: &Option<TimestampSuffix>,
228    ) -> io::Result<TimestampSuffix> {
229        assert!(suffix.is_none());
230        if suffix.is_none() {
231            let mut now = now();
232
233            match self.date_from {
234                DateFrom::DateYesterday => {
235                    now = now - Duration::days(1);
236                }
237                DateFrom::DateHourAgo => {
238                    now = now - Duration::hours(1);
239                }
240                _ => {}
241            };
242
243            let fmt_now = now.format(self.format).to_string();
244
245            let number = if let Some(newest_suffix) = newest_suffix {
246                if newest_suffix.timestamp == fmt_now {
247                    Some(newest_suffix.number.unwrap_or(0) + 1)
248                } else {
249                    None
250                }
251            } else {
252                None
253            };
254            Ok(TimestampSuffix {
255                timestamp: fmt_now,
256                number,
257            })
258        } else {
259            // This rotation scheme dictates that only the main log file should ever be renamed.
260            // In debug build the above assert will catch this.
261            Err(io::Error::new(
262                io::ErrorKind::InvalidData,
263                "Critical error in file-rotate algorithm",
264            ))
265        }
266    }
267    fn parse(&self, suffix: &str) -> Option<Self::Repr> {
268        let (timestamp_str, n) = if let Some(dot) = suffix.find('.') {
269            if let Ok(n) = suffix[(dot + 1)..].parse::<usize>() {
270                (&suffix[..dot], Some(n))
271            } else {
272                return None;
273            }
274        } else {
275            (suffix, None)
276        };
277        let success = match NaiveDateTime::parse_from_str(timestamp_str, self.format) {
278            Ok(_) => true,
279            Err(e) => e.kind() == ParseErrorKind::NotEnough,
280        };
281        if success {
282            Some(TimestampSuffix {
283                timestamp: timestamp_str.to_string(),
284                number: n,
285            })
286        } else {
287            None
288        }
289    }
290    fn too_old(&self, suffix: &TimestampSuffix, file_number: usize) -> bool {
291        match self.file_limit {
292            FileLimit::MaxFiles(max_files) => file_number >= max_files,
293            FileLimit::Age(age) => {
294                let old_timestamp = (Local::now() - age).format(self.format).to_string();
295                suffix.timestamp < old_timestamp
296            }
297            FileLimit::Unlimited => false,
298        }
299    }
300}
301
302/// How to determine whether a file should be deleted, in the case of [AppendTimestamp].
303pub enum FileLimit {
304    /// Delete the oldest files if number of files is too high
305    MaxFiles(usize),
306    /// Delete files whose age exceeds the `Duration` - age is determined by the suffix of the file
307    Age(Duration),
308    /// Never delete files
309    Unlimited,
310}
311
312#[cfg(test)]
313mod test {
314    use super::*;
315    use std::fs::File;
316    use tempfile::TempDir;
317    #[test]
318    fn timestamp_ordering() {
319        assert!(
320            TimestampSuffix {
321                timestamp: "2021".to_string(),
322                number: None
323            } < TimestampSuffix {
324                timestamp: "2020".to_string(),
325                number: None
326            }
327        );
328        assert!(
329            TimestampSuffix {
330                timestamp: "2021".to_string(),
331                number: Some(1)
332            } < TimestampSuffix {
333                timestamp: "2021".to_string(),
334                number: None
335            }
336        );
337    }
338
339    #[test]
340    fn timestamp_scan_suffixes_base_paths() {
341        let working_dir = TempDir::new().unwrap();
342        let working_dir = working_dir.path().join("dir");
343        let suffix_scheme = AppendTimestamp::default(FileLimit::Age(Duration::weeks(1)));
344
345        // Test `scan_suffixes` for different possible paths given to it
346        // (it used to have a bug taking e.g. "log".parent() --> panic)
347        for relative_path in ["logs/log", "./log", "log", "../log", "../logs/log"] {
348            std::fs::create_dir_all(&working_dir).unwrap();
349            println!("Testing relative path: {}", relative_path);
350            let relative_path = Path::new(relative_path);
351
352            let log_file = working_dir.join(relative_path);
353            let log_dir = log_file.parent().unwrap();
354            // Ensure all directories needed exist
355            std::fs::create_dir_all(log_dir).unwrap();
356
357            // We cd into working_dir
358            std::env::set_current_dir(&working_dir).unwrap();
359
360            // Need to create the log file in order to canonicalize it and then get the parent
361            File::create(working_dir.join(&relative_path)).unwrap();
362            let canonicalized = relative_path.canonicalize().unwrap();
363            let relative_dir = canonicalized.parent().unwrap();
364
365            File::create(relative_dir.join("log.20210911T121830")).unwrap();
366            File::create(relative_dir.join("log.20210911T121831.gz")).unwrap();
367
368            let paths = suffix_scheme.scan_suffixes(relative_path);
369            assert_eq!(paths.len(), 2);
370
371            // Reset CWD: necessary on Windows only - otherwise we get the error:
372            // "The process cannot access the file because it is being used by another process."
373            // (code 32)
374            std::env::set_current_dir("/").unwrap();
375
376            // Cleanup
377            std::fs::remove_dir_all(&working_dir).unwrap();
378        }
379    }
380
381    #[test]
382    fn timestamp_scan_suffixes_formats() {
383        struct TestCase {
384            format: &'static str,
385            suffixes: &'static [&'static str],
386            incorrect_suffixes: &'static [&'static str],
387        }
388
389        let cases = [
390            TestCase {
391                format: "%Y%m%dT%H%M%S",
392                suffixes: &["20220201T101010", "20220202T101010"],
393                incorrect_suffixes: &["20220201T1010", "20220201T999999", "2022-02-02"],
394            },
395            TestCase {
396                format: "%Y-%m-%d",
397                suffixes: &["2022-02-01", "2022-02-02"],
398                incorrect_suffixes: &[
399                    "abc",
400                    "2022-99-99",
401                    "2022-05",
402                    "2022",
403                    "20220202",
404                    "2022-02-02T112233",
405                ],
406            },
407        ];
408
409        for (i, case) in cases.iter().enumerate() {
410            println!("Case {}", i);
411            let tmp_dir = TempDir::new().unwrap();
412            let dir = tmp_dir.path();
413            let log_path = dir.join("file");
414
415            for suffix in case.suffixes.iter().chain(case.incorrect_suffixes) {
416                File::create(dir.join(format!("file.{}", suffix))).unwrap();
417            }
418
419            let scheme = AppendTimestamp::with_format(
420                case.format,
421                FileLimit::MaxFiles(1),
422                DateFrom::DateYesterday,
423            );
424
425            // Scan for suffixes
426            let suffixes_set = scheme.scan_suffixes(&log_path);
427
428            // Collect these suffixes, and the expected suffixes, into Vec, and sort
429            let mut suffixes = suffixes_set
430                .into_iter()
431                .map(|x| x.suffix.to_string())
432                .collect::<Vec<_>>();
433            suffixes.sort_unstable();
434
435            let mut expected_suffixes = case.suffixes.to_vec();
436            expected_suffixes.sort_unstable();
437
438            assert_eq!(suffixes, case.suffixes);
439            println!("Passed\n");
440        }
441    }
442}