use std::collections::HashSet;
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::bundle::{list_sessions, validate_session_id};
use crate::storage::now_ms;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RetentionPolicy {
pub max_age_days: Option<u32>,
pub max_sessions: Option<u32>,
pub orphan_max_age_days: Option<u32>,
}
impl RetentionPolicy {
pub fn is_empty(&self) -> bool {
self.max_age_days.is_none()
&& self.max_sessions.is_none()
&& self.orphan_max_age_days.is_none()
}
}
fn effective(v: Option<u32>) -> Option<u32> {
v.filter(|&x| x > 0)
}
fn remove_session_dir(root: &Path, id: &str) -> bool {
if !validate_session_id(id) {
return false;
}
fs::remove_dir_all(root.join(id)).is_ok()
}
pub fn enforce_retention_protect(
root: &Path,
policy: &RetentionPolicy,
protect: &[&str],
) -> Result<usize, String> {
let max_age = effective(policy.max_age_days);
let orphan_age = effective(policy.orphan_max_age_days);
let max_sessions = effective(policy.max_sessions);
if max_age.is_none() && orphan_age.is_none() && max_sessions.is_none() {
return Ok(0);
}
let protected: HashSet<&str> = protect.iter().copied().collect();
let mut sessions = list_sessions(root);
let now = now_ms();
let mut removed = 0usize;
if let Some(days) = max_age {
let cutoff = now - (days as i64) * 86_400_000;
sessions.retain(|s| {
let Some(id) = s["id"].as_str() else {
return true;
};
if protected.contains(id) {
return true;
}
let overage = matches!(s["endedAt"].as_i64(), Some(t) if t < cutoff);
if overage && remove_session_dir(root, id) {
removed += 1;
return false;
}
true
});
}
if let Some(days) = orphan_age {
let cutoff = now - (days as i64) * 86_400_000;
sessions.retain(|s| {
let Some(id) = s["id"].as_str() else {
return true;
};
if protected.contains(id) {
return true;
}
if s["endedAt"].as_i64().is_some() {
return true; }
let Some(started) = s["startedAt"].as_i64() else {
return true;
};
if started < cutoff && remove_session_dir(root, id) {
removed += 1;
return false;
}
true
});
}
if let Some(max) = max_sessions {
let mut eligible: Vec<&serde_json::Value> = sessions
.iter()
.filter(|s| s["id"].as_str().map_or(true, |id| !protected.contains(id)))
.collect();
eligible.sort_by(|a, b| b["startedAt"].as_i64().cmp(&a["startedAt"].as_i64()));
for s in eligible.iter().skip(max as usize) {
if let Some(id) = s["id"].as_str() {
if remove_session_dir(root, id) {
removed += 1;
}
}
}
}
Ok(removed)
}
pub fn enforce_retention(root: &Path, policy: &RetentionPolicy) -> Result<usize, String> {
enforce_retention_protect(root, policy, &[])
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
fn make_session(root: &Path, id: &str, started: i64, ended: Option<i64>) {
let dir = root.join(id);
fs::create_dir_all(dir.join("segments")).unwrap();
let mut s = json!({ "id": id, "startedAt": started });
if let Some(e) = ended {
s["endedAt"] = json!(e);
}
fs::write(dir.join("session.json"), s.to_string()).unwrap();
}
#[test]
fn enforces_max_age() {
let root = tempdir().unwrap();
let now = now_ms();
make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
make_session(root.path(), "900000000003", now - 1000, Some(now - 1000));
make_session(root.path(), "900000000004", now - 2000, None);
let removed = enforce_retention(
root.path(),
&RetentionPolicy {
max_age_days: Some(30),
max_sessions: None,
orphan_max_age_days: None,
},
)
.unwrap();
assert_eq!(removed, 1);
assert!(!root.path().join("900000000002").exists());
assert!(root.path().join("900000000003").exists());
assert!(root.path().join("900000000004").exists());
}
#[test]
fn enforces_max_sessions() {
let root = tempdir().unwrap();
make_session(root.path(), "1001", 1000, Some(1100));
make_session(root.path(), "1002", 2000, Some(2100));
make_session(root.path(), "1003", 3000, Some(3100));
let removed = enforce_retention(
root.path(),
&RetentionPolicy {
max_age_days: None,
max_sessions: Some(2),
orphan_max_age_days: None,
},
)
.unwrap();
assert_eq!(removed, 1);
assert!(!root.path().join("1001").exists());
assert!(root.path().join("1002").exists());
assert!(root.path().join("1003").exists());
}
#[test]
fn empty_policy_noop() {
let root = tempdir().unwrap();
make_session(root.path(), "1001", 1000, Some(1100));
let removed = enforce_retention(root.path(), &RetentionPolicy::default()).unwrap();
assert_eq!(removed, 0);
assert!(root.path().join("1001").exists());
}
#[test]
fn protect_skips_in_max_age() {
let root = tempdir().unwrap();
let now = now_ms();
make_session(root.path(), "900000000001", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
let policy = RetentionPolicy {
max_age_days: Some(30),
max_sessions: None,
orphan_max_age_days: None,
};
let removed = enforce_retention_protect(root.path(), &policy, &["900000000001"]).unwrap();
assert_eq!(removed, 1);
assert!(root.path().join("900000000001").exists());
assert!(!root.path().join("900000000002").exists());
}
#[test]
fn protect_skips_in_max_sessions() {
let root = tempdir().unwrap();
make_session(root.path(), "1001", 1000, Some(1100));
make_session(root.path(), "1002", 2000, Some(2100));
make_session(root.path(), "1003", 3000, Some(3100));
make_session(root.path(), "1004", 4000, Some(4100));
let policy = RetentionPolicy {
max_age_days: None,
max_sessions: Some(2),
orphan_max_age_days: None,
};
let removed = enforce_retention_protect(root.path(), &policy, &["1001"]).unwrap();
assert_eq!(removed, 1);
assert!(root.path().join("1001").exists());
assert!(!root.path().join("1002").exists());
assert!(root.path().join("1003").exists());
assert!(root.path().join("1004").exists());
}
#[test]
fn orphan_swept_by_started_at() {
let root = tempdir().unwrap();
let now = now_ms();
make_session(root.path(), "800000000001", now - 86_400_000 * 40, None);
make_session(root.path(), "800000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
let policy = RetentionPolicy {
max_age_days: None,
max_sessions: None,
orphan_max_age_days: Some(30),
};
let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
assert_eq!(removed, 1);
assert!(!root.path().join("800000000001").exists());
assert!(root.path().join("800000000002").exists()); }
#[test]
fn orphan_skips_protected() {
let root = tempdir().unwrap();
let now = now_ms();
make_session(root.path(), "800000000003", now - 86_400_000 * 40, None);
let policy = RetentionPolicy {
max_age_days: None,
max_sessions: None,
orphan_max_age_days: Some(30),
};
let removed = enforce_retention_protect(root.path(), &policy, &["800000000003"]).unwrap();
assert_eq!(removed, 0);
assert!(root.path().join("800000000003").exists());
}
#[test]
fn orphan_none_keeps_legacy_semantics() {
let root = tempdir().unwrap();
let now = now_ms();
make_session(root.path(), "800000000001", now - 86_400_000 * 40, None);
let policy = RetentionPolicy {
max_age_days: Some(30),
max_sessions: None,
orphan_max_age_days: None,
};
let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
assert_eq!(removed, 0);
assert!(root.path().join("800000000001").exists());
}
#[test]
fn zero_values_treated_as_noop() {
let root = tempdir().unwrap();
let now = now_ms();
make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
make_session(root.path(), "800000000001", now - 86_400_000 * 40, None);
let policy = RetentionPolicy {
max_age_days: Some(0),
max_sessions: Some(0),
orphan_max_age_days: Some(0),
};
let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
assert_eq!(removed, 0);
assert!(root.path().join("900000000002").exists());
assert!(root.path().join("800000000001").exists());
}
#[test]
fn invalid_session_id_skipped() {
let root = tempdir().unwrap();
let now = now_ms();
let dir = root.path().join("dir-a");
fs::create_dir_all(dir.join("segments")).unwrap();
fs::write(
dir.join("session.json"),
json!({
"id": "../evil",
"startedAt": now - 86_400_000 * 40,
"endedAt": now - 86_400_000 * 40
})
.to_string(),
)
.unwrap();
let policy = RetentionPolicy {
max_age_days: Some(30),
max_sessions: None,
orphan_max_age_days: None,
};
let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
assert_eq!(removed, 0);
assert!(root.path().join("dir-a").exists());
}
#[test]
fn delegate_empty_protect_equivalent() {
let root = tempdir().unwrap();
let now = now_ms();
make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
let policy = RetentionPolicy {
max_age_days: Some(30),
max_sessions: None,
orphan_max_age_days: None,
};
let removed = enforce_retention(root.path(), &policy).unwrap();
assert_eq!(removed, 1);
assert!(!root.path().join("900000000002").exists());
}
}