use anyhow::Result;
use chrono::Utc;
use crate::git::GitRepo;
use crate::storage::{self, SnapshotRecord};
const KEEP_LAST_N: usize = 30;
const KEEP_DAYS: i64 = 7;
pub struct CleanReport {
pub kept: usize,
pub deleted: Vec<String>,
}
pub fn clean(repo: &GitRepo) -> Result<CleanReport> {
let all = storage::read_all(repo)?;
let now = Utc::now().timestamp();
let cutoff = now - (KEEP_DAYS * 24 * 60 * 60);
let total = all.len();
let last_n_start = total.saturating_sub(KEEP_LAST_N);
let mut keep: Vec<SnapshotRecord> = Vec::with_capacity(total);
let mut deleted_ids: Vec<String> = Vec::new();
for (idx, rec) in all.iter().enumerate() {
let recent_enough = rec.timestamp >= cutoff;
let in_last_n = idx >= last_n_start;
if recent_enough || in_last_n {
keep.push(rec.clone());
} else {
let _ = repo.delete_ref(&rec.id);
deleted_ids.push(rec.id.clone());
}
}
if !deleted_ids.is_empty() {
storage::rewrite(repo, &keep)?;
}
Ok(CleanReport {
kept: keep.len(),
deleted: deleted_ids,
})
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn make_rec(id: &str, age_days: i64) -> SnapshotRecord {
SnapshotRecord {
id: id.to_string(),
stash_sha: format!("{}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", id),
tree_sha: format!("{}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", id),
trigger: "manual".to_string(),
message: None,
timestamp: Utc::now().timestamp() - age_days * 24 * 60 * 60,
files_added: 0,
files_deleted: 0,
clean: true,
}
}
fn decide(
records: &[SnapshotRecord],
keep_last_n: usize,
keep_days: i64,
) -> (Vec<String>, Vec<String>) {
let now = Utc::now().timestamp();
let cutoff = now - (keep_days * 24 * 60 * 60);
let total = records.len();
let last_n_start = total.saturating_sub(keep_last_n);
let mut kept = Vec::new();
let mut dropped = Vec::new();
for (idx, rec) in records.iter().enumerate() {
if rec.timestamp >= cutoff || idx >= last_n_start {
kept.push(rec.id.clone());
} else {
dropped.push(rec.id.clone());
}
}
(kept, dropped)
}
#[test]
fn keep_recent_even_if_beyond_n() {
let recs: Vec<_> = (0..50).map(|i| make_rec(&format!("r{i}"), 1)).collect();
let (kept, dropped) = decide(&recs, 30, 7);
assert_eq!(kept.len(), 50);
assert!(dropped.is_empty());
}
#[test]
fn keep_last_n_even_if_old() {
let recs: Vec<_> = (0..50).map(|i| make_rec(&format!("o{i}"), 100)).collect();
let (kept, dropped) = decide(&recs, 30, 7);
assert_eq!(kept.len(), 30);
assert_eq!(dropped.len(), 20);
for i in 0..20 {
assert!(dropped.contains(&format!("o{i}")));
}
}
#[test]
fn delete_when_outside_both_windows() {
let mut recs: Vec<_> = (0..5).map(|i| make_rec(&format!("old{i}"), 100)).collect();
recs.extend((0..10).map(|i| make_rec(&format!("new{i}"), 1)));
let (kept, dropped) = decide(&recs, 10, 7);
assert_eq!(kept.len(), 10);
assert_eq!(dropped.len(), 5);
}
#[test]
fn empty_index_no_op() {
let (kept, dropped) = decide(&[], 30, 7);
assert!(kept.is_empty());
assert!(dropped.is_empty());
}
}