use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use traits::ArtifactId;
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactRecord {
pub id: ArtifactId,
pub size_bytes: u64,
pub created_at: Option<i64>,
#[serde(default)]
pub protected_by_property: bool,
}
impl ArtifactRecord {
pub fn from_entry(entry: traits::ArtifactEntry) -> Self {
Self {
id: entry.id,
size_bytes: entry.size_bytes.max(0) as u64,
created_at: None,
protected_by_property: false,
}
}
pub fn protected_by_property(mut self, protected: bool) -> Self {
self.protected_by_property = protected;
self
}
fn group_key(&self) -> (Option<String>, String) {
(self.id.namespace.clone(), self.id.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetentionPolicy {
#[serde(default)]
pub keep_last_n: Option<usize>,
#[serde(default)]
pub max_age_secs: Option<i64>,
#[serde(default)]
pub keep_min_per_name: Option<usize>,
#[serde(default)]
pub max_total_bytes: Option<u64>,
#[serde(default = "default_true")]
pub protect_latest_per_name: bool,
#[serde(default)]
pub protect_property: Option<(String, String)>,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
keep_last_n: None,
max_age_secs: None,
keep_min_per_name: None,
max_total_bytes: None,
protect_latest_per_name: true,
protect_property: None,
}
}
}
impl RetentionPolicy {
pub fn new() -> Self {
Self::default()
}
pub fn keep_last(mut self, n: usize) -> Self {
self.keep_last_n = Some(n);
self
}
pub fn max_age(mut self, secs: i64) -> Self {
self.max_age_secs = Some(secs);
self
}
pub fn keep_min(mut self, n: usize) -> Self {
self.keep_min_per_name = Some(n);
self
}
pub fn quota(mut self, bytes: u64) -> Self {
self.max_total_bytes = Some(bytes);
self
}
pub fn protect_latest(mut self, on: bool) -> Self {
self.protect_latest_per_name = on;
self
}
pub fn protect_property_kv(mut self, key: &str, value: &str) -> Self {
self.protect_property = Some((key.to_string(), value.to_string()));
self
}
pub fn is_noop(&self) -> bool {
self.keep_last_n.is_none()
&& self.max_age_secs.is_none()
&& self.max_total_bytes.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RetentionReason {
ProtectedLatest,
ProtectedByProperty,
MinPerNameFloor,
WithinKeepLastN,
WithinMaxAge,
NoPolicyMatched,
SupersededByNewer,
AgedOut,
QuotaEvicted,
}
impl RetentionReason {
pub fn as_str(&self) -> &'static str {
match self {
RetentionReason::ProtectedLatest => "protected-latest",
RetentionReason::ProtectedByProperty => "protected-by-property",
RetentionReason::MinPerNameFloor => "min-per-name-floor",
RetentionReason::WithinKeepLastN => "within-keep-last-n",
RetentionReason::WithinMaxAge => "within-max-age",
RetentionReason::NoPolicyMatched => "no-policy-matched",
RetentionReason::SupersededByNewer => "superseded-by-newer",
RetentionReason::AgedOut => "aged-out",
RetentionReason::QuotaEvicted => "quota-evicted",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetentionDecision {
pub record: ArtifactRecord,
pub delete: bool,
pub reasons: Vec<RetentionReason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetentionPlan {
pub decisions: Vec<RetentionDecision>,
pub kept_count: usize,
pub delete_count: usize,
pub kept_bytes: u64,
pub reclaimed_bytes: u64,
pub name_count: usize,
pub evaluated_at: i64,
}
impl RetentionPlan {
pub fn to_delete(&self) -> impl Iterator<Item = &ArtifactRecord> {
self.decisions.iter().filter(|d| d.delete).map(|d| &d.record)
}
pub fn to_keep(&self) -> impl Iterator<Item = &ArtifactRecord> {
self.decisions.iter().filter(|d| !d.delete).map(|d| &d.record)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GcOptions {
pub dry_run: bool,
}
impl Default for GcOptions {
fn default() -> Self {
Self { dry_run: true }
}
}
impl GcOptions {
pub fn dry_run() -> Self {
Self { dry_run: true }
}
pub fn execute() -> Self {
Self { dry_run: false }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GcOutcome {
Deleted,
WouldDelete,
Skipped(String),
Failed(String),
}
impl GcOutcome {
pub fn as_str(&self) -> &'static str {
match self {
GcOutcome::Deleted => "deleted",
GcOutcome::WouldDelete => "would-delete",
GcOutcome::Skipped(_) => "skipped",
GcOutcome::Failed(_) => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GcAction {
pub id: ArtifactId,
pub size_bytes: u64,
pub content_id: Option<String>,
pub outcome: GcOutcome,
pub reasons: Vec<RetentionReason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GcReport {
pub repository: String,
pub dry_run: bool,
pub actions: Vec<GcAction>,
pub deleted_count: usize,
pub reclaimed_bytes: u64,
pub would_delete_count: usize,
pub skipped_count: usize,
pub failed_count: usize,
pub evaluated_at: i64,
}
fn cmp_version(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let split = |s: &str| -> Vec<String> {
s.split(['.', '-', '+', '_'])
.filter(|p| !p.is_empty())
.map(|p| p.to_string())
.collect()
};
let (sa, sb) = (split(a), split(b));
for (x, y) in sa.iter().zip(sb.iter()) {
let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(nx), Ok(ny)) => nx.cmp(&ny),
_ => x.cmp(y),
};
if ord != Ordering::Equal {
return ord;
}
}
sa.len().cmp(&sb.len())
}
pub fn plan_retention(
records: &[ArtifactRecord],
policy: &RetentionPolicy,
now: i64,
) -> RetentionPlan {
let mut groups: BTreeMap<(Option<String>, String), Vec<usize>> = BTreeMap::new();
for (i, r) in records.iter().enumerate() {
groups.entry(r.group_key()).or_default().push(i);
}
let name_count = groups.len();
for idxs in groups.values_mut() {
idxs.sort_by(|&x, &y| {
let (rx, ry) = (&records[x], &records[y]);
let tx = rx.created_at.unwrap_or(i64::MIN);
let ty = ry.created_at.unwrap_or(i64::MIN);
ty.cmp(&tx)
.then_with(|| cmp_version(&ry.id.version, &rx.id.version))
.then_with(|| ry.id.version.cmp(&rx.id.version))
});
}
let mut delete = vec![false; records.len()];
let mut reasons: Vec<Vec<RetentionReason>> = vec![Vec::new(); records.len()];
let mut protected = vec![false; records.len()];
for idxs in groups.values() {
for (pos, &i) in idxs.iter().enumerate() {
let r = &records[i];
let protected_latest = policy.protect_latest_per_name && pos == 0;
let within_floor = policy.keep_min_per_name.is_some_and(|m| pos < m);
protected[i] = protected_latest || within_floor || r.protected_by_property;
let superseded = policy.keep_last_n.is_some_and(|n| pos >= n);
let aged = match (policy.max_age_secs, r.created_at) {
(Some(max), Some(c)) => now.saturating_sub(c) > max,
_ => false,
};
let should_delete = (superseded || aged) && !protected[i];
delete[i] = should_delete;
if should_delete {
if superseded {
reasons[i].push(RetentionReason::SupersededByNewer);
}
if aged {
reasons[i].push(RetentionReason::AgedOut);
}
} else {
if protected_latest {
reasons[i].push(RetentionReason::ProtectedLatest);
}
if r.protected_by_property {
reasons[i].push(RetentionReason::ProtectedByProperty);
}
if within_floor {
reasons[i].push(RetentionReason::MinPerNameFloor);
}
if let Some(n) = policy.keep_last_n {
if pos < n {
reasons[i].push(RetentionReason::WithinKeepLastN);
}
}
if policy.max_age_secs.is_some() && !aged && r.created_at.is_some() {
reasons[i].push(RetentionReason::WithinMaxAge);
}
if reasons[i].is_empty() {
reasons[i].push(RetentionReason::NoPolicyMatched);
}
}
}
}
if let Some(quota) = policy.max_total_bytes {
let mut kept_bytes: u64 = records
.iter()
.enumerate()
.filter(|(i, _)| !delete[*i])
.map(|(_, r)| r.size_bytes)
.sum();
if kept_bytes > quota {
let mut evictable: Vec<usize> = (0..records.len())
.filter(|&i| !delete[i] && !protected[i])
.collect();
evictable.sort_by(|&x, &y| {
let (rx, ry) = (&records[x], &records[y]);
let tx = rx.created_at.unwrap_or(i64::MIN);
let ty = ry.created_at.unwrap_or(i64::MIN);
tx.cmp(&ty)
.then_with(|| cmp_version(&rx.id.version, &ry.id.version))
.then_with(|| x.cmp(&y))
});
for i in evictable {
if kept_bytes <= quota {
break;
}
delete[i] = true;
reasons[i].clear();
reasons[i].push(RetentionReason::QuotaEvicted);
kept_bytes = kept_bytes.saturating_sub(records[i].size_bytes);
}
}
}
let mut decisions = Vec::with_capacity(records.len());
let mut kept_count = 0usize;
let mut delete_count = 0usize;
let mut kept_bytes = 0u64;
let mut reclaimed_bytes = 0u64;
for idxs in groups.values() {
for &i in idxs {
let d = delete[i];
if d {
delete_count += 1;
reclaimed_bytes += records[i].size_bytes;
} else {
kept_count += 1;
kept_bytes += records[i].size_bytes;
}
decisions.push(RetentionDecision {
record: records[i].clone(),
delete: d,
reasons: std::mem::take(&mut reasons[i]),
});
}
}
RetentionPlan {
decisions,
kept_count,
delete_count,
kept_bytes,
reclaimed_bytes,
name_count,
evaluated_at: now,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(name: &str, version: &str, size: u64, created_at: Option<i64>) -> ArtifactRecord {
ArtifactRecord {
id: ArtifactId {
namespace: None,
name: name.into(),
version: version.into(),
},
size_bytes: size,
created_at,
protected_by_property: false,
}
}
fn ns_rec(ns: &str, name: &str, version: &str, size: u64) -> ArtifactRecord {
ArtifactRecord {
id: ArtifactId {
namespace: Some(ns.into()),
name: name.into(),
version: version.into(),
},
size_bytes: size,
created_at: None,
protected_by_property: false,
}
}
#[test]
fn empty_input_yields_empty_plan() {
let plan = plan_retention(&[], &RetentionPolicy::new().keep_last(3), 1000);
assert_eq!(plan.decisions.len(), 0);
assert_eq!(plan.kept_count, 0);
assert_eq!(plan.delete_count, 0);
assert_eq!(plan.name_count, 0);
}
#[test]
fn noop_policy_keeps_everything() {
let recs = vec![
rec("a", "1.0.0", 10, None),
rec("a", "2.0.0", 10, None),
rec("a", "3.0.0", 10, None),
];
let plan = plan_retention(&recs, &RetentionPolicy::new(), 1000);
assert!(RetentionPolicy::new().is_noop());
assert_eq!(plan.kept_count, 3);
assert_eq!(plan.delete_count, 0);
assert_eq!(plan.reclaimed_bytes, 0);
}
#[test]
fn keep_last_n_reclaims_older_versions_per_name() {
let recs = vec![
rec("a", "1.0.0", 100, None),
rec("a", "2.0.0", 100, None),
rec("a", "3.0.0", 100, None),
rec("a", "4.0.0", 100, None),
rec("a", "5.0.0", 100, None),
];
let plan = plan_retention(&recs, &RetentionPolicy::new().keep_last(2), 1000);
assert_eq!(plan.kept_count, 2);
assert_eq!(plan.delete_count, 3);
assert_eq!(plan.reclaimed_bytes, 300);
let kept: Vec<_> = plan.to_keep().map(|r| r.id.version.clone()).collect();
assert_eq!(kept, vec!["5.0.0", "4.0.0"]);
let recs2 = vec![
rec("b", "1.9.0", 1, None),
rec("b", "1.10.0", 1, None),
];
let p2 = plan_retention(&recs2, &RetentionPolicy::new().keep_last(1), 1000);
assert_eq!(p2.to_keep().next().unwrap().id.version, "1.10.0");
}
#[test]
fn keep_last_n_is_per_name_not_global() {
let recs = vec![
rec("a", "1.0.0", 1, None),
rec("a", "2.0.0", 1, None),
rec("b", "1.0.0", 1, None),
rec("b", "2.0.0", 1, None),
];
let plan = plan_retention(&recs, &RetentionPolicy::new().keep_last(1), 1000);
assert_eq!(plan.name_count, 2);
assert_eq!(plan.kept_count, 2);
assert_eq!(plan.delete_count, 2);
}
#[test]
fn max_age_reclaims_old_and_keeps_young() {
let now = 10_000;
let recs = vec![
rec("a", "1.0.0", 1, Some(now - 5000)), rec("a", "2.0.0", 1, Some(now - 100)), rec("a", "3.0.0", 1, Some(now - 50)), ];
let policy = RetentionPolicy::new().max_age(1000).protect_latest(false);
let plan = plan_retention(&recs, &policy, now);
assert_eq!(plan.delete_count, 1);
let del: Vec<_> = plan.to_delete().map(|r| r.id.version.clone()).collect();
assert_eq!(del, vec!["1.0.0"]);
assert!(plan
.decisions
.iter()
.find(|d| d.record.id.version == "1.0.0")
.unwrap()
.reasons
.contains(&RetentionReason::AgedOut));
}
#[test]
fn protect_latest_saves_the_newest_even_when_aged() {
let now = 10_000;
let recs = vec![rec("a", "1.0.0", 1, Some(now - 9000))];
let policy = RetentionPolicy::new().max_age(10); let plan = plan_retention(&recs, &policy, now);
assert_eq!(plan.delete_count, 0);
assert!(plan.decisions[0]
.reasons
.contains(&RetentionReason::ProtectedLatest));
}
#[test]
fn keep_min_per_name_is_a_floor_against_age() {
let now = 10_000;
let recs = vec![
rec("a", "1.0.0", 1, Some(now - 9000)),
rec("a", "2.0.0", 1, Some(now - 8000)),
rec("a", "3.0.0", 1, Some(now - 7000)),
];
let policy = RetentionPolicy::new()
.max_age(10)
.keep_min(2)
.protect_latest(false);
let plan = plan_retention(&recs, &policy, now);
assert_eq!(plan.kept_count, 2);
assert_eq!(plan.delete_count, 1);
let kept: Vec<_> = plan.to_keep().map(|r| r.id.version.clone()).collect();
assert_eq!(kept, vec!["3.0.0", "2.0.0"]);
assert!(plan
.decisions
.iter()
.find(|d| d.record.id.version == "2.0.0")
.unwrap()
.reasons
.contains(&RetentionReason::MinPerNameFloor));
}
#[test]
fn quota_evicts_oldest_until_under_bound() {
let now = 10_000;
let recs = vec![
rec("a", "1.0.0", 100, Some(now - 4000)),
rec("b", "1.0.0", 100, Some(now - 3000)),
rec("c", "1.0.0", 100, Some(now - 2000)),
rec("d", "1.0.0", 100, Some(now - 1000)),
];
let policy = RetentionPolicy::new().quota(250).protect_latest(false);
let plan = plan_retention(&recs, &policy, now);
assert!(plan.kept_bytes <= 250, "kept {} > 250", plan.kept_bytes);
assert_eq!(plan.delete_count, 2);
let del: Vec<_> = plan.to_delete().map(|r| r.id.name.clone()).collect();
assert_eq!(del, vec!["a", "b"]);
assert!(plan
.decisions
.iter()
.find(|d| d.record.id.name == "a")
.unwrap()
.reasons
.contains(&RetentionReason::QuotaEvicted));
}
#[test]
fn quota_never_evicts_a_protected_latest() {
let now = 10_000;
let recs = vec![
rec("a", "1.0.0", 100, Some(now - 2000)),
rec("a", "2.0.0", 100, Some(now - 1000)),
];
let policy = RetentionPolicy::new().quota(50); let plan = plan_retention(&recs, &policy, now);
assert_eq!(plan.delete_count, 1);
let kept: Vec<_> = plan.to_keep().map(|r| r.id.version.clone()).collect();
assert_eq!(kept, vec!["2.0.0"]);
assert!(plan.kept_bytes > 50, "protected latest must survive the quota");
}
#[test]
fn combined_policy_composes_keep_last_and_quota() {
let now = 10_000;
let recs = vec![
rec("a", "1.0.0", 100, Some(now - 4000)),
rec("a", "2.0.0", 100, Some(now - 3000)),
rec("a", "3.0.0", 100, Some(now - 2000)),
rec("a", "4.0.0", 100, Some(now - 1000)),
];
let policy = RetentionPolicy::new()
.keep_last(3)
.quota(150)
.protect_latest(false);
let plan = plan_retention(&recs, &policy, now);
assert!(plan.kept_bytes <= 150);
let kept: Vec<_> = plan.to_keep().map(|r| r.id.version.clone()).collect();
assert_eq!(kept, vec!["4.0.0"]);
let v1 = plan.decisions.iter().find(|d| d.record.id.version == "1.0.0").unwrap();
assert!(v1.reasons.contains(&RetentionReason::SupersededByNewer));
}
#[test]
fn namespace_separates_groups() {
let recs = vec![
ns_rec("org1", "lib", "1.0.0", 1),
ns_rec("org1", "lib", "2.0.0", 1),
ns_rec("org2", "lib", "1.0.0", 1),
ns_rec("org2", "lib", "2.0.0", 1),
];
let plan = plan_retention(&recs, &RetentionPolicy::new().keep_last(1), 1000);
assert_eq!(plan.name_count, 2);
assert_eq!(plan.kept_count, 2);
assert_eq!(plan.delete_count, 2);
}
#[test]
fn plan_is_deterministic() {
let recs = vec![
rec("z", "3.0.0", 7, Some(300)),
rec("a", "1.0.0", 3, Some(100)),
rec("a", "2.0.0", 3, Some(200)),
rec("z", "1.0.0", 7, Some(100)),
];
let policy = RetentionPolicy::new().keep_last(1).quota(20).protect_latest(false);
let p1 = plan_retention(&recs, &policy, 1000);
let p2 = plan_retention(&recs, &policy, 1000);
assert_eq!(p1, p2, "same inputs must produce byte-identical plans");
}
#[test]
fn record_without_created_at_is_never_aged_out() {
let recs = vec![
rec("a", "1.0.0", 1, None),
rec("a", "2.0.0", 1, None),
];
let policy = RetentionPolicy::new().max_age(1).protect_latest(false);
let plan = plan_retention(&recs, &policy, 1_000_000);
assert_eq!(plan.delete_count, 0, "no created_at → age never bites");
}
#[test]
fn cmp_version_orders_numeric_segments() {
use std::cmp::Ordering;
assert_eq!(cmp_version("1.10.0", "1.9.0"), Ordering::Greater);
assert_eq!(cmp_version("2.0.0", "10.0.0"), Ordering::Less);
assert_eq!(cmp_version("1.2.0", "1.2"), Ordering::Greater);
assert_eq!(cmp_version("1.0.0", "1.0.0"), Ordering::Equal);
}
#[test]
fn property_pinned_record_survives_a_sweep_that_reclaims_the_untagged() {
let pinned = rec("a", "1.0.0", 1, None).protected_by_property(true);
let newer = rec("a", "2.0.0", 1, None);
let policy = RetentionPolicy::new().keep_last(1).protect_latest(false);
let plan = plan_retention(&[pinned.clone(), newer.clone()], &policy, 1_000);
let older = plan
.decisions
.iter()
.find(|d| d.record.id.version == "1.0.0")
.unwrap();
assert!(!older.delete, "the property-pinned older version must be KEPT");
assert!(
older.reasons.contains(&RetentionReason::ProtectedByProperty),
"kept for the property pin"
);
let untagged = rec("a", "1.0.0", 1, None); let plan2 = plan_retention(&[untagged, newer], &policy, 1_000);
let older2 = plan2
.decisions
.iter()
.find(|d| d.record.id.version == "1.0.0")
.unwrap();
assert!(older2.delete, "the untagged older version IS reclaimed (control)");
}
#[test]
fn property_pinned_record_is_not_quota_evicted() {
let pinned = rec("keepme", "1.0.0", 100, Some(10)).protected_by_property(true);
let evictable = rec("dropme", "1.0.0", 100, Some(20));
let policy = RetentionPolicy::new().quota(100).protect_latest(false);
let plan = plan_retention(&[pinned, evictable], &policy, 1_000);
let keep = plan.decisions.iter().find(|d| d.record.id.name == "keepme").unwrap();
assert!(!keep.delete, "a property-pinned record must never be quota-evicted");
}
}