rs-zero 0.2.10

Rust-first microservice framework inspired by go-zero engineering practices
Documentation
use std::{
    fs::{self, File},
    io::{self},
    path::{Path, PathBuf},
    time::{Duration, SystemTime},
};

use chrono::{Datelike, Local, NaiveDate};
use flate2::{Compression, write::GzEncoder};

use crate::core::logging::writer::{RollingFileConfig, RotationPolicy};

pub(crate) fn rotate_files(config: &RollingFileConfig) -> io::Result<()> {
    if !config.path.exists() {
        return Ok(());
    }
    let rotated = match config.rotation {
        RotationPolicy::Size => rotate_size_file(config)?,
        RotationPolicy::Daily => rotate_daily_file(config)?,
    };
    if config.compress {
        compress_file(&rotated)?;
    }
    Ok(())
}

fn rotate_size_file(config: &RollingFileConfig) -> io::Result<PathBuf> {
    if config.max_files == 0 {
        let target = unique_size_rotated_path(&config.path);
        fs::rename(&config.path, &target)?;
        return Ok(target);
    }

    let max_files = config.max_files;
    remove_path_if_exists(rotated_path(&config.path, max_files, config.compress))?;
    remove_path_if_exists(rotated_path(&config.path, max_files, false))?;
    for index in (1..max_files).rev() {
        rename_if_exists(
            rotated_path(&config.path, index, config.compress),
            rotated_path(&config.path, index + 1, config.compress),
        )?;
        rename_if_exists(
            rotated_path(&config.path, index, false),
            rotated_path(&config.path, index + 1, false),
        )?;
    }
    let target = rotated_path(&config.path, 1, false);
    fs::rename(&config.path, &target)?;
    Ok(target)
}

fn rotate_daily_file(config: &RollingFileConfig) -> io::Result<PathBuf> {
    let date = Local::now().date_naive();
    let mut target = dated_rotated_path(&config.path, date, false, None);
    let mut suffix = 1;
    while target.exists()
        || target
            .with_extension(format!("{}gz", extension_prefix(&target)))
            .exists()
    {
        target = dated_rotated_path(&config.path, date, false, Some(suffix));
        suffix += 1;
    }
    fs::rename(&config.path, &target)?;
    Ok(target)
}

fn rename_if_exists(source: PathBuf, target: PathBuf) -> io::Result<()> {
    if source.exists() {
        fs::rename(source, target)?;
    }
    Ok(())
}

fn remove_path_if_exists(path: PathBuf) -> io::Result<()> {
    if path.exists() {
        fs::remove_file(path)?;
    }
    Ok(())
}

fn compress_file(path: &Path) -> io::Result<()> {
    if path.extension().is_some_and(|value| value == "gz") {
        return Ok(());
    }
    let gzip_path = gzip_path(path);
    let mut input = File::open(path)?;
    let output = File::create(&gzip_path)?;
    let mut encoder = GzEncoder::new(output, Compression::default());
    io::copy(&mut input, &mut encoder)?;
    encoder.finish()?;
    fs::remove_file(path)
}

pub(crate) fn cleanup_rotated_files(config: &RollingFileConfig) -> io::Result<()> {
    let Some(parent) = config.path.parent() else {
        return Ok(());
    };
    if !parent.exists() {
        return Ok(());
    }

    let mut files = rotated_files(config)?;
    if let Some(days) = config.keep_days {
        let max_age = Duration::from_secs(days.saturating_mul(24 * 60 * 60));
        let now = SystemTime::now();
        for file in &files {
            if let Ok(metadata) = fs::metadata(file)
                && let Ok(modified) = metadata.modified()
                && now.duration_since(modified).unwrap_or_default() > max_age
            {
                remove_path_if_exists(file.clone())?;
            }
        }
        files = rotated_files(config)?;
    }

    if config.max_files > 0 && files.len() > config.max_files {
        files.sort_by_key(|path| {
            fs::metadata(path)
                .and_then(|metadata| metadata.modified())
                .unwrap_or(SystemTime::UNIX_EPOCH)
        });
        let remove_count = files.len() - config.max_files;
        for file in files.into_iter().take(remove_count) {
            remove_path_if_exists(file)?;
        }
    }
    Ok(())
}

fn rotated_files(config: &RollingFileConfig) -> io::Result<Vec<PathBuf>> {
    let Some(parent) = config.path.parent() else {
        return Ok(Vec::new());
    };
    let Some(active_name) = config.path.file_name().and_then(|name| name.to_str()) else {
        return Ok(Vec::new());
    };
    let mut files = Vec::new();
    for entry in fs::read_dir(parent)? {
        let path = entry?.path();
        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };
        if name.starts_with(&format!("{active_name}.")) && path != config.path {
            files.push(path);
        }
    }
    Ok(files)
}

fn rotated_path(path: &Path, index: usize, compressed: bool) -> PathBuf {
    let mut path = path.with_file_name(format!(
        "{}.{index}",
        path.file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("service.log")
    ));
    if compressed {
        path = gzip_path(&path);
    }
    path
}

fn unique_size_rotated_path(path: &Path) -> PathBuf {
    let stamp = Local::now().format("%Y%m%d%H%M%S%.f");
    let mut target = path.with_file_name(format!(
        "{}.{}",
        path.file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("service.log"),
        stamp
    ));
    let mut suffix = 1;
    while target.exists() || gzip_path(&target).exists() {
        target = path.with_file_name(format!(
            "{}.{}.{}",
            path.file_name()
                .and_then(|name| name.to_str())
                .unwrap_or("service.log"),
            stamp,
            suffix
        ));
        suffix += 1;
    }
    target
}

fn dated_rotated_path(
    path: &Path,
    date: NaiveDate,
    compressed: bool,
    suffix: Option<usize>,
) -> PathBuf {
    let date = format!("{:04}{:02}{:02}", date.year(), date.month(), date.day());
    let suffix = suffix.map(|value| format!(".{value}")).unwrap_or_default();
    let mut path = path.with_file_name(format!(
        "{}.{}{suffix}",
        path.file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("service.log"),
        date
    ));
    if compressed {
        path = gzip_path(&path);
    }
    path
}

fn gzip_path(path: &Path) -> PathBuf {
    path.with_extension(format!("{}gz", extension_prefix(path)))
}

fn extension_prefix(path: &Path) -> String {
    path.extension()
        .and_then(|value| value.to_str())
        .map(|value| format!("{value}."))
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::{dated_rotated_path, gzip_path, rotated_path};
    use chrono::NaiveDate;
    use std::path::Path;

    #[test]
    fn builds_rotated_paths() {
        let path = Path::new("logs/service.log");
        assert_eq!(
            rotated_path(path, 1, false),
            Path::new("logs/service.log.1")
        );
        assert_eq!(
            rotated_path(path, 1, true),
            Path::new("logs/service.log.1.gz")
        );
        assert_eq!(gzip_path(path), Path::new("logs/service.log.gz"));
        assert_eq!(
            dated_rotated_path(
                path,
                NaiveDate::from_ymd_opt(2026, 5, 17).unwrap(),
                false,
                None
            ),
            Path::new("logs/service.log.20260517")
        );
    }
}