logs-wheel 0.3.1

Rolling log files with compression
Documentation
#![doc(html_favicon_url = "https://codeberg.org/mo8it/logs-wheel/raw/branch/main/wheel.svg")]
#![doc(html_logo_url = "https://codeberg.org/mo8it/logs-wheel/raw/branch/main/wheel.svg")]
#![doc = include_str!("../README.md")]

mod date_stamp;
mod formatters;
mod oldest_path_finder;
mod rolling;

use std::{
    fs::{create_dir_all, File, OpenOptions},
    io,
    path::Path,
};

use rolling::{delete_oldest_file_over_max, roll, RollingStatus};

/// The initializer of the log file.
/// See [`Self::init`].
pub struct LogFileInitializer<'a, P>
where
    P: AsRef<Path>,
{
    /// The directory where the log files will be placed in.
    ///
    /// The directory and its parent directories will be recursively created if they don't already exist while calling [`init`](Self::init)
    /// (equivalent to `mkdir -p`).
    ///
    /// The directory should be used exclusively for the log files.
    /// Other files in the directory will slow down the process of counting existing old files
    /// and finding the oldest one to delete if [`max_n_old_files`](Self::max_n_old_files) is exceeded.
    pub directory: P,

    /// The file name of the uncompressed log file that will be opened and returned (`directory/filename`).
    pub filename: &'a str,

    /// The maximum number of old (compressed) log files.
    ///
    /// The value 0 leads to directly returning the file in append mode.
    /// The value of [`preferred_max_file_size_mib`](Self::preferred_max_file_size_mib) will be ignored in that case.
    ///
    /// You shouldn't manually set [`max_n_old_files`](Self::max_n_old_files) to 0.
    /// If you don't want rolling, just create the directory and the file manually and open the file in append mode
    /// instead of having this library as a dependency.
    /// The value 0 is just supported as a fallback in case the user of your program wants to deactivate rolling.
    pub max_n_old_files: usize,

    /// The preferred maximum size of the uncompressed log file `directory/filename` in MiB.
    ///
    /// It is called the _preferred_ maximum file size because this file size can be exceeded before the next initialization.
    /// If the file size exceeds the preferred maximum, rolling will only happen if it was not already done on the same day (in UTC).
    pub preferred_max_file_size_mib: u64,
}

impl<'a, P> LogFileInitializer<'a, P>
where
    P: AsRef<Path>,
{
    /// Return a file at `directory/filename` for appending new logs.
    ///
    /// Rolling will be applied to the file `directory/file` if all the following conditions are true:
    /// - The file already exists.
    /// - The file has a size >= [`preferred_max_file_size_mib`](Self::preferred_max_file_size_mib) (in MiB).
    /// - No rolling was already done today.
    ///
    /// In the case of rolling, the file will be compressed with GZIP to `directory/filename-YYYYMMDD.gz`
    /// with today's date (in UTC).
    ///
    /// If rolling was applied and the number of old files exceeds [`max_n_old_files`](Self::max_n_old_files),
    /// the oldest file will be deleted.
    ///
    /// # Example
    /// ```
    /// # use logs_wheel::LogFileInitializer;
    /// let log_file = LogFileInitializer {
    ///   directory: "logs",
    ///   filename: "test",
    ///   max_n_old_files: 2,
    ///   preferred_max_file_size_mib: 1,
    /// }.init()?;
    /// # Ok::<(), std::io::Error>(())
    /// ```
    ///
    /// # Compatibility
    /// Only UTF8 paths are supported.
    pub fn init(self) -> io::Result<File> {
        let directory = self.directory.as_ref();
        create_dir_all(directory)?;

        let log_path = directory.join(self.filename);
        let append_or_new = || OpenOptions::new().append(true).create(true).open(&log_path);

        if self.max_n_old_files == 0 {
            return append_or_new();
        }

        let log_metadata = match log_path.metadata() {
            Ok(v) => v,
            Err(e) => match e.kind() {
                io::ErrorKind::NotFound => return append_or_new(),
                _ => return Err(e),
            },
        };

        let size_mib = log_metadata.len() >> 20;
        if size_mib < self.preferred_max_file_size_mib {
            return append_or_new();
        }

        match roll(&log_path, directory, self.filename)? {
            RollingStatus::Done => {
                delete_oldest_file_over_max(directory, self.filename, self.max_n_old_files)?;
                OpenOptions::new().write(true).truncate(true).open(log_path)
            }
            RollingStatus::AlreadyDoneToday => append_or_new(),
        }
    }
}