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")
);
}
}