use std::path::Path;
use std::time::{Duration, SystemTime};
use tracing::warn;
#[derive(Debug, Default, Clone)]
pub struct RetentionStats {
pub deleted: u32,
pub kept: u32,
}
pub fn prune(
journal_dir: &Path,
retention_days: u32,
retention_count: u32,
) -> std::io::Result<RetentionStats> {
let mut stats = RetentionStats::default();
let entries = std::fs::read_dir(journal_dir)?;
let mut sessions: Vec<(std::path::PathBuf, SystemTime)> = entries
.filter_map(std::result::Result::ok)
.filter_map(|e| {
let path = e.path();
if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
return None;
}
let modified = e.metadata().ok().and_then(|m| m.modified().ok())?;
Some((path, modified))
})
.collect();
sessions.sort_by(|a, b| b.1.cmp(&a.1));
let age_cutoff =
SystemTime::now().checked_sub(Duration::from_secs(u64::from(retention_days) * 86_400));
for (idx, (path, modified)) in sessions.iter().enumerate() {
let too_old = age_cutoff.is_some_and(|cutoff| *modified < cutoff);
let beyond_count = idx >= retention_count as usize;
if too_old || beyond_count {
match std::fs::remove_file(path) {
Ok(()) => stats.deleted = stats.deleted.saturating_add(1),
Err(e) => warn!(
target: "vortix::journal::retention",
path = %path.display(),
error = %e,
"failed to delete stale journal file"
),
}
} else {
stats.kept = stats.kept.saturating_add(1);
}
}
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::time::Duration;
#[test]
fn count_rule_prunes_oldest() {
let tmp = tempfile::tempdir().unwrap();
for i in 0u32..35 {
let path = tmp
.path()
.join(format!("2026-05-{:02}T00:00:00Z-1.jsonl", i + 1));
File::create(&path).unwrap();
let then = SystemTime::now() - Duration::from_secs(u64::from(35 - i) * 60);
set_mtime(&path, then);
}
let stats = prune(tmp.path(), u32::MAX, 30).unwrap();
assert_eq!(stats.kept, 30);
assert_eq!(stats.deleted, 5);
}
#[test]
fn day_rule_prunes_old() {
let tmp = tempfile::tempdir().unwrap();
let recent = tmp.path().join("recent.jsonl");
let stale = tmp.path().join("stale.jsonl");
File::create(&recent).unwrap();
File::create(&stale).unwrap();
set_mtime(&stale, SystemTime::now() - Duration::from_secs(60 * 86_400));
let stats = prune(tmp.path(), 30, u32::MAX).unwrap();
assert_eq!(stats.deleted, 1);
assert!(recent.exists());
assert!(!stale.exists());
}
#[test]
fn ignores_non_jsonl_files() {
let tmp = tempfile::tempdir().unwrap();
File::create(tmp.path().join("session.jsonl")).unwrap();
File::create(tmp.path().join("README.md")).unwrap();
let stats = prune(tmp.path(), u32::MAX, u32::MAX).unwrap();
assert_eq!(stats.kept, 1);
assert_eq!(stats.deleted, 0);
}
fn set_mtime(path: &std::path::Path, time: SystemTime) {
let times = std::fs::FileTimes::new().set_modified(time);
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_times(times)
.unwrap();
}
}