use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::version::{ModelVersion, VersionControl};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetentionPolicy {
pub max_versions: usize,
pub max_age_days: u32,
pub keep_minimum: usize,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
max_versions: 0,
max_age_days: 0,
keep_minimum: 1,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PolicyData {
pub policies: HashMap<String, RetentionPolicy>,
pub default_policy: Option<RetentionPolicy>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PolicyReport {
pub model: String,
pub versions_before: usize,
pub versions_removed: Vec<u32>,
pub versions_after: usize,
}
pub struct PolicyStore {
path: PathBuf,
data: PolicyData,
}
impl PolicyStore {
const FILE_NAME: &'static str = "policies.json";
pub fn new(vault_path: &Path) -> Result<Self> {
let path = vault_path.join(Self::FILE_NAME);
let data = if path.exists() {
let contents = fs::read_to_string(&path)?;
serde_json::from_str(&contents).unwrap_or_default()
} else {
PolicyData::default()
};
Ok(Self { path, data })
}
fn save(&self) -> Result<()> {
let json = serde_json::to_string_pretty(&self.data)?;
fs::write(&self.path, json)?;
crate::permissions::restrict_file(&self.path)?;
Ok(())
}
pub fn set(&mut self, model: &str, policy: RetentionPolicy) -> Result<()> {
self.data.policies.insert(model.to_string(), policy);
self.save()
}
pub fn remove(&mut self, model: &str) -> Result<()> {
self.data.policies.remove(model);
self.save()
}
pub fn set_default(&mut self, policy: RetentionPolicy) -> Result<()> {
self.data.default_policy = Some(policy);
self.save()
}
pub fn get(&self, model: &str) -> Option<&RetentionPolicy> {
self.data
.policies
.get(model)
.or(self.data.default_policy.as_ref())
}
pub fn list(&self) -> &HashMap<String, RetentionPolicy> {
&self.data.policies
}
pub fn apply(
&self,
model: &str,
vc: &mut VersionControl,
dry_run: bool,
) -> Result<PolicyReport> {
let Some(policy) = self.get(model).cloned() else {
let versions = vc.list_versions(model);
return Ok(PolicyReport {
model: model.to_string(),
versions_before: versions.len(),
versions_removed: vec![],
versions_after: versions.len(),
});
};
let mut versions: Vec<ModelVersion> =
vc.list_versions(model).into_iter().cloned().collect();
versions.sort_by_key(|v| std::cmp::Reverse(v.version));
let versions_before = versions.len();
let mut to_remove: Vec<u32> = Vec::new();
let now = Utc::now();
for (idx, ver) in versions.iter().enumerate() {
if idx < policy.keep_minimum {
continue;
}
let mut should_remove = false;
if policy.max_versions > 0 && idx >= policy.max_versions {
should_remove = true;
}
if policy.max_age_days > 0 {
let age = now.signed_duration_since(ver.timestamp);
if age > Duration::days(policy.max_age_days as i64) {
should_remove = true;
}
}
if should_remove {
to_remove.push(ver.version);
}
}
if !dry_run {
for v in &to_remove {
vc.delete_version(model, *v)?;
}
}
Ok(PolicyReport {
model: model.to_string(),
versions_before,
versions_removed: to_remove.clone(),
versions_after: versions_before - to_remove.len(),
})
}
pub fn apply_all(&self, vc: &mut VersionControl, dry_run: bool) -> Result<Vec<PolicyReport>> {
let models = vc.list_models_owned();
let mut reports = Vec::new();
for model in &models {
reports.push(self.apply(model, vc, dry_run)?);
}
Ok(reports)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_policy_crud() {
let dir = tempfile::tempdir().unwrap();
let mut store = PolicyStore::new(dir.path()).unwrap();
let policy = RetentionPolicy {
max_versions: 5,
max_age_days: 90,
keep_minimum: 1,
};
store.set("llama", policy.clone()).unwrap();
assert!(store.get("llama").is_some());
assert_eq!(store.get("llama").unwrap().max_versions, 5);
store.remove("llama").unwrap();
assert!(store.get("llama").is_none());
}
#[test]
fn test_default_policy() {
let dir = tempfile::tempdir().unwrap();
let mut store = PolicyStore::new(dir.path()).unwrap();
assert!(store.get("any-model").is_none());
store
.set_default(RetentionPolicy {
max_versions: 3,
max_age_days: 0,
keep_minimum: 1,
})
.unwrap();
assert!(store.get("any-model").is_some());
assert_eq!(store.get("any-model").unwrap().max_versions, 3);
}
#[test]
fn test_persistence() {
let dir = tempfile::tempdir().unwrap();
{
let mut store = PolicyStore::new(dir.path()).unwrap();
store
.set(
"llama",
RetentionPolicy {
max_versions: 10,
max_age_days: 30,
keep_minimum: 2,
},
)
.unwrap();
}
let store2 = PolicyStore::new(dir.path()).unwrap();
assert_eq!(store2.get("llama").unwrap().max_versions, 10);
}
}