diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..4882eb2 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,30 @@ +name: Rust + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Check + run: cargo check + test: + runs-on: '${{ matrix.os }}' + strategy: + matrix: + include: + - os: macos-latest + - os: ubuntu-latest + - os: windows-latest + steps: + - uses: actions/checkout@v3 + - name: Test + run: cargo test diff --git a/.gitignore b/.gitignore index 1e7caa9..e25542f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ Cargo.lock +logs/ target/ diff --git a/Cargo.toml b/Cargo.toml index 3fafbef..6cf5e89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,20 @@ [package] name = "file-rotate" -version = "0.2.0" -authors = ["Kevin Robert Stravers "] +version = "0.8.0" +authors = ["Kevin Robert Stravers ", "Erlend Langseth <3rlendhl@gmail.com>"] edition = "2018" description = "Log rotation for files" -homepage = "https://github.com/BourgondAries/file-rotate" -repository = "https://github.com/BourgondAries/file-rotate" +homepage = "https://github.com/kstrafe/file-rotate" +repository = "https://github.com/kstrafe/file-rotate" keywords= ["log", "rotate", "logrotate"] -license = "LGPL-3.0-or-later" +license = "MIT" + +[dependencies] +chrono = { version = "0.4.20", default-features = false, features = ["clock"] } +flate2 = "1.0" [dev-dependencies] -quickcheck = "0.9" -quickcheck_macros = "0.9" +filetime = "0.2" +quickcheck = "0.9.2" +quickcheck_macros = "0.9.1" +tempfile = "3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a75d5a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 BourgondAries + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..98edd92 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# file-rotate + +Rotate files with configurable suffix. + +Look to the [docs](https://docs.rs/file-rotate/latest/file_rotate/index.html) for explanatory examples of all features, like: +* Using count or timestamp as suffix +* Age-based deletion of log files +* Optional compression +* Getting a list of log files + +Limitations / known issues: +* `file-rotate` assumes that no other process or user moves files around in the logging directory, but we want to find a way to [support this](https://github.com/BourgondAries/file-rotate/issues/17) + +Following are some supplementary examples to get started. + +## Basic example + +```rust +use file_rotate::{FileRotate, ContentLimit, compression::Compression, suffix::AppendCount}; +use std::{fs, io::Write, path::PathBuf}; + +fn main() { + let mut log = FileRotate::new("logs/log", AppendCount::new(2), ContentLimit::Lines(3), Compression::None, None); + + // Write a bunch of lines + writeln!(log, "Line 1: Hello World!"); + for idx in 2..=10 { + writeln!(log, "Line {}", idx); + } +} +``` + +``` +$ ls logs +log log.1 log.2 + +$ cat log.2 log.1 log +Line 4 +Line 5 +Line 6 +Line 7 +Line 8 +Line 9 +Line 10 +``` + +## Example with timestamp suffixes + +```rust +let mut log = FileRotate::new( + "logs/log", + AppendTimestamp::default(FileLimit::MaxFiles(3)), + ContentLimit::Lines(3), + Compression::None, + None, +); + +// Write a bunch of lines +writeln!(log, "Line 1: Hello World!"); +for idx in 2..=10 { + std::thread::sleep(std::time::Duration::from_millis(200)); + writeln!(log, "Line {}", idx); +} +``` + +``` +$ ls logs +log log.20210825T151133.1 +log.20210825T151133 log.20210825T151134 + +$ cat logs/* +Line 10 +Line 1: Hello World! +Line 2 +Line 3 +Line 4 +Line 5 +Line 6 +Line 7 +Line 8 +Line 9 +``` + +The timestamp format (including the extra trailing `.N`) works by default so that the lexical ordering of filenames equals the chronological ordering. +So it almost works perfectly with `cat logs/*`, except that `log` is smaller (lexically "older") than all the rest. This can of course be fixed with a more complex script to assemble the logs. + + +## License + +This project is licensed under the [MIT license]. + +[MIT license]: https://github.com/BourgondAries/file-rotate/blob/master/LICENSE + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in file-rotate by you, shall be licensed as MIT, without any additional +terms or conditions. diff --git a/examples/rotate_by_date.rs b/examples/rotate_by_date.rs new file mode 100644 index 0000000..80f7002 --- /dev/null +++ b/examples/rotate_by_date.rs @@ -0,0 +1,23 @@ +use file_rotate::{ + compression::Compression, + suffix::{AppendTimestamp, DateFrom, FileLimit}, + ContentLimit, FileRotate, TimeFrequency, +}; +use std::io::Write; + +fn main() { + let mut log = FileRotate::new( + "logs/log", + AppendTimestamp::with_format("%Y-%m-%d", FileLimit::MaxFiles(7), DateFrom::DateYesterday), + ContentLimit::Time(TimeFrequency::Daily), + Compression::None, + None, + ); + + // Write a bunch of lines + writeln!(log, "Line 1: Hello World!").expect("write log"); + for idx in 2..=10 { + std::thread::sleep(std::time::Duration::from_millis(500)); + writeln!(log, "Line {}", idx).expect("write log"); + } +} diff --git a/src/compression.rs b/src/compression.rs new file mode 100644 index 0000000..70095ed --- /dev/null +++ b/src/compression.rs @@ -0,0 +1,37 @@ +//! Compression - configuration and implementation +use flate2::write::GzEncoder; +use std::{ + fs::{self, File, OpenOptions}, + io, + path::{Path, PathBuf}, +}; + +/// Compression mode - when to compress files. +#[derive(Debug, Clone)] +pub enum Compression { + /// No compression + None, + /// Look for files to compress when rotating. + /// First argument: How many files to keep uncompressed (excluding the original file) + OnRotate(usize), +} + +pub(crate) fn compress(path: &Path) -> io::Result<()> { + let dest_path = PathBuf::from(format!("{}.gz", path.display())); + + let mut src_file = File::open(path)?; + let dest_file = OpenOptions::new() + .write(true) + .create(true) + .append(false) + .open(&dest_path)?; + + assert!(path.exists()); + assert!(dest_path.exists()); + let mut encoder = GzEncoder::new(dest_file, flate2::Compression::default()); + io::copy(&mut src_file, &mut encoder)?; + + fs::remove_file(path)?; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index c2ff2a5..179e3d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,25 +2,35 @@ //! //! Defines a simple [std::io::Write] object that you can plug into your writers as middleware. //! -//! # Rotating by Lines # +//! # Content limit # //! -//! We can rotate log files by using the amount of lines as a limit. +//! [ContentLimit] specifies at what point a log file has to be rotated. +//! +//! ## Rotating by Lines ## +//! +//! We can rotate log files with the amount of lines as a limit, by using [ContentLimit::Lines]. //! //! ``` -//! use file_rotate::{FileRotate, RotationMode}; +//! use file_rotate::{FileRotate, ContentLimit, suffix::AppendCount, compression::Compression}; //! use std::{fs, io::Write}; //! -//! // Create a directory to store our logs, this is not strictly needed but shows how we can -//! // arbitrary paths. -//! fs::create_dir("target/my-log-directory-lines"); -//! //! // Create a new log writer. The first argument is anything resembling a path. The //! // basename is used for naming the log files. //! // //! // Here we choose to limit logs by 10 lines, and have at most 2 rotated log files. This -//! // makes the total amount of log files 4, since the original file is present as well as -//! // file 0. -//! let mut log = FileRotate::new("target/my-log-directory-lines/my-log-file", RotationMode::Lines(3), 2); +//! // makes the total amount of log files 3, since the original file is present as well. +//! +//! # let directory = tempfile::TempDir::new().unwrap(); +//! # let directory = directory.path(); +//! let log_path = directory.join("my-log-file"); +//! +//! let mut log = FileRotate::new( +//! log_path.clone(), +//! AppendCount::new(2), +//! ContentLimit::Lines(3), +//! Compression::None, +//! None, +//! ); //! //! // Write a bunch of lines //! writeln!(log, "Line 1: Hello World!"); @@ -28,30 +38,35 @@ //! writeln!(log, "Line {}", idx); //! } //! -//! assert_eq!("Line 10\n", fs::read_to_string("target/my-log-directory-lines/my-log-file").unwrap()); +//! assert_eq!("Line 10\n", fs::read_to_string(&log_path).unwrap()); //! -//! assert_eq!("Line 1: Hello World!\nLine 2\nLine 3\n", fs::read_to_string("target/my-log-directory-lines/my-log-file.0").unwrap()); -//! assert_eq!("Line 4\nLine 5\nLine 6\n", fs::read_to_string("target/my-log-directory-lines/my-log-file.1").unwrap()); -//! assert_eq!("Line 7\nLine 8\nLine 9\n", fs::read_to_string("target/my-log-directory-lines/my-log-file.2").unwrap()); -//! -//! fs::remove_dir_all("target/my-log-directory-lines"); +//! assert_eq!("Line 4\nLine 5\nLine 6\n", fs::read_to_string(&directory.join("my-log-file.2")).unwrap()); +//! assert_eq!("Line 7\nLine 8\nLine 9\n", fs::read_to_string(&directory.join("my-log-file.1")).unwrap()); //! ``` //! -//! # Rotating by Bytes # +//! ## Rotating by Bytes ## //! -//! Another method of rotation is by bytes instead of lines. +//! Another method of rotation is by bytes instead of lines, with [ContentLimit::Bytes]. //! //! ``` -//! use file_rotate::{FileRotate, RotationMode}; +//! use file_rotate::{FileRotate, ContentLimit, suffix::AppendCount, compression::Compression}; //! use std::{fs, io::Write}; //! -//! fs::create_dir("target/my-log-directory-bytes"); +//! # let directory = tempfile::TempDir::new().unwrap(); +//! # let directory = directory.path(); +//! let log_path = directory.join("my-log-file"); //! -//! let mut log = FileRotate::new("target/my-log-directory-bytes/my-log-file", RotationMode::Bytes(5), 2); +//! let mut log = FileRotate::new( +//! "target/my-log-directory-bytes/my-log-file", +//! AppendCount::new(2), +//! ContentLimit::Bytes(5), +//! Compression::None, +//! None, +//! ); //! //! writeln!(log, "Test file"); //! -//! assert_eq!("Test ", fs::read_to_string("target/my-log-directory-bytes/my-log-file.0").unwrap()); +//! assert_eq!("Test ", fs::read_to_string(&log.log_paths()[0]).unwrap()); //! assert_eq!("file\n", fs::read_to_string("target/my-log-directory-bytes/my-log-file").unwrap()); //! //! fs::remove_dir_all("target/my-log-directory-bytes"); @@ -59,64 +74,209 @@ //! //! # Rotation Method # //! -//! The rotation method used is to always write to the base path, and then move the file to a new -//! location when the limit is exceeded. The moving occurs in the sequence 0, 1, 2, n, 0, 1, 2... +//! Two rotation methods are provided, but any behaviour can be implemented with the [SuffixScheme] +//! trait. +//! +//! ## Basic count ## +//! +//! With [AppendCount], when the limit is reached in the main log file, the file is moved with +//! suffix `.1`, and subsequently numbered files are moved in a cascade. //! //! Here's an example with 1 byte limits: //! //! ``` -//! use file_rotate::{FileRotate, RotationMode}; +//! use file_rotate::{FileRotate, ContentLimit, suffix::AppendCount, compression::Compression}; //! use std::{fs, io::Write}; //! -//! fs::create_dir("target/my-log-directory-small"); +//! # let directory = tempfile::TempDir::new().unwrap(); +//! # let directory = directory.path(); +//! let log_path = directory.join("my-log-file"); //! -//! let mut log = FileRotate::new("target/my-log-directory-small/my-log-file", RotationMode::Bytes(1), 3); +//! let mut log = FileRotate::new( +//! log_path.clone(), +//! AppendCount::new(3), +//! ContentLimit::Bytes(1), +//! Compression::None, +//! None, +//! ); //! //! write!(log, "A"); -//! assert_eq!("A", fs::read_to_string("target/my-log-directory-small/my-log-file").unwrap()); +//! assert_eq!("A", fs::read_to_string(&log_path).unwrap()); //! //! write!(log, "B"); -//! assert_eq!("A", fs::read_to_string("target/my-log-directory-small/my-log-file.0").unwrap()); -//! assert_eq!("B", fs::read_to_string("target/my-log-directory-small/my-log-file").unwrap()); +//! assert_eq!("A", fs::read_to_string(directory.join("my-log-file.1")).unwrap()); +//! assert_eq!("B", fs::read_to_string(&log_path).unwrap()); //! //! write!(log, "C"); -//! assert_eq!("A", fs::read_to_string("target/my-log-directory-small/my-log-file.0").unwrap()); -//! assert_eq!("B", fs::read_to_string("target/my-log-directory-small/my-log-file.1").unwrap()); -//! assert_eq!("C", fs::read_to_string("target/my-log-directory-small/my-log-file").unwrap()); +//! assert_eq!("A", fs::read_to_string(directory.join("my-log-file.2")).unwrap()); +//! assert_eq!("B", fs::read_to_string(directory.join("my-log-file.1")).unwrap()); +//! assert_eq!("C", fs::read_to_string(&log_path).unwrap()); //! //! write!(log, "D"); -//! assert_eq!("A", fs::read_to_string("target/my-log-directory-small/my-log-file.0").unwrap()); -//! assert_eq!("B", fs::read_to_string("target/my-log-directory-small/my-log-file.1").unwrap()); -//! assert_eq!("C", fs::read_to_string("target/my-log-directory-small/my-log-file.2").unwrap()); -//! assert_eq!("D", fs::read_to_string("target/my-log-directory-small/my-log-file").unwrap()); +//! assert_eq!("A", fs::read_to_string(directory.join("my-log-file.3")).unwrap()); +//! assert_eq!("B", fs::read_to_string(directory.join("my-log-file.2")).unwrap()); +//! assert_eq!("C", fs::read_to_string(directory.join("my-log-file.1")).unwrap()); +//! assert_eq!("D", fs::read_to_string(&log_path).unwrap()); //! //! write!(log, "E"); -//! assert_eq!("A", fs::read_to_string("target/my-log-directory-small/my-log-file.0").unwrap()); -//! assert_eq!("B", fs::read_to_string("target/my-log-directory-small/my-log-file.1").unwrap()); -//! assert_eq!("C", fs::read_to_string("target/my-log-directory-small/my-log-file.2").unwrap()); -//! assert_eq!("D", fs::read_to_string("target/my-log-directory-small/my-log-file.3").unwrap()); -//! assert_eq!("E", fs::read_to_string("target/my-log-directory-small/my-log-file").unwrap()); -//! -//! -//! // Here we overwrite the 0 file since we're out of log files, restarting the sequencing -//! write!(log, "F"); -//! assert_eq!("E", fs::read_to_string("target/my-log-directory-small/my-log-file.0").unwrap()); -//! assert_eq!("B", fs::read_to_string("target/my-log-directory-small/my-log-file.1").unwrap()); -//! assert_eq!("C", fs::read_to_string("target/my-log-directory-small/my-log-file.2").unwrap()); -//! assert_eq!("D", fs::read_to_string("target/my-log-directory-small/my-log-file.3").unwrap()); -//! assert_eq!("F", fs::read_to_string("target/my-log-directory-small/my-log-file").unwrap()); -//! -//! fs::remove_dir_all("target/my-log-directory-small"); +//! assert_eq!("B", fs::read_to_string(directory.join("my-log-file.3")).unwrap()); +//! assert_eq!("C", fs::read_to_string(directory.join("my-log-file.2")).unwrap()); +//! assert_eq!("D", fs::read_to_string(directory.join("my-log-file.1")).unwrap()); +//! assert_eq!("E", fs::read_to_string(&log_path).unwrap()); +//! ``` +//! +//! ## Timestamp suffix ## +//! +//! With [AppendTimestamp], when the limit is reached in the main log file, the file is moved with +//! suffix equal to the current timestamp (with the specified or a default format). If the +//! destination file name already exists, `.1` (and up) is appended. +//! +//! Note that this works somewhat different to `AppendCount` because of lexical ordering concerns: +//! Higher numbers mean more recent logs, whereas `AppendCount` works in the opposite way. +//! The reason for this is to keep the lexical ordering of log names consistent: Higher lexical value +//! means more recent. +//! This is of course all assuming that the format start with the year (or most significant +//! component). +//! +//! With this suffix scheme, you can also decide whether to delete old files based on the age of +//! their timestamp ([FileLimit::Age]), or just maximum number of files ([FileLimit::MaxFiles]). +//! +//! ``` +//! use file_rotate::{FileRotate, ContentLimit, suffix::{AppendTimestamp, FileLimit}, +//! compression::Compression}; +//! use std::{fs, io::Write}; +//! +//! # let directory = tempfile::TempDir::new().unwrap(); +//! # let directory = directory.path(); +//! let log_path = directory.join("my-log-file"); +//! +//! let mut log = FileRotate::new( +//! log_path.clone(), +//! AppendTimestamp::default(FileLimit::MaxFiles(2)), +//! ContentLimit::Bytes(1), +//! Compression::None, +//! None, +//! ); +//! +//! write!(log, "A"); +//! assert_eq!("A", fs::read_to_string(&log_path).unwrap()); +//! +//! write!(log, "B"); +//! assert_eq!("A", fs::read_to_string(&log.log_paths()[0]).unwrap()); +//! assert_eq!("B", fs::read_to_string(&log_path).unwrap()); +//! +//! write!(log, "C"); +//! assert_eq!("A", fs::read_to_string(&log.log_paths()[0]).unwrap()); +//! assert_eq!("B", fs::read_to_string(&log.log_paths()[1]).unwrap()); +//! assert_eq!("C", fs::read_to_string(&log_path).unwrap()); +//! +//! write!(log, "D"); +//! assert_eq!("B", fs::read_to_string(&log.log_paths()[0]).unwrap()); +//! assert_eq!("C", fs::read_to_string(&log.log_paths()[1]).unwrap()); +//! assert_eq!("D", fs::read_to_string(&log_path).unwrap()); +//! ``` +//! +//! If you use timestamps as suffix, you can also configure files to be removed as they reach a +//! certain age. For example: +//! ```rust +//! use file_rotate::suffix::{AppendTimestamp, FileLimit}; +//! AppendTimestamp::default(FileLimit::Age(chrono::Duration::weeks(1))); +//! ``` +//! +//! # Compression # +//! +//! Select a [Compression] mode to make the file rotater compress old files using flate2. +//! Compressed files get an additional suffix `.gz` after the main suffix. +//! +//! ## Compression example ## +//! If we run this: +//! +//! ```ignore +//! use file_rotate::{compression::*, suffix::*, *}; +//! use std::io::Write; +//! +//! let mut log = FileRotate::new( +//! "./log", +//! AppendTimestamp::default(FileLimit::MaxFiles(4)), +//! ContentLimit::Bytes(1), +//! Compression::OnRotate(2), +//! None, +//! ); +//! +//! for i in 0..6 { +//! write!(log, "{}", i).unwrap(); +//! std::thread::sleep(std::time::Duration::from_secs(1)); +//! } +//! ``` +//! The following files will be created: +//! ```ignore +//! log log.20220112T112415.gz log.20220112T112416.gz log.20220112T112417 log.20220112T112418 +//! ``` +//! And we can assemble all the available log data with: +//! ```ignore +//! $ gunzip -c log.20220112T112415.gz ; gunzip -c log.20220112T112416.gz ; cat log.20220112T112417 log.20220112T112418 log +//! 12345 +//! ``` +//! +//! +//! ## Get structured list of log files ## +//! +//! We can programmatically get the list of log files. +//! The following code scans the current directory and recognizes log files based on their file name: +//! +//! ``` +//! # use file_rotate::{suffix::*, *}; +//! # use std::path::Path; +//! println!( +//! "{:#?}", +//! AppendTimestamp::default(FileLimit::MaxFiles(4)).scan_suffixes(Path::new("./log")) +//! ); //! ``` //! +//! [SuffixScheme::scan_suffixes] also takes into account the possibility of the extra `.gz` suffix, and +//! interprets it correctly as compression. The output: +//! +//! ```ignore +//! { +//! SuffixInfo { +//! suffix: TimestampSuffix { +//! timestamp: "20220112T112418", +//! number: None, +//! }, +//! compressed: false, +//! }, +//! SuffixInfo { +//! suffix: TimestampSuffix { +//! timestamp: "20220112T112417", +//! number: None, +//! }, +//! compressed: false, +//! }, +//! SuffixInfo { +//! suffix: TimestampSuffix { +//! timestamp: "20220112T112416", +//! number: None, +//! }, +//! compressed: true, +//! }, +//! SuffixInfo { +//! suffix: TimestampSuffix { +//! timestamp: "20220112T112415", +//! number: None, +//! }, +//! compressed: true, +//! }, +//! } +//! ``` +//! This information can be used by for example a program to assemble log history. +//! //! # Filesystem Errors # //! //! If the directory containing the logs is deleted or somehow made inaccessible then the rotator //! will simply continue operating without fault. When a rotation occurs, it attempts to open a //! file in the directory. If it can, it will just continue logging. If it can't then the written -//! date is sent to the void. -//! -//! This logger never panics. +//! data is sent to the void. + #![deny( missing_docs, trivial_casts, @@ -126,120 +286,412 @@ unused_qualifications )] +use chrono::prelude::*; +use compression::*; +use std::io::{BufRead, BufReader}; use std::{ - fs::{self, File}, + cmp::Ordering, + collections::BTreeSet, + fs::{self, File, OpenOptions}, io::{self, Write}, path::{Path, PathBuf}, }; +use suffix::*; + +pub mod compression; +pub mod suffix; +#[cfg(test)] +mod tests; // --- -/// Condition on which a file is rotated. -pub enum RotationMode { +/// At which frequency to rotate the file. +#[derive(Clone, Copy, Debug)] +pub enum TimeFrequency { + /// Rotate every hour. + Hourly, + /// Rotate one time a day. + Daily, + /// Rotate ones a week. + Weekly, + /// Rotate every month. + Monthly, + /// Rotate yearly. + Yearly, +} + +/// When to move files: Condition on which a file is rotated. +#[derive(Clone, Debug)] +pub enum ContentLimit { /// Cut the log at the exact size in bytes. Bytes(usize), /// Cut the log file at line breaks. Lines(usize), + /// Cut the log at time interval. + Time(TimeFrequency), /// Cut the log file after surpassing size in bytes (but having written a complete buffer from a write call.) BytesSurpassed(usize), + /// Don't do any rotation automatically + None, +} + +/// Used mostly internally. Info about suffix + compressed state. +#[derive(Clone, Debug, Eq)] +pub struct SuffixInfo { + /// Suffix + pub suffix: Repr, + /// Whether there is a `.gz` suffix after the suffix + pub compressed: bool, +} +impl PartialEq for SuffixInfo { + fn eq(&self, other: &Self) -> bool { + self.suffix == other.suffix + } +} + +impl SuffixInfo { + /// Append this suffix (and eventual `.gz`) to a path + pub fn to_path(&self, basepath: &Path) -> PathBuf { + let path = self.suffix.to_path(basepath); + if self.compressed { + PathBuf::from(format!("{}.gz", path.display())) + } else { + path + } + } +} + +impl Ord for SuffixInfo { + fn cmp(&self, other: &Self) -> Ordering { + self.suffix.cmp(&other.suffix) + } +} +impl PartialOrd for SuffixInfo { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } } /// The main writer used for rotating logs. -pub struct FileRotate { - basename: PathBuf, - count: usize, +#[derive(Debug)] +pub struct FileRotate { + basepath: PathBuf, file: Option, - file_number: usize, - max_file_number: usize, - mode: RotationMode, + modified: Option>, + content_limit: ContentLimit, + count: usize, + compression: Compression, + suffix_scheme: S, + /// The bool is whether or not there's a .gz suffix to the filename + suffixes: BTreeSet>, + open_options: Option, } -impl FileRotate { +impl FileRotate { /// Create a new [FileRotate]. /// /// The basename of the `path` is used to create new log files by appending an extension of the - /// form `.N`, where N is `0..=max_file_number`. + /// form `.N`, where N is `0..=max_files`. /// - /// `rotation_mode` specifies the limits for rotating a file. + /// `content_limit` specifies the limits for rotating a file. + /// + /// `open_options`: If provided, you must set `.read(true).create(true).append(true)`! /// /// # Panics /// /// Panics if `bytes == 0` or `lines == 0`. pub fn new>( path: P, - rotation_mode: RotationMode, - max_file_number: usize, + suffix_scheme: S, + content_limit: ContentLimit, + compression: Compression, + open_options: Option, ) -> Self { - match rotation_mode { - RotationMode::Bytes(bytes) => { + match content_limit { + ContentLimit::Bytes(bytes) => { assert!(bytes > 0); - }, - RotationMode::Lines(lines) => { + } + ContentLimit::Lines(lines) => { assert!(lines > 0); - }, - RotationMode::BytesSurpassed(bytes) => { + } + ContentLimit::Time(_) => {} + ContentLimit::BytesSurpassed(bytes) => { assert!(bytes > 0); - }, + } + ContentLimit::None => {} }; - Self { - basename: path.as_ref().to_path_buf(), + let basepath = path.as_ref().to_path_buf(); + fs::create_dir_all(basepath.parent().unwrap()).expect("create dir"); + + let mut s = Self { + file: None, + modified: None, + basepath, + content_limit, count: 0, - file: match File::create(&path) { - Ok(file) => Some(file), - Err(_) => None, - }, - file_number: 0, - max_file_number, - mode: rotation_mode, + compression, + suffixes: BTreeSet::new(), + suffix_scheme, + open_options, + }; + s.ensure_log_directory_exists(); + s.scan_suffixes(); + + s + } + fn ensure_log_directory_exists(&mut self) { + let path = self.basepath.parent().unwrap(); + if !path.exists() { + let _ = fs::create_dir_all(path).expect("create dir"); + self.scan_suffixes(); } + if !self.basepath.exists() || self.file.is_none() { + // Open or create the file + self.open_file(); + + match self.file { + None => self.count = 0, + Some(ref mut file) => { + match self.content_limit { + ContentLimit::Bytes(_) | ContentLimit::BytesSurpassed(_) => { + // Update byte `count` + if let Ok(metadata) = file.metadata() { + self.count = metadata.len() as usize; + } else { + self.count = 0; + } + } + ContentLimit::Lines(_) => { + self.count = BufReader::new(file).lines().count(); + } + ContentLimit::Time(_) => { + self.modified = mtime(file); + } + ContentLimit::None => {} + } + } + } + } + } + + fn open_file(&mut self) { + let open_options = self.open_options.clone().unwrap_or_else(|| { + let mut o = OpenOptions::new(); + o.read(true).create(true).append(true); + o + }); + + self.file = open_options.open(&self.basepath).ok(); + } + + fn scan_suffixes(&mut self) { + self.suffixes = self.suffix_scheme.scan_suffixes(&self.basepath); + } + /// Get paths of rotated log files (excluding the original/current log file), ordered from + /// oldest to most recent + pub fn log_paths(&mut self) -> Vec { + self.suffixes + .iter() + .rev() + .map(|suffix| suffix.to_path(&self.basepath)) + .collect::>() + } + + /// Recursive function that keeps moving files if there's any file name collision. + /// If `suffix` is `None`, it moves from basepath to next suffix given by the SuffixScheme + /// Assumption: Any collision in file name is due to an old log file. + /// + /// Returns the suffix of the new file (the last suffix after possible cascade of renames). + fn move_file_with_suffix( + &mut self, + old_suffix_info: Option>, + ) -> io::Result> { + // NOTE: this newest_suffix is there only because AppendTimestamp specifically needs + // it. Otherwise it might not be necessary to provide this to `rotate_file`. We could also + // have passed the internal BTreeMap itself, but it would require to make SuffixInfo `pub`. + + let newest_suffix = self.suffixes.iter().next().map(|info| &info.suffix); + + let new_suffix = self.suffix_scheme.rotate_file( + &self.basepath, + newest_suffix, + &old_suffix_info.clone().map(|i| i.suffix), + )?; + + // The destination file/path eventual .gz suffix must match the source path + let new_suffix_info = SuffixInfo { + suffix: new_suffix, + compressed: old_suffix_info + .as_ref() + .map(|x| x.compressed) + .unwrap_or(false), + }; + let new_path = new_suffix_info.to_path(&self.basepath); + + // Whatever exists that would block a move to the new suffix + let existing_suffix_info = self.suffixes.get(&new_suffix_info).cloned(); + + // Move destination file out of the way if it exists + let newly_created_suffix = if let Some(existing_suffix_info) = existing_suffix_info { + // We might move files in a way that the destination path doesn't equal the path that + // was replaced. Due to possible `.gz`, a "conflicting" file doesn't mean that paths + // are equal. + self.suffixes.replace(new_suffix_info); + // Recurse to move conflicting file. + self.move_file_with_suffix(Some(existing_suffix_info))? + } else { + new_suffix_info + }; + + let old_path = match old_suffix_info { + Some(suffix) => suffix.to_path(&self.basepath), + None => self.basepath.clone(), + }; + + // Do the move + assert!(old_path.exists()); + assert!(!new_path.exists()); + fs::rename(old_path, new_path)?; + + Ok(newly_created_suffix) } - fn rotate(&mut self) -> io::Result<()> { - let mut path = self.basename.clone(); - path.set_extension(self.file_number.to_string()); + /// Trigger a log rotation manually. This is mostly intended for use with `ContentLimit::None` + /// but will work with all content limits. + pub fn rotate(&mut self) -> io::Result<()> { + self.ensure_log_directory_exists(); let _ = self.file.take(); - let _ = fs::rename(&self.basename, path); - self.file = Some(File::create(&self.basename)?); + // This function will always create a new file. Returns suffix of that file + let new_suffix_info = self.move_file_with_suffix(None)?; + self.suffixes.insert(new_suffix_info); + + self.open_file(); - self.file_number = (self.file_number + 1) % (self.max_file_number + 1); self.count = 0; + self.handle_old_files()?; + Ok(()) } + fn handle_old_files(&mut self) -> io::Result<()> { + // Find the youngest suffix that is too old, and then remove all suffixes that are older or + // equally old: + let mut youngest_old = None; + // Start from oldest suffix, stop when we find a suffix that is not too old + let mut result = Ok(()); + for (i, suffix) in self.suffixes.iter().enumerate().rev() { + if self.suffix_scheme.too_old(&suffix.suffix, i) { + result = result.and(fs::remove_file(suffix.to_path(&self.basepath))); + youngest_old = Some((*suffix).clone()); + } else { + break; + } + } + if let Some(youngest_old) = youngest_old { + // Removes all the too old + let _ = self.suffixes.split_off(&youngest_old); + } + + // Compression + if let Compression::OnRotate(max_file_n) = self.compression { + let n = (self.suffixes.len() as i32 - max_file_n as i32).max(0) as usize; + // The oldest N files should be compressed + let suffixes_to_compress = self + .suffixes + .iter() + .rev() + .take(n) + .filter(|info| !info.compressed) + .cloned() + .collect::>(); + for info in suffixes_to_compress { + // Do the compression + let path = info.suffix.to_path(&self.basepath); + compress(&path)?; + + self.suffixes.replace(SuffixInfo { + compressed: true, + ..info + }); + } + } + + result + } } -impl Write for FileRotate { +impl Write for FileRotate { fn write(&mut self, mut buf: &[u8]) -> io::Result { let written = buf.len(); - match self.mode { - RotationMode::Bytes(bytes) => { + match self.content_limit { + ContentLimit::Bytes(bytes) => { while self.count + buf.len() > bytes { - let bytes_left = bytes - self.count; - if let Some(Err(err)) = self - .file - .as_mut() - .map(|file| file.write(&buf[..bytes_left])) - { - return Err(err); + let bytes_left = bytes.saturating_sub(self.count); + if let Some(ref mut file) = self.file { + file.write_all(&buf[..bytes_left])?; } self.rotate()?; buf = &buf[bytes_left..]; } self.count += buf.len(); - if let Some(Err(err)) = self.file.as_mut().map(|file| file.write(&buf[..])) { - return Err(err); + if let Some(ref mut file) = self.file { + file.write_all(buf)?; } } - RotationMode::Lines(lines) => { + ContentLimit::Time(time) => { + let local: DateTime = now(); + + if let Some(modified) = self.modified { + match time { + TimeFrequency::Hourly => { + if local.hour() != modified.hour() + || local.day() != modified.day() + || local.month() != modified.month() + || local.year() != modified.year() + { + self.rotate()?; + } + } + TimeFrequency::Daily => { + if local.date() > modified.date() { + self.rotate()?; + } + } + TimeFrequency::Weekly => { + if local.iso_week().week() != modified.iso_week().week() + || local.year() > modified.year() + { + self.rotate()?; + } + } + TimeFrequency::Monthly => { + if local.month() != modified.month() || local.year() != modified.year() + { + self.rotate()?; + } + } + TimeFrequency::Yearly => { + if local.year() > modified.year() { + self.rotate()?; + } + } + } + } + + if let Some(ref mut file) = self.file { + file.write_all(buf)?; + + self.modified = Some(local); + } + } + ContentLimit::Lines(lines) => { while let Some((idx, _)) = buf.iter().enumerate().find(|(_, byte)| *byte == &b'\n') { - if let Some(Err(err)) = - self.file.as_mut().map(|file| file.write(&buf[..idx + 1])) - { - return Err(err); + if let Some(ref mut file) = self.file { + file.write_all(&buf[..idx + 1])?; } self.count += 1; buf = &buf[idx + 1..]; @@ -247,143 +699,78 @@ impl Write for FileRotate { self.rotate()?; } } - if let Some(Err(err)) = self.file.as_mut().map(|file| file.write(buf)) { - return Err(err); + if let Some(ref mut file) = self.file { + file.write_all(buf)?; } - }, - RotationMode::BytesSurpassed(bytes) => { - if let Some(Err(err)) = self - .file - .as_mut() - .map(|file| file.write(&buf)) - { - return Err(err); - } - self.count += buf.len(); + } + ContentLimit::BytesSurpassed(bytes) => { if self.count > bytes { self.rotate()? } + if let Some(ref mut file) = self.file { + file.write_all(buf)?; + } + self.count += buf.len(); + } + ContentLimit::None => { + if let Some(ref mut file) = self.file { + file.write_all(buf)?; + } } } Ok(written) } fn flush(&mut self) -> io::Result<()> { - if let Some(Err(err)) = self.file.as_mut().map(|file| file.flush()) { - Err(err) - } else { - Ok(()) - } + self.file + .as_mut() + .map(|file| file.flush()) + .unwrap_or(Ok(())) } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - #[should_panic(expected = "assertion failed: bytes > 0")] - fn zero_bytes() { - let mut rot = FileRotate::new("target/zero_bytes", RotationMode::Bytes(0), 0); - writeln!(rot, "Zero").unwrap(); - assert_eq!("\n", fs::read_to_string("target/zero_bytes").unwrap()); - assert_eq!("o", fs::read_to_string("target/zero_bytes.0").unwrap()); - } - - #[test] - #[should_panic(expected = "assertion failed: lines > 0")] - fn zero_lines() { - let mut rot = FileRotate::new("target/zero_lines", RotationMode::Lines(0), 0); - write!(rot, "a\nb\nc\nd\n").unwrap(); - assert_eq!("", fs::read_to_string("target/zero_lines").unwrap()); - assert_eq!("d\n", fs::read_to_string("target/zero_lines.0").unwrap()); - } - - #[test] - fn rotate_to_deleted_directory() { - let _ = fs::remove_dir_all("target/rotate"); - fs::create_dir("target/rotate").unwrap(); - - let mut rot = FileRotate::new("target/rotate/log", RotationMode::Lines(1), 0); - writeln!(rot, "a").unwrap(); - assert_eq!("", fs::read_to_string("target/rotate/log").unwrap()); - assert_eq!("a\n", fs::read_to_string("target/rotate/log.0").unwrap()); - - fs::remove_dir_all("target/rotate").unwrap(); - - assert!(writeln!(rot, "b").is_err()); - - rot.flush().unwrap(); - assert!(fs::read_dir("target/rotate").is_err()); - fs::create_dir("target/rotate").unwrap(); - - writeln!(rot, "c").unwrap(); - assert_eq!("", fs::read_to_string("target/rotate/log").unwrap()); - - writeln!(rot, "d").unwrap(); - assert_eq!("", fs::read_to_string("target/rotate/log").unwrap()); - assert_eq!("d\n", fs::read_to_string("target/rotate/log.0").unwrap()); +/// Get modification time, in non test case. +#[cfg(not(test))] +fn mtime(file: &File) -> Option> { + if let Ok(time) = file.metadata().and_then(|metadata| metadata.modified()) { + return Some(time.into()); } - #[test] - fn write_complete_record_until_bytes_surpassed() { - let _ = fs::remove_dir_all("target/surpassed_bytes"); - fs::create_dir("target/surpassed_bytes").unwrap(); + None +} - let mut rot = FileRotate::new("target/surpassed_bytes/log", RotationMode::BytesSurpassed(1), 1); +/// Get modification time, in test case. +#[cfg(test)] +fn mtime(_: &File) -> Option> { + Some(now()) +} - write!(rot, "0123456789").unwrap(); - rot.flush().unwrap(); - assert!(Path::new("target/surpassed_bytes/log.0").exists()); - // shouldn't exist yet - because entire record was written in one shot - assert!(!Path::new("target/surpassed_bytes/log.1").exists()); +/// Get system time, in non test case. +#[cfg(not(test))] +fn now() -> DateTime { + Local::now() +} - // This should create the second file - write!(rot, "0123456789").unwrap(); - rot.flush().unwrap(); - assert!(Path::new("target/surpassed_bytes/log.1").exists()); +/// Get mocked system time, in test case. +#[cfg(test)] +pub mod mock_time { + use super::*; + use std::cell::RefCell; - fs::remove_dir_all("target/surpassed_bytes").unwrap(); + thread_local! { + static MOCK_TIME: RefCell>> = RefCell::new(None); } - #[quickcheck_macros::quickcheck] - fn arbitrary_lines(count: usize) { - let _ = fs::remove_dir_all("target/arbitrary_lines"); - fs::create_dir("target/arbitrary_lines").unwrap(); - - let count = count.max(1); - let mut rot = FileRotate::new("target/arbitrary_lines/log", RotationMode::Lines(count), 0); - - for _ in 0..count - 1 { - writeln!(rot).unwrap(); - } - - rot.flush().unwrap(); - assert!(!Path::new("target/arbitrary_lines/log.0").exists()); - writeln!(rot).unwrap(); - assert!(Path::new("target/arbitrary_lines/log.0").exists()); - - fs::remove_dir_all("target/arbitrary_lines").unwrap(); + /// Get current _mocked_ time + pub fn now() -> DateTime { + MOCK_TIME.with(|cell| cell.borrow().as_ref().cloned().unwrap_or_else(Local::now)) } - #[quickcheck_macros::quickcheck] - fn arbitrary_bytes() { - let _ = fs::remove_dir_all("target/arbitrary_bytes"); - fs::create_dir("target/arbitrary_bytes").unwrap(); - - let count = 0.max(1); - let mut rot = FileRotate::new("target/arbitrary_bytes/log", RotationMode::Bytes(count), 0); - - for _ in 0..count { - write!(rot, "0").unwrap(); - } - - rot.flush().unwrap(); - assert!(!Path::new("target/arbitrary_bytes/log.0").exists()); - write!(rot, "1").unwrap(); - assert!(Path::new("target/arbitrary_bytes/log.0").exists()); - - fs::remove_dir_all("target/arbitrary_bytes").unwrap(); + /// Set mocked time + pub fn set_mock_time(time: DateTime) { + MOCK_TIME.with(|cell| *cell.borrow_mut() = Some(time)); } - } + +#[cfg(test)] +pub use mock_time::now; diff --git a/src/suffix.rs b/src/suffix.rs new file mode 100644 index 0000000..b7279f3 --- /dev/null +++ b/src/suffix.rs @@ -0,0 +1,442 @@ +//! Suffix schemes determine the suffix of rotated files +//! +//! This behaviour is fully extensible through the [SuffixScheme] trait, and two behaviours are +//! provided: [AppendCount] and [AppendTimestamp] +//! +use super::now; +use crate::SuffixInfo; +use chrono::{format::ParseErrorKind, offset::Local, Duration, NaiveDateTime}; +use std::{ + cmp::Ordering, + collections::BTreeSet, + io, + path::{Path, PathBuf}, +}; + +/// Representation of a suffix +/// `Ord + PartialOrd`: sort by age of the suffix. Most recent first (smallest). +pub trait Representation: Ord + ToString + Eq + Clone + std::fmt::Debug { + /// Create path + fn to_path(&self, basepath: &Path) -> PathBuf { + PathBuf::from(format!("{}.{}", basepath.display(), self.to_string())) + } +} + +/// How to move files: How to rename, when to delete. +pub trait SuffixScheme { + /// The representation of suffixes that this suffix scheme uses. + /// E.g. if the suffix is a number, you can use `usize`. + type Repr: Representation; + + /// `file-rotate` calls this function when the file at `suffix` needs to be rotated, and moves the log file + /// accordingly. Thus, this function should not move any files itself. + /// + /// If `suffix` is `None`, it means it's the main log file (with path equal to just `basepath`) + /// that is being rotated. + /// + /// Returns the target suffix that the log file should be moved to. + /// If the target suffix already exists, `rotate_file` is called again with `suffix` set to the + /// target suffix. Thus it cascades files by default, and if this is not desired, it's up to + /// `rotate_file` to return a suffix that does not already exist on disk. + /// + /// `newest_suffix` is provided just in case it's useful (depending on the particular suffix scheme, it's not always useful) + fn rotate_file( + &mut self, + basepath: &Path, + newest_suffix: Option<&Self::Repr>, + suffix: &Option, + ) -> io::Result; + + /// Parse suffix from string. + fn parse(&self, suffix: &str) -> Option; + + /// Whether either the suffix or the chronological file number indicates that the file is old + /// and should be deleted, depending of course on the file limit. + /// `file_number` starts at 0 for the most recent suffix. + fn too_old(&self, suffix: &Self::Repr, file_number: usize) -> bool; + + /// Find all files in the basepath.parent() directory that has path equal to basepath + a valid + /// suffix. Return sorted collection - sorted from most recent to oldest based on the + /// [Ord] implementation of `Self::Repr`. + fn scan_suffixes(&self, basepath: &Path) -> BTreeSet> { + let mut suffixes = BTreeSet::new(); + let filename_prefix = basepath + .file_name() + .expect("basepath.file_name()") + .to_string_lossy(); + + // We need the parent directory of the given basepath, but this should also work when the path + // only has one segment. Thus we prepend the current working dir if the path is relative: + let basepath = if basepath.is_relative() { + let mut path = std::env::current_dir().unwrap(); + path.push(basepath); + path + } else { + basepath.to_path_buf() + }; + + let parent = basepath.parent().unwrap(); + + let filenames = std::fs::read_dir(parent) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.path().is_file()) + .map(|entry| entry.file_name()); + for filename in filenames { + let filename = filename.to_string_lossy(); + if !filename.starts_with(&*filename_prefix) { + continue; + } + let (filename, compressed) = prepare_filename(&*filename); + let suffix_str = filename.strip_prefix(&format!("{}.", filename_prefix)); + if let Some(suffix) = suffix_str.and_then(|s| self.parse(s)) { + suffixes.insert(SuffixInfo { suffix, compressed }); + } + } + suffixes + } +} +fn prepare_filename(path: &str) -> (&str, bool) { + path.strip_suffix(".gz") + .map(|x| (x, true)) + .unwrap_or((path, false)) +} + +/// Append a number when rotating the file. +/// The greater the number, the older. The oldest files are deleted. +pub struct AppendCount { + max_files: usize, +} + +impl AppendCount { + /// New suffix scheme, deleting files when the number of rotated files (i.e. excluding the main + /// file) exceeds `max_files`. + /// For example, if `max_files` is 3, then the files `log`, `log.1`, `log.2`, `log.3` may exist + /// but not `log.4`. In other words, `max_files` determines the largest possible suffix number. + pub fn new(max_files: usize) -> Self { + Self { max_files } + } +} + +impl Representation for usize {} +impl SuffixScheme for AppendCount { + type Repr = usize; + fn rotate_file( + &mut self, + _basepath: &Path, + _: Option<&usize>, + suffix: &Option, + ) -> io::Result { + Ok(match suffix { + Some(suffix) => suffix + 1, + None => 1, + }) + } + fn parse(&self, suffix: &str) -> Option { + suffix.parse::().ok() + } + fn too_old(&self, _suffix: &usize, file_number: usize) -> bool { + file_number >= self.max_files + } +} + +/// Add timestamp from: +pub enum DateFrom { + /// Date yesterday, to represent the timestamps within the log file. + DateYesterday, + /// Date from hour ago, useful with rotate hourly. + DateHourAgo, + /// Date from now. + Now, +} + +/// Append current timestamp as suffix when rotating files. +/// If the timestamp already exists, an additional number is appended. +/// +/// Current limitations: +/// - Neither `format` nor the base filename can include the character `"."`. +/// - The `format` should ensure that the lexical and chronological orderings are the same +pub struct AppendTimestamp { + /// The format of the timestamp suffix + pub format: &'static str, + /// The file limit, e.g. when to delete an old file - by age (given by suffix) or by number of files + pub file_limit: FileLimit, + /// Add timestamp from DateFrom + pub date_from: DateFrom, +} + +impl AppendTimestamp { + /// With format `"%Y%m%dT%H%M%S"` + pub fn default(file_limit: FileLimit) -> Self { + Self { + format: "%Y%m%dT%H%M%S", + file_limit, + date_from: DateFrom::Now, + } + } + /// Create new AppendTimestamp suffix scheme + pub fn with_format(format: &'static str, file_limit: FileLimit, date_from: DateFrom) -> Self { + Self { + format, + file_limit, + date_from, + } + } +} + +/// Structured representation of the suffixes of AppendTimestamp. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TimestampSuffix { + /// The timestamp + pub timestamp: String, + /// Optional number suffix if two timestamp suffixes are the same + pub number: Option, +} +impl Representation for TimestampSuffix {} +impl Ord for TimestampSuffix { + fn cmp(&self, other: &Self) -> Ordering { + // Most recent = smallest (opposite as the timestamp Ord) + // Smallest = most recent. Thus, biggest timestamp first. And then biggest number + match other.timestamp.cmp(&self.timestamp) { + Ordering::Equal => other.number.cmp(&self.number), + unequal => unequal, + } + } +} +impl PartialOrd for TimestampSuffix { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl std::fmt::Display for TimestampSuffix { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + match self.number { + Some(n) => write!(f, "{}.{}", self.timestamp, n), + None => write!(f, "{}", self.timestamp), + } + } +} + +impl SuffixScheme for AppendTimestamp { + type Repr = TimestampSuffix; + + fn rotate_file( + &mut self, + _basepath: &Path, + newest_suffix: Option<&TimestampSuffix>, + suffix: &Option, + ) -> io::Result { + assert!(suffix.is_none()); + if suffix.is_none() { + let mut now = now(); + + match self.date_from { + DateFrom::DateYesterday => { + now = now - Duration::days(1); + } + DateFrom::DateHourAgo => { + now = now - Duration::hours(1); + } + _ => {} + }; + + let fmt_now = now.format(self.format).to_string(); + + let number = if let Some(newest_suffix) = newest_suffix { + if newest_suffix.timestamp == fmt_now { + Some(newest_suffix.number.unwrap_or(0) + 1) + } else { + None + } + } else { + None + }; + Ok(TimestampSuffix { + timestamp: fmt_now, + number, + }) + } else { + // This rotation scheme dictates that only the main log file should ever be renamed. + // In debug build the above assert will catch this. + Err(io::Error::new( + io::ErrorKind::InvalidData, + "Critical error in file-rotate algorithm", + )) + } + } + fn parse(&self, suffix: &str) -> Option { + let (timestamp_str, n) = if let Some(dot) = suffix.find('.') { + if let Ok(n) = suffix[(dot + 1)..].parse::() { + (&suffix[..dot], Some(n)) + } else { + return None; + } + } else { + (suffix, None) + }; + let success = match NaiveDateTime::parse_from_str(timestamp_str, self.format) { + Ok(_) => true, + Err(e) => e.kind() == ParseErrorKind::NotEnough, + }; + if success { + Some(TimestampSuffix { + timestamp: timestamp_str.to_string(), + number: n, + }) + } else { + None + } + } + fn too_old(&self, suffix: &TimestampSuffix, file_number: usize) -> bool { + match self.file_limit { + FileLimit::MaxFiles(max_files) => file_number >= max_files, + FileLimit::Age(age) => { + let old_timestamp = (Local::now() - age).format(self.format).to_string(); + suffix.timestamp < old_timestamp + } + FileLimit::Unlimited => false, + } + } +} + +/// How to determine whether a file should be deleted, in the case of [AppendTimestamp]. +pub enum FileLimit { + /// Delete the oldest files if number of files is too high + MaxFiles(usize), + /// Delete files whose age exceeds the `Duration` - age is determined by the suffix of the file + Age(Duration), + /// Never delete files + Unlimited, +} + +#[cfg(test)] +mod test { + use super::*; + use std::fs::File; + use tempfile::TempDir; + #[test] + fn timestamp_ordering() { + assert!( + TimestampSuffix { + timestamp: "2021".to_string(), + number: None + } < TimestampSuffix { + timestamp: "2020".to_string(), + number: None + } + ); + assert!( + TimestampSuffix { + timestamp: "2021".to_string(), + number: Some(1) + } < TimestampSuffix { + timestamp: "2021".to_string(), + number: None + } + ); + } + + #[test] + fn timestamp_scan_suffixes_base_paths() { + let working_dir = TempDir::new().unwrap(); + let working_dir = working_dir.path().join("dir"); + let suffix_scheme = AppendTimestamp::default(FileLimit::Age(Duration::weeks(1))); + + // Test `scan_suffixes` for different possible paths given to it + // (it used to have a bug taking e.g. "log".parent() --> panic) + for relative_path in ["logs/log", "./log", "log", "../log", "../logs/log"] { + std::fs::create_dir_all(&working_dir).unwrap(); + println!("Testing relative path: {}", relative_path); + let relative_path = Path::new(relative_path); + + let log_file = working_dir.join(relative_path); + let log_dir = log_file.parent().unwrap(); + // Ensure all directories needed exist + std::fs::create_dir_all(log_dir).unwrap(); + + // We cd into working_dir + std::env::set_current_dir(&working_dir).unwrap(); + + // Need to create the log file in order to canonicalize it and then get the parent + File::create(working_dir.join(&relative_path)).unwrap(); + let canonicalized = relative_path.canonicalize().unwrap(); + let relative_dir = canonicalized.parent().unwrap(); + + File::create(relative_dir.join("log.20210911T121830")).unwrap(); + File::create(relative_dir.join("log.20210911T121831.gz")).unwrap(); + + let paths = suffix_scheme.scan_suffixes(relative_path); + assert_eq!(paths.len(), 2); + + // Reset CWD: necessary on Windows only - otherwise we get the error: + // "The process cannot access the file because it is being used by another process." + // (code 32) + std::env::set_current_dir("/").unwrap(); + + // Cleanup + std::fs::remove_dir_all(&working_dir).unwrap(); + } + } + + #[test] + fn timestamp_scan_suffixes_formats() { + struct TestCase { + format: &'static str, + suffixes: &'static [&'static str], + incorrect_suffixes: &'static [&'static str], + } + + let cases = [ + TestCase { + format: "%Y%m%dT%H%M%S", + suffixes: &["20220201T101010", "20220202T101010"], + incorrect_suffixes: &["20220201T1010", "20220201T999999", "2022-02-02"], + }, + TestCase { + format: "%Y-%m-%d", + suffixes: &["2022-02-01", "2022-02-02"], + incorrect_suffixes: &[ + "abc", + "2022-99-99", + "2022-05", + "2022", + "20220202", + "2022-02-02T112233", + ], + }, + ]; + + for (i, case) in cases.iter().enumerate() { + println!("Case {}", i); + let tmp_dir = TempDir::new().unwrap(); + let dir = tmp_dir.path(); + let log_path = dir.join("file"); + + for suffix in case.suffixes.iter().chain(case.incorrect_suffixes) { + File::create(dir.join(format!("file.{}", suffix))).unwrap(); + } + + let scheme = AppendTimestamp::with_format( + case.format, + FileLimit::MaxFiles(1), + DateFrom::DateYesterday, + ); + + // Scan for suffixes + let suffixes_set = scheme.scan_suffixes(&log_path); + + // Collect these suffixes, and the expected suffixes, into Vec, and sort + let mut suffixes = suffixes_set + .into_iter() + .map(|x| x.suffix.to_string()) + .collect::>(); + suffixes.sort_unstable(); + + let mut expected_suffixes = case.suffixes.to_vec(); + expected_suffixes.sort_unstable(); + + assert_eq!(suffixes, case.suffixes); + println!("Passed\n"); + } + } +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..257d5c9 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,628 @@ +use super::{suffix::*, *}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use tempfile::TempDir; + +// Just useful to debug why test doesn't succeed +#[allow(dead_code)] +fn list(dir: &Path) { + let files = fs::read_dir(dir) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.path().is_file()) + .map(|entry| (entry.file_name(), fs::read_to_string(entry.path()))) + .collect::>(); + println!("Files on disk:"); + for (name, content) in files { + println!("{:?}: {:?}", name, content); + } +} + +#[test] +fn timestamp_max_files_rotation() { + let tmp_dir = TempDir::new().unwrap(); + let log_path = tmp_dir.path().join("log"); + + let mut log = FileRotate::new( + &log_path, + AppendTimestamp::default(FileLimit::MaxFiles(4)), + ContentLimit::Lines(2), + Compression::None, + None, + ); + + // Write 9 lines + // This should result in 5 files in total (4 rotated files). The main file will have one line. + write!(log, "a\nb\nc\nd\ne\nf\ng\nh\ni\n").unwrap(); + let log_paths = log.log_paths(); + assert_eq!(log_paths.len(), 4); + + // Log names should be sorted. Low (old timestamp) to high (more recent timestamp) + let mut log_paths_sorted = log_paths.clone(); + log_paths_sorted.sort(); + assert_eq!(log_paths, log_paths_sorted); + + assert_eq!("a\nb\n", fs::read_to_string(&log_paths[0]).unwrap()); + assert_eq!("c\nd\n", fs::read_to_string(&log_paths[1]).unwrap()); + assert_eq!("e\nf\n", fs::read_to_string(&log_paths[2]).unwrap()); + assert_eq!("g\nh\n", fs::read_to_string(&log_paths[3]).unwrap()); + assert_eq!("i\n", fs::read_to_string(&log_path).unwrap()); + + // Write 4 more lines + write!(log, "j\nk\nl\nm\n").unwrap(); + let log_paths = log.log_paths(); + assert_eq!(log_paths.len(), 4); + let mut log_paths_sorted = log_paths.clone(); + log_paths_sorted.sort(); + assert_eq!(log_paths, log_paths_sorted); + + list(tmp_dir.path()); + assert_eq!("e\nf\n", fs::read_to_string(&log_paths[0]).unwrap()); + assert_eq!("g\nh\n", fs::read_to_string(&log_paths[1]).unwrap()); + assert_eq!("i\nj\n", fs::read_to_string(&log_paths[2]).unwrap()); + assert_eq!("k\nl\n", fs::read_to_string(&log_paths[3]).unwrap()); + assert_eq!("m\n", fs::read_to_string(&log_path).unwrap()); +} +#[test] +fn timestamp_max_age_deletion() { + // In order not to have to sleep, and keep it deterministic, let's already create the log files and see how FileRotate + // cleans up the old ones. + let tmp_dir = TempDir::new().unwrap(); + let dir = tmp_dir.path(); + let log_path = dir.join("log"); + + // One recent file: + let recent_file = Local::now().format("log.%Y%m%dT%H%M%S").to_string(); + File::create(dir.join(&recent_file)).unwrap(); + // Two very old files: + File::create(dir.join("log.20200825T151133")).unwrap(); + File::create(dir.join("log.20200825T151133.1")).unwrap(); + + let mut log = FileRotate::new( + &*log_path.to_string_lossy(), + AppendTimestamp::default(FileLimit::Age(chrono::Duration::weeks(1))), + ContentLimit::Lines(1), + Compression::None, + None, + ); + writeln!(log, "trigger\nat\nleast\none\nrotation").unwrap(); + + let mut filenames = fs::read_dir(dir) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.path().is_file()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(); + filenames.sort(); + assert!(filenames.contains(&"log".to_string())); + assert!(filenames.contains(&recent_file)); + assert!(!filenames.contains(&"log.20200825T151133".to_string())); + assert!(!filenames.contains(&"log.20200825T151133.1".to_string())); +} +#[test] +fn count_max_files_rotation() { + let tmp_dir = TempDir::new().unwrap(); + let parent = tmp_dir.path(); + let log_path = parent.join("log"); + let mut log = FileRotate::new( + &*log_path.to_string_lossy(), + AppendCount::new(4), + ContentLimit::Lines(2), + Compression::None, + None, + ); + + // Write 9 lines + // This should result in 5 files in total (4 rotated files). The main file will have one line. + write!(log, "a\nb\nc\nd\ne\nf\ng\nh\ni\n").unwrap(); // 9 lines + let log_paths = vec![ + parent.join("log.4"), + parent.join("log.3"), + parent.join("log.2"), + parent.join("log.1"), + ]; + assert_eq!(log_paths, log.log_paths()); + assert_eq!("a\nb\n", fs::read_to_string(&log_paths[0]).unwrap()); + assert_eq!("c\nd\n", fs::read_to_string(&log_paths[1]).unwrap()); + assert_eq!("e\nf\n", fs::read_to_string(&log_paths[2]).unwrap()); + assert_eq!("g\nh\n", fs::read_to_string(&log_paths[3]).unwrap()); + assert_eq!("i\n", fs::read_to_string(&log_path).unwrap()); + + // Write 4 more lines + write!(log, "j\nk\nl\nm\n").unwrap(); + list(parent); + assert_eq!(log_paths, log.log_paths()); + + assert_eq!("e\nf\n", fs::read_to_string(&log_paths[0]).unwrap()); + assert_eq!("g\nh\n", fs::read_to_string(&log_paths[1]).unwrap()); + assert_eq!("i\nj\n", fs::read_to_string(&log_paths[2]).unwrap()); + assert_eq!("k\nl\n", fs::read_to_string(&log_paths[3]).unwrap()); + assert_eq!("m\n", fs::read_to_string(&log_path).unwrap()); +} + +#[test] +fn rotate_to_deleted_directory() { + let tmp_dir = TempDir::new().unwrap(); + let parent = tmp_dir.path(); + let log_path = parent.join("log"); + let mut log = FileRotate::new( + &*log_path.to_string_lossy(), + AppendCount::new(4), + ContentLimit::Lines(1), + Compression::None, + None, + ); + + write!(log, "a\nb\n").unwrap(); + assert_eq!("", fs::read_to_string(&log_path).unwrap()); + assert_eq!("a\n", fs::read_to_string(&log.log_paths()[0]).unwrap()); + + let _ = fs::remove_dir_all(parent); + + // Will fail to write `"c"` + writeln!(log, "c").unwrap(); + log.flush().unwrap(); + + // But the next `write` will succeed + writeln!(log, "d").unwrap(); + assert_eq!("", fs::read_to_string(&log_path).unwrap()); + assert_eq!("d\n", fs::read_to_string(&log.log_paths()[1]).unwrap()); +} + +#[test] +fn write_complete_record_until_bytes_surpassed() { + let tmp_dir = TempDir::new().unwrap(); + let dir = tmp_dir.path(); + let log_path = dir.join("log"); + + let mut log = FileRotate::new( + &log_path, + AppendTimestamp::default(FileLimit::MaxFiles(100)), + ContentLimit::BytesSurpassed(1), + Compression::None, + None, + ); + + write!(log, "0123456789").unwrap(); + log.flush().unwrap(); + assert!(log_path.exists()); + // shouldn't exist yet - because entire record was written in one shot + assert!(log.log_paths().is_empty()); + + // This should create the second file + write!(log, "0123456789").unwrap(); + log.flush().unwrap(); + assert!(&log.log_paths()[0].exists()); +} + +#[test] +fn compression_on_rotation() { + let tmp_dir = TempDir::new().unwrap(); + let parent = tmp_dir.path(); + let log_path = parent.join("log"); + let mut log = FileRotate::new( + &*log_path.to_string_lossy(), + AppendCount::new(3), + ContentLimit::Lines(1), + Compression::OnRotate(1), // Keep one file uncompressed + None, + ); + + writeln!(log, "A").unwrap(); + writeln!(log, "B").unwrap(); + writeln!(log, "C").unwrap(); + list(tmp_dir.path()); + + let log_paths = log.log_paths(); + + assert_eq!( + log_paths, + vec![ + parent.join("log.3.gz"), + parent.join("log.2.gz"), + parent.join("log.1"), + ] + ); + + assert_eq!("", fs::read_to_string(&log_path).unwrap()); + + fn compress(text: &str) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + + encoder.write_all(text.as_bytes()).unwrap(); + encoder.finish().unwrap() + } + assert_eq!(compress("A\n"), fs::read(&log.log_paths()[0]).unwrap()); + assert_eq!(compress("B\n"), fs::read(&log.log_paths()[1]).unwrap()); + assert_eq!("C\n", fs::read_to_string(&log.log_paths()[2]).unwrap()); +} + +#[test] +fn no_truncate() { + // Don't truncate log file if it already exists + let tmp_dir = TempDir::new().unwrap(); + let parent = tmp_dir.path(); + let log_path = parent.join("log"); + let file_rotate = || { + FileRotate::new( + &*log_path.to_string_lossy(), + AppendCount::new(3), + ContentLimit::Lines(10000), + Compression::None, + None, + ) + }; + writeln!(file_rotate(), "A").unwrap(); + list(parent); + writeln!(file_rotate(), "B").unwrap(); + list(parent); + + assert_eq!("A\nB\n", fs::read_to_string(&log_path).unwrap()); +} + +#[test] +fn byte_count_recalculation() { + // If there is already some content in the logging file, FileRotate should set its `count` + // field to the size of the file, so that it rotates at the right time + let tmp_dir = TempDir::new().unwrap(); + let parent = tmp_dir.path(); + let log_path = parent.join("log"); + + fs::write(&log_path, b"a").unwrap(); + + let mut file_rotate = FileRotate::new( + &*log_path.to_string_lossy(), + AppendCount::new(3), + ContentLimit::Bytes(2), + Compression::None, + None, + ); + + write!(file_rotate, "bc").unwrap(); + assert_eq!(file_rotate.log_paths().len(), 1); + // The size of the rotated file should be 2 ('ab) + let rotated_content = fs::read(&file_rotate.log_paths()[0]).unwrap(); + assert_eq!(rotated_content, b"ab"); + // The size of the main file should be 1 ('c') + let main_content = fs::read(log_path).unwrap(); + assert_eq!(main_content, b"c"); +} + +#[test] +fn line_count_recalculation() { + // If there is already some content in the logging file, FileRotate should set its `count` + // field to the line count of the file, so that it rotates at the right time + let tmp_dir = TempDir::new().unwrap(); + let parent = tmp_dir.path(); + let log_path = parent.join("log"); + + fs::write(&log_path, b"a\n").unwrap(); + + let mut file_rotate = FileRotate::new( + &*log_path.to_string_lossy(), + AppendCount::new(3), + ContentLimit::Lines(2), + Compression::None, + None, + ); + + // A single line existed before the new logger ('a') + assert_eq!(file_rotate.count, 1); + + writeln!(file_rotate, "b").unwrap(); + writeln!(file_rotate, "c").unwrap(); + + assert_eq!(file_rotate.log_paths().len(), 1); + + // The line count of the rotated file should be 2 ('a' & 'b') + let mut lines = BufReader::new(File::open(&file_rotate.log_paths()[0]).unwrap()).lines(); + assert_eq!(lines.next().unwrap().unwrap(), "a".to_string()); + assert_eq!(lines.next().unwrap().unwrap(), "b".to_string()); + + // The line count of the main file should be 1 ('c') + let mut lines = BufReader::new(File::open(&log_path).unwrap()).lines(); + assert_eq!(lines.next().unwrap().unwrap(), "c".to_string()); +} + +#[cfg(unix)] +#[test] +fn unix_file_permissions() { + use std::os::unix::fs::OpenOptionsExt; + let permissions = &[0o600, 0o644]; + + for permission in permissions { + let tmp_dir = TempDir::new().unwrap(); + let parent = tmp_dir.path(); + let log_path = parent.join("log"); + + let mut options = OpenOptions::new(); + options + .read(true) + .create(true) + .append(true) + .mode(*permission); + + let mut file_rotate = FileRotate::new( + &*log_path.to_string_lossy(), + AppendCount::new(3), + ContentLimit::Lines(2), + Compression::None, + Some(options), + ); + + // Trigger a rotation by writing three lines + writeln!(file_rotate, "a").unwrap(); + writeln!(file_rotate, "b").unwrap(); + writeln!(file_rotate, "c").unwrap(); + + assert_eq!(file_rotate.log_paths().len(), 1); + + // The file created at initialization time should have the right permissions ... + let metadata = fs::metadata(&log_path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, *permission); + + // ... and also the one generated through a rotation + let metadata = fs::metadata(&file_rotate.log_paths()[0]).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, *permission); + } +} + +#[test] +fn manual_rotation() { + // Check that manual rotation works as intented + let tmp_dir = TempDir::new().unwrap(); + let parent = tmp_dir.path(); + let log_path = parent.join("log"); + let mut log = FileRotate::new( + &*log_path.to_string_lossy(), + AppendCount::new(3), + ContentLimit::None, + Compression::None, + None, + ); + writeln!(log, "A").unwrap(); + log.rotate().unwrap(); + list(parent); + writeln!(log, "B").unwrap(); + list(parent); + + dbg!(log.log_paths()); + let logs = log.log_paths(); + assert_eq!(logs.len(), 1); + assert_eq!("A\n", fs::read_to_string(&logs[0]).unwrap()); + assert_eq!("B\n", fs::read_to_string(&log_path).unwrap()); +} + +#[quickcheck_macros::quickcheck] +fn arbitrary_lines(count: usize) { + let tmp_dir = TempDir::new().unwrap(); + let dir = tmp_dir.path(); + let log_path = dir.join("log"); + + let count = count.max(1); + let mut log = FileRotate::new( + &log_path, + AppendTimestamp::default(FileLimit::MaxFiles(100)), + ContentLimit::Lines(count), + Compression::None, + None, + ); + + for _ in 0..count - 1 { + writeln!(log).unwrap(); + } + + log.flush().unwrap(); + assert!(log.log_paths().is_empty()); + writeln!(log).unwrap(); + assert!(Path::new(&log.log_paths()[0]).exists()); +} + +#[quickcheck_macros::quickcheck] +fn arbitrary_bytes(count: usize) { + let tmp_dir = TempDir::new().unwrap(); + let dir = tmp_dir.path(); + let log_path = dir.join("log"); + + let count = count.max(1); + let mut log = FileRotate::new( + &log_path, + AppendTimestamp::default(FileLimit::MaxFiles(100)), + ContentLimit::Bytes(count), + Compression::None, + None, + ); + + for _ in 0..count { + write!(log, "0").unwrap(); + } + + log.flush().unwrap(); + assert!(log.log_paths().is_empty()); + write!(log, "1").unwrap(); + assert!(&log.log_paths()[0].exists()); +} + +#[test] +fn rotate_by_time_frequency() { + // Test time frequency by hours. + test_time_frequency( + "2022-05-03T06:00:12", + "2022-05-03T06:59:00", + "2022-05-03T07:01:00", + "2022-05-03_06-01-00", + TimeFrequency::Hourly, + DateFrom::DateHourAgo, + ); + + // Test time frequency by days. + test_time_frequency( + "2022-05-02T12:59:59", + "2022-05-02T23:01:15", + "2022-05-03T01:01:00", + "2022-05-02_01-01-00", + TimeFrequency::Daily, + DateFrom::DateYesterday, + ); + + // Test time frequency by weeks. + test_time_frequency( + "2022-05-02T12:34:02", + "2022-05-06T11:30:00", + "2022-05-09T13:01:00", + "2022-05-08_13-01-00", + TimeFrequency::Weekly, + DateFrom::DateYesterday, + ); + + // Test time frequency by months. + test_time_frequency( + "2022-03-01T11:50:01", + "2022-03-30T15:30:10", + "2022-04-02T05:03:50", + "2022-04-02_05-03-50", + TimeFrequency::Monthly, + DateFrom::Now, + ); + + // Test time frequency by year. + test_time_frequency( + "2021-08-31T12:34:02", + "2021-12-15T15:20:00", + "2022-09-02T13:01:00", + "2022-09-01_13-01-00", + TimeFrequency::Yearly, + DateFrom::DateYesterday, + ); +} + +#[test] +fn test_file_limit() { + let tmp_dir = TempDir::new().unwrap(); + let dir = tmp_dir.path(); + let log_path = dir.join("file"); + let old_file = dir.join("file.2022-02-01"); + + File::create(&old_file).unwrap(); + + let first = get_fake_date_time("2022-02-02T01:00:00"); + let second = get_fake_date_time("2022-02-03T01:00:00"); + let third = get_fake_date_time("2022-02-04T01:00:00"); + + let mut log = FileRotate::new( + log_path, + AppendTimestamp::with_format("%Y-%m-%d", FileLimit::MaxFiles(1), DateFrom::DateYesterday), + ContentLimit::Time(TimeFrequency::Daily), + Compression::None, + None, + ); + + mock_time::set_mock_time(first); + writeln!(log, "1").unwrap(); + mock_time::set_mock_time(second); + writeln!(log, "2").unwrap(); + mock_time::set_mock_time(third); + writeln!(log, "3").unwrap(); + + assert_eq!(log.log_paths(), [dir.join("file.2022-02-03")]); + assert!(!old_file.is_file()); +} + +#[test] +fn test_panic() { + use std::io::Write; + + let tmp_dir = TempDir::new().unwrap(); + let dir = tmp_dir.path(); + let log_path = dir.join("file"); + // write 9 bytes of data + { + let mut log = FileRotate::new( + &log_path, + AppendCount::new(2), + ContentLimit::None, + Compression::None, + None, + ); + + write!(log, "nineteen characters").unwrap(); + } + + // set content limit to less than the existing file size + let mut log = FileRotate::new( + &log_path, + AppendCount::new(2), + ContentLimit::Bytes(8), + Compression::None, + None, + ); + + write!(log, "0123").unwrap(); + + let log_paths = log.log_paths(); + assert_eq!( + "nineteen characters", + fs::read_to_string(&log_paths[0]).unwrap() + ); + assert_eq!("0123", fs::read_to_string(&log_path).unwrap()); +} + +fn get_fake_date_time(date_time: &str) -> DateTime { + let date_obj = NaiveDateTime::parse_from_str(date_time, "%Y-%m-%dT%H:%M:%S"); + + Local.from_local_datetime(&date_obj.unwrap()).unwrap() +} + +fn test_time_frequency( + old_time: &str, + second_old_time: &str, + new_time: &str, + test_suffix: &str, + frequency: TimeFrequency, + date_from: DateFrom, +) { + let old_time = get_fake_date_time(old_time); + let new_time = get_fake_date_time(new_time); + let second_old_time = get_fake_date_time(second_old_time); + let tmp_dir = TempDir::new().unwrap(); + let dir = tmp_dir.path(); + let log_path = dir.join("log"); + + mock_time::set_mock_time(old_time); + + let mut log = FileRotate::new( + &log_path, + AppendTimestamp::with_format("%Y-%m-%d_%H-%M-%S", FileLimit::MaxFiles(7), date_from), + ContentLimit::Time(frequency), + Compression::None, + None, + ); + + writeln!(log, "a").unwrap(); + log.flush().unwrap(); + + filetime::set_file_mtime( + log_path, + filetime::FileTime::from_system_time(old_time.into()), + ) + .unwrap(); + + mock_time::set_mock_time(second_old_time); + + writeln!(log, "b").unwrap(); + + mock_time::set_mock_time(new_time); + + writeln!(log, "c").unwrap(); + + assert!(&log.log_paths()[0].exists()); + assert_eq!( + log.log_paths()[0] + .display() + .to_string() + .split('.') + .collect::>() + .last(), + Some(&test_suffix) + ); +}