use std::path::Path;
use chrono::{Duration, NaiveDate, Utc};
use super::phase::Phase;
use super::{ERROR_LOG_PREFIX, INFO_LOG_PREFIX};
pub(crate) fn prune_old_logs(log_dir: &Path, retention_days: i64) -> std::io::Result<()> {
let cutoff = Utc::now().date_naive() - Duration::days(retention_days);
prune_with_cutoff(log_dir, cutoff)
}
pub(crate) fn prune_with_cutoff(log_dir: &Path, cutoff: NaiveDate) -> std::io::Result<()> {
let entries = match std::fs::read_dir(log_dir) {
Ok(it) => it,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
for entry in entries.flatten() {
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Some(date) = parse_log_date(file_name) else {
continue;
};
if date < cutoff
&& let Err(err) = std::fs::remove_file(&path)
{
tracing::warn!(
phase = %Phase::Daemon,
log_file = %path.display(),
error = %err,
"failed to remove stale log file",
);
}
}
Ok(())
}
fn parse_log_date(file_name: &str) -> Option<NaiveDate> {
for prefix in [INFO_LOG_PREFIX, ERROR_LOG_PREFIX] {
let with_dot = format!("{prefix}.");
if let Some(rest) = file_name.strip_prefix(&with_dot)
&& let Ok(date) = NaiveDate::parse_from_str(rest, "%Y-%m-%d")
{
return Some(date);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
fn tmp_dir(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"vik-retention-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("tmp dir");
dir
}
fn touch(dir: &Path, name: &str) -> PathBuf {
let p = dir.join(name);
let mut f = File::create(&p).expect("touch file");
writeln!(f, "dummy").expect("write dummy");
p
}
#[test]
fn deletes_old_files_keeps_fresh_ones() {
let dir = tmp_dir("prune");
let cutoff = NaiveDate::from_ymd_opt(2026, 3, 10).expect("valid date");
let old_info = touch(&dir, "vik.log.2026-03-01");
let old_error = touch(&dir, "vik-error.log.2026-03-05");
let fresh_info = touch(&dir, "vik.log.2026-03-15");
let fresh_error = touch(&dir, "vik-error.log.2026-03-10");
let alien = touch(&dir, "README.md");
prune_with_cutoff(&dir, cutoff).expect("prune ok");
assert!(!old_info.exists(), "old info file should be removed");
assert!(!old_error.exists(), "old error file should be removed");
assert!(fresh_info.exists(), "fresh info file should be kept");
assert!(fresh_error.exists(), "file equal to the cutoff date must be kept");
assert!(alien.exists(), "unrelated files must be left alone");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_directory_is_ok() {
let dir = std::env::temp_dir().join(format!(
"vik-retention-missing-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
));
assert!(
prune_with_cutoff(&dir, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()).is_ok(),
"missing dir must be a no-op"
);
}
}