Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/src/retention.rs

//! Retention / GC **planner** — the pure function that decides which artifacts a
//! repository should keep and which it should reclaim.
//!
//! This is the answer to the Artifactory/Nexus "retention policy" table stakes
//! (`.nornir/feature-complete-gaps.md` §4, the highest operability gap), shipped
//! the way the parity design demands: a **pure, deterministic function over
//! artifact metadata** — no I/O, no scheduler, no deletion. [`plan_retention`]
//! takes a slice of [`ArtifactRecord`] plus a [`RetentionPolicy`] and returns a
//! [`RetentionPlan`]: a keep/delete verdict per artifact, each annotated with the
//! [`RetentionReason`]s that produced it, plus the reclaimable-byte total.
//!
//! Why a planner and not a deleter: holger's storage is immutable, blake3-verified
//! znippy archives (retention at the *archive-set* level, never in-archive edits).
//! The plan is the decision; carrying it out belongs to a jera job that drops whole
//! superseded generations, and the decision is historized to skade — exactly the
//! reuse note in the gap doc. Keeping the planner pure means it is trivially
//! testable, re-runnable on a policy change, and identical whether it drives a
//! `--dry-run` CLI preview, a gRPC verb, or the eventual jera executor.
//!
//! ## Policy dimensions (all optional, all composable)
//!
//! * `keep_last_n`          — keep the N newest **versions per name**, reclaim the rest.
//! * `max_age_secs`         — reclaim anything older than this (needs a `created_at`).
//! * `keep_min_per_name`    — a floor: never drop below this many versions of a name,
//!   even when age would.
//! * `max_total_bytes`      — a store-size quota: after the above, evict oldest-first
//!   until the kept set fits, honouring the floors.
//! * `protect_latest_per_name` — the single newest version of every name is never
//!   reclaimed (default `true`, the safety net).
//!
//! Ordering is deterministic end to end (group by `(namespace, name)`, newest-first
//! within a group by `created_at` then version), so the same inputs always yield the
//! same plan — a property the tests pin.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

use traits::ArtifactId;

fn default_true() -> bool {
    true
}

/// One artifact considered for retention. Pure data — the planner never does I/O;
/// callers gather these from a backend's `list()` (see
/// `Holger::plan_repo_retention`) or synthesize them in tests/benches.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactRecord {
    /// The artifact coordinate. Records are grouped by `(namespace, name)`; the
    /// `version` breaks newest-first ties when `created_at` is absent or equal.
    pub id: ArtifactId,
    /// Size on disk (uncompressed bytes) — the reclaim/quota accounting unit.
    pub size_bytes: u64,
    /// Ingest time (unix seconds). `None` when the backend can't supply one
    /// (immutable znippy archives carry no per-file mtime today — see the deferred
    /// note in `holger_agents_problems.md`); age policies then no-op for this
    /// record while `keep_last_n` / `max_total_bytes` still apply, ordered by
    /// version.
    pub created_at: Option<i64>,
    /// Protected from reclamation by a **custom property** (`RetentionPolicy::
    /// protect_property`). The pure planner honours this flag — a `true` record is
    /// never selected for deletion by any rule (superseded / aged / quota) — but
    /// never computes it: the property lookup lives in the adapter
    /// (`Holger::plan_repo_retention`), which reads the `PropertyStore` and sets
    /// this, keeping the planner I/O-free. Default `false`.
    #[serde(default)]
    pub protected_by_property: bool,
}

impl ArtifactRecord {
    /// Build a record from a `list()` entry with no known ingest time. `size_bytes`
    /// is clamped at zero (the proto carries an `i64`).
    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,
        }
    }

    /// Mark this record as protected by a custom property (builder-style, used by
    /// the adapter after a `PropertyStore` lookup).
    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())
    }
}

/// A retention policy: which artifacts to keep. Every dimension is optional and
/// they compose (an artifact is reclaimed if any rule selects it, unless a
/// protection rule saves it). Deserializes straight from RON.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetentionPolicy {
    /// Keep the N newest **versions per name**; reclaim the rest. `None` = no bound.
    #[serde(default)]
    pub keep_last_n: Option<usize>,
    /// Reclaim records older than this many seconds. Needs `created_at`; a record
    /// with none is never aged out. `None` = no age bound.
    #[serde(default)]
    pub max_age_secs: Option<i64>,
    /// Floor: never drop below this many versions of a name, even when age would.
    /// `None` = no floor. Independent of `protect_latest_per_name`.
    #[serde(default)]
    pub keep_min_per_name: Option<usize>,
    /// Store-size quota (bytes): after the per-name rules, evict oldest-first until
    /// the kept set fits under this, honouring the protection floors. `None` = no
    /// quota.
    #[serde(default)]
    pub max_total_bytes: Option<u64>,
    /// Never reclaim the single newest version of any name. Default `true` — the
    /// safety net that stops an aggressive `max_age`/quota from emptying a repo.
    #[serde(default = "default_true")]
    pub protect_latest_per_name: bool,
    /// Protect any artifact carrying this custom **property** `(key, value)` from
    /// reclamation — the "pin this blob" escape hatch (e.g. `("keep","true")`, or
    /// `("keep","")` for "any value under `keep`"). The pure planner does not read
    /// this (it has no store); the adapter (`Holger::plan_repo_retention`) resolves
    /// it against the `PropertyStore` and sets `ArtifactRecord::protected_by_property`.
    /// `None` = no property protection. `.nornir/property-policy-design.md`.
    #[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 {
    /// A policy that selects nothing for deletion (the identity plan) — the base
    /// for the builder methods below.
    pub fn new() -> Self {
        Self::default()
    }
    /// Keep the N newest versions per name.
    pub fn keep_last(mut self, n: usize) -> Self {
        self.keep_last_n = Some(n);
        self
    }
    /// Reclaim records older than `secs`.
    pub fn max_age(mut self, secs: i64) -> Self {
        self.max_age_secs = Some(secs);
        self
    }
    /// Never drop below `n` versions per name.
    pub fn keep_min(mut self, n: usize) -> Self {
        self.keep_min_per_name = Some(n);
        self
    }
    /// Cap the kept set at `bytes` total (oldest-first eviction).
    pub fn quota(mut self, bytes: u64) -> Self {
        self.max_total_bytes = Some(bytes);
        self
    }
    /// Toggle the "never delete the latest version of a name" safety net.
    pub fn protect_latest(mut self, on: bool) -> Self {
        self.protect_latest_per_name = on;
        self
    }
    /// Pin any artifact carrying the custom property `(key, value)` from
    /// reclamation (an empty `value` = "the key is present, any value"). The
    /// adapter resolves this against the `PropertyStore`.
    pub fn protect_property_kv(mut self, key: &str, value: &str) -> Self {
        self.protect_property = Some((key.to_string(), value.to_string()));
        self
    }
    /// True when the policy would never select anything for deletion regardless of
    /// input (all bounds unset). The plan is then the pure keep-everything identity.
    pub fn is_noop(&self) -> bool {
        self.keep_last_n.is_none() && self.max_age_secs.is_none() && self.max_total_bytes.is_none()
    }
}

/// Why the planner kept or reclaimed a record. A kept record carries the
/// protection/keep reasons; a reclaimed one carries the rule(s) that selected it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RetentionReason {
    /// KEEP — the single newest version of its name (`protect_latest_per_name`).
    ProtectedLatest,
    /// KEEP — carries the `protect_property` custom property (e.g. `keep=true`).
    ProtectedByProperty,
    /// KEEP — within the `keep_min_per_name` floor.
    MinPerNameFloor,
    /// KEEP — within the `keep_last_n` newest versions.
    WithinKeepLastN,
    /// KEEP — younger than `max_age_secs`.
    WithinMaxAge,
    /// KEEP — no deletion rule selected it.
    NoPolicyMatched,
    /// DELETE — beyond `keep_last_n` (a newer version supersedes it).
    SupersededByNewer,
    /// DELETE — older than `max_age_secs`.
    AgedOut,
    /// DELETE — evicted to bring the kept set under `max_total_bytes`.
    QuotaEvicted,
}

impl RetentionReason {
    /// Short stable slug for CLI/JSON output.
    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",
        }
    }
}

/// The per-artifact verdict: keep or delete, plus the reason(s).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetentionDecision {
    pub record: ArtifactRecord,
    /// `true` = reclaim this artifact; `false` = keep it.
    pub delete: bool,
    /// The reasons that produced the verdict (deletion rules when `delete`, keep/
    /// protection reasons otherwise).
    pub reasons: Vec<RetentionReason>,
}

/// A full retention plan: a verdict per input record plus the roll-up totals. The
/// decisions are in a deterministic order (by `(namespace, name)`, newest-first
/// within a name).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetentionPlan {
    pub decisions: Vec<RetentionDecision>,
    /// How many records are kept.
    pub kept_count: usize,
    /// How many records are selected for reclamation.
    pub delete_count: usize,
    /// Total bytes of the kept records.
    pub kept_bytes: u64,
    /// Total bytes that carrying out the plan would reclaim.
    pub reclaimed_bytes: u64,
    /// Number of distinct `(namespace, name)` groups evaluated.
    pub name_count: usize,
    /// The `now` the plan was evaluated at (unix seconds) — stamped so a stored
    /// plan is reproducible.
    pub evaluated_at: i64,
}

impl RetentionPlan {
    /// The records this plan would reclaim, in plan order.
    pub fn to_delete(&self) -> impl Iterator<Item = &ArtifactRecord> {
        self.decisions
            .iter()
            .filter(|d| d.delete)
            .map(|d| &d.record)
    }
    /// The records this plan would keep, in plan order.
    pub fn to_keep(&self) -> impl Iterator<Item = &ArtifactRecord> {
        self.decisions
            .iter()
            .filter(|d| !d.delete)
            .map(|d| &d.record)
    }
}

// ============================================================================
// Executor contract (pure data — the executor lives on the `Holger` facade,
// `Holger::execute_repo_retention`, so this module stays I/O-free). The planner
// decides; the executor consumes a `RetentionPlan` and acts.
// ============================================================================

/// How to carry out a [`RetentionPlan`]. **Dry-run by default** — nothing is
/// deleted unless `dry_run` is explicitly set to `false`. This is the gate: a
/// preview costs nothing and drops nothing; an actual reclaim is an opt-in.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GcOptions {
    /// `true` (the default) ⇒ report what *would* be reclaimed, unlink nothing.
    /// `false` ⇒ actually drop the plan's delete-set from the store.
    pub dry_run: bool,
}

impl Default for GcOptions {
    fn default() -> Self {
        Self { dry_run: true }
    }
}

impl GcOptions {
    /// The safe default: a dry-run that deletes nothing.
    pub fn dry_run() -> Self {
        Self { dry_run: true }
    }
    /// Opt in to an actual reclaim — the executor will unlink the plan's
    /// delete-set (each safely, content-id-verified).
    pub fn execute() -> Self {
        Self { dry_run: false }
    }
}

/// What the executor did (or, in a dry-run, would do) with one artifact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GcOutcome {
    /// Bytes were actually reclaimed from the store.
    Deleted,
    /// Dry-run: these bytes *would* be reclaimed (nothing was unlinked).
    WouldDelete,
    /// Not acted on — the reason is carried (protected by the plan, already
    /// absent, …). Never an error, just a no-op the operator should see.
    Skipped(String),
    /// A delete was attempted but failed (content-id mismatch, IO error, or a
    /// backend that does not support in-place delete). The store is unchanged for
    /// this artifact; the reason is carried.
    Failed(String),
}

impl GcOutcome {
    /// Short stable slug for CLI/JSON output.
    pub fn as_str(&self) -> &'static str {
        match self {
            GcOutcome::Deleted => "deleted",
            GcOutcome::WouldDelete => "would-delete",
            GcOutcome::Skipped(_) => "skipped",
            GcOutcome::Failed(_) => "failed",
        }
    }
}

/// The executor's per-artifact record — the plan's coordinate plus what became
/// of it and the content id that was verified before any unlink.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GcAction {
    pub id: ArtifactId,
    pub size_bytes: u64,
    /// The lowercase-hex SHA-256 the executor computed for the stored bytes and
    /// verified against the backend before unlinking. `None` when the artifact
    /// was absent or protected (never read).
    pub content_id: Option<String>,
    pub outcome: GcOutcome,
    /// The plan's reasons this coordinate was in the delete-set.
    pub reasons: Vec<RetentionReason>,
}

/// The result of carrying out (or previewing) a [`RetentionPlan`] against one
/// repository — the GC record historized to the warehouse/audit plane.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GcReport {
    pub repository: String,
    /// Whether this was a preview (`true`) or an actual reclaim (`false`).
    pub dry_run: bool,
    pub actions: Vec<GcAction>,
    /// Artifacts actually reclaimed (0 in a dry-run).
    pub deleted_count: usize,
    /// Bytes actually reclaimed (0 in a dry-run).
    pub reclaimed_bytes: u64,
    /// Artifacts the executor would reclaim in a dry-run (0 outside a dry-run).
    pub would_delete_count: usize,
    /// Artifacts left alone (protected, already absent).
    pub skipped_count: usize,
    /// Artifacts a delete was attempted for but failed.
    pub failed_count: usize,
    /// **Reclaims whose audit record could not be written** (A28 M17): the artifact is gone
    /// and the append-only trail does not say so. Never swallowed: each one is also counted
    /// on `/metrics` (`holger_audit_records_missed_total`) and warned.
    #[serde(default)]
    pub audit_missed: usize,
    /// The `evaluated_at` of the plan that produced this report.
    pub evaluated_at: i64,
}

/// **Must a reclaim wait for the twin?** (stinky SB #35) — the reason, or `None`.
///
/// A primary's retention must not drop what its twin has not yet followed: the
/// twin is the copy a dead main is rebuilt from, and an artifact reclaimed here
/// before the twin fetched it is an artifact nobody can give back. `twins` is
/// every enrolled twin with the moment it has followed to (Unix ms,
/// `crate::grow::followed_to`); `newest_write_ms` is the newest write into the
/// repository the audit log records. Pure:
///
/// * no twin enrolled ⇒ `None` (nothing to wait for);
/// * a twin that never completed a follow ⇒ wait;
/// * no write time on record ⇒ wait (nothing proves the twin is past it);
/// * the newest write AFTER a twin's follow ⇒ wait until it follows again.
pub fn twin_holds(twins: &[(String, Option<i64>)], newest_write_ms: Option<i64>) -> Option<String> {
    if twins.is_empty() {
        return None;
    }
    if let Some((t, _)) = twins.iter().find(|(_, at)| at.is_none()) {
        return Some(format!("the twin `{t}` has not completed a follow yet; the reclaim waits until it holds this repository"));
    }
    let Some(newest) = newest_write_ms else {
        return Some("no write time for this repository is on record (audit off or empty), so nothing proves the twin holds it; the reclaim waits".to_string());
    };
    let (t, at) = twins
        .iter()
        .filter_map(|(t, at)| at.map(|a| (t, a)))
        .min_by_key(|(_, a)| *a)?;
    (newest > at).then(|| format!("the newest write here ({newest} ms) is after the twin `{t}` last followed ({at} ms); the reclaim waits until it follows again"))
}

/// Compare two version strings newest-first-friendly: split on `.`, `-`, `+`,
/// compare segment by segment, numerically when both segments parse as integers
/// (so `10` > `9`), lexically otherwise. A pure prerelease-agnostic ordering — it
/// is only a *tie-break* for grouping, not a semver authority.
///
/// ★ **Public because it is THE version ordering in this server, and there must
/// be exactly one.** The front page's "latest release" table is folded with this
/// function (`exposed/http.rs`, the `/-/releases` door), not with a comparator
/// of its own — otherwise the page and a retention sweep would name different
/// artifacts as the newest the first time somebody published `1.10.0` beside
/// `1.9.0`, with nothing to say which of them was wrong.
pub 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;
        }
    }
    // Longer version (more segments) sorts as the greater when the shared prefix
    // is equal (1.2.1 > 1.2), keeping the order total + stable.
    sa.len().cmp(&sb.len())
}

/// The pure planner. Groups `records` by `(namespace, name)`, orders each group
/// newest-first, applies the per-name rules (`protect_latest`, `keep_min`,
/// `keep_last_n`, `max_age`), then the global `max_total_bytes` quota (oldest-first
/// eviction, honouring the floors), and returns the [`RetentionPlan`].
///
/// Deterministic: the same `(records, policy, now)` always yields the same plan.
/// Total function: an empty input yields an empty plan; a no-op policy keeps
/// everything.
pub fn plan_retention(
    records: &[ArtifactRecord],
    policy: &RetentionPolicy,
    now: i64,
) -> RetentionPlan {
    // 1. Group by (namespace, name), deterministic order via BTreeMap. Each group
    //    holds indices into `records` so we can rebuild the plan in a stable order.
    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();

    // 2. Order each group newest-first: created_at desc (unknown time sorts oldest),
    //    then version desc, then a stable version-string tie-break.
    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))
        });
    }

    // 3. Per-name rules → an initial decision per record (keyed by original index).
    let mut delete = vec![false; records.len()];
    let mut reasons: Vec<Vec<RetentionReason>> = vec![Vec::new(); records.len()];
    // Track protection so the quota pass can't evict a protected/floored record.
    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);
            // A custom-property pin (adapter-computed) is a hard protection too, so
            // no rule — superseded / aged / quota — can ever reclaim it.
            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);
                }
            }
        }
    }

    // 4. Quota pass: if the kept set still exceeds `max_total_bytes`, evict the
    //    kept, non-protected records oldest-first (created_at asc, then version asc)
    //    until it fits (or nothing evictable remains).
    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 {
            // Eviction order: oldest first (unknown time = oldest = evicted first),
            // then oldest version, then a stable index tie-break.
            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);
            }
        }
    }

    // 5. Assemble the plan in the deterministic group/newest-first order.
    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() {
        // Five versions of `a`, keep the 2 newest → reclaim 3 oldest.
        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);
        // The two kept are the newest versions.
        let kept: Vec<_> = plan.to_keep().map(|r| r.id.version.clone()).collect();
        assert_eq!(kept, vec!["5.0.0", "4.0.0"]);
        // Version ordering is numeric, not lexical (10 > 9).
        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);
        // One newest kept per name → 2 kept, 2 reclaimed.
        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)), // old
            rec("a", "2.0.0", 1, Some(now - 100)),  // young
            rec("a", "3.0.0", 1, Some(now - 50)),   // youngest
        ];
        // max_age 1000s, and turn OFF latest-protection so age can bite freely.
        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;
        // Both ancient; only one version of `a`.
        let recs = vec![rec("a", "1.0.0", 1, Some(now - 9000))];
        let policy = RetentionPolicy::new().max_age(10); // everything is "old"
        let plan = plan_retention(&recs, &policy, now);
        // protect_latest defaults ON → the lone newest survives.
        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;
        // Three old versions; max_age would drop all, but keep_min 2 floors it, and
        // protect_latest is off so the floor (not protection) is what saves them.
        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);
        // 2 newest floored-kept, oldest aged out.
        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;
        // Four artifacts of distinct names, 100 bytes each = 400 total. Quota 250 →
        // must evict 2 (oldest first). protect_latest is per-name; distinct names so
        // each is a "latest" — turn it off so the quota can actually act.
        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);
        // Oldest two (a, b) evicted.
        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;
        // One name, two versions, 100 bytes each. Quota 50 (< one artifact). The
        // newest is protected → it must survive even though the quota can't be met.
        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); // protect_latest defaults ON
        let plan = plan_retention(&recs, &policy, now);
        // Older evicted; newest protected stays despite the quota being unmet.
        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;
        // Name `a`: 4 versions. keep_last 3 drops v1; then quota trims more oldest.
        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);
        // keep_last 3 removes v1 (superseded); quota 150 then evicts v2 & v3 oldest
        // -first, leaving only v4 (100 <= 150).
        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"]);
        // v1's reason is superseded (per-name rule), v2/v3 are quota-evicted.
        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() {
        // Same name, different namespace = different groups; keep_last 1 keeps one
        // newest per (namespace, name).
        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);
    }

    /// ★ **A reclaim waits for the twin** (stinky SB #35): no twin, nothing to
    /// wait for; a twin that never followed, or followed before the newest
    /// write, or no write time on record — wait.
    #[test]
    fn a_reclaim_waits_until_the_twin_has_followed_the_newest_write() {
        assert_eq!(twin_holds(&[], Some(5)), None, "no twin enrolled");
        let followed =
            |at: Option<i64>| vec![("vm3".to_string(), Some(100)), ("vm4".to_string(), at)];
        assert_eq!(
            twin_holds(&followed(Some(200)), Some(90)),
            None,
            "every twin followed past the newest write"
        );
        let w = twin_holds(&followed(None), Some(90)).expect("a twin that never followed");
        assert!(
            w.contains("vm4") && w.contains("not completed a follow"),
            "{w}"
        );
        let w = twin_holds(&followed(Some(200)), Some(150))
            .expect("the newest write is after vm3's follow");
        assert!(
            w.contains("vm3") && w.contains("150") && w.contains("100"),
            "{w}"
        );
        assert!(
            twin_holds(&followed(Some(200)), None).is_some(),
            "no write time: nothing proves the twin holds it"
        );
    }

    #[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() {
        // A record with no ingest time can't be aged out, but keep_last still applies.
        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);
    }

    /// RED-when-broken (property-policy §): a blob PINNED by a custom property
    /// survives a `keep_last_n=1` sweep that reclaims its untagged sibling. Without
    /// the `protected_by_property` honouring, the older pinned version would be
    /// SupersededByNewer and deleted.
    #[test]
    fn property_pinned_record_survives_a_sweep_that_reclaims_the_untagged() {
        // Two versions of "a"; keep_last_n=1 would normally reclaim the older 1.0.0.
        // Pin 1.0.0 with a property; it must be KEPT while an untagged 1.0.0 would go.
        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"
        );

        // Control (RED-when-broken): the SAME shape UNtagged is reclaimed.
        let untagged = rec("a", "1.0.0", 1, None); // not pinned
        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)"
        );
    }

    /// A property pin also beats a byte quota: a pinned record is never quota-evicted.
    #[test]
    fn property_pinned_record_is_not_quota_evicted() {
        // Two names, each one version, 100 bytes; quota=100 forces one eviction.
        let pinned = rec("keepme", "1.0.0", 100, Some(10)).protected_by_property(true);
        let evictable = rec("dropme", "1.0.0", 100, Some(20));
        // protect_latest off so only the pin can save "keepme".
        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"
        );
    }

    /// **The plan accounts for every record, exactly once, and the roll-ups add
    /// up.** A plan is what a GC executor unlinks from, so a record that fell out
    /// of the decision list — or a byte total that disagrees with the verdicts —
    /// would mean deleting (or keeping) something nobody decided on.
    #[test]
    fn every_record_gets_exactly_one_decision_and_the_totals_reconcile() {
        let records = vec![
            rec("serde", "1.0.0", 10, Some(100)),
            rec("serde", "1.1.0", 20, Some(200)),
            rec("serde", "1.2.0", 30, Some(300)),
            ns_rec("org.example", "widget", "1.0", 40),
            ns_rec("org.example", "widget", "2.0", 50),
            rec("lonely", "0.1.0", 60, None),
        ];
        let policy = RetentionPolicy::new().keep_last(1).max_age(50).quota(100);
        let plan = plan_retention(&records, &policy, 1_000);

        assert_eq!(
            plan.decisions.len(),
            records.len(),
            "one verdict per record"
        );
        assert_eq!(plan.kept_count + plan.delete_count, records.len());
        assert_eq!(
            plan.kept_bytes + plan.reclaimed_bytes,
            records.iter().map(|r| r.size_bytes).sum::<u64>(),
            "no byte is counted twice or lost"
        );
        assert_eq!(plan.name_count, 3, "serde, org.example/widget, lonely");
        assert_eq!(
            plan.evaluated_at, 1_000,
            "the plan stamps when it was evaluated"
        );
        assert_eq!(plan.to_delete().count(), plan.delete_count);
        assert_eq!(plan.to_keep().count(), plan.kept_count);
        assert!(
            plan.decisions.iter().all(|d| !d.reasons.is_empty()),
            "every verdict names at least one reason"
        );

        // Every input coordinate appears in the plan exactly once.
        for r in &records {
            let hits = plan
                .decisions
                .iter()
                .filter(|d| d.record.id == r.id)
                .count();
            assert_eq!(hits, 1, "{:?} must appear exactly once", r.id);
        }
    }

    /// The safety net holds at the extremes: `keep_last(0)` and a zero quota both
    /// mean "reclaim everything", yet the newest version of every name still
    /// survives while `protect_latest_per_name` is on. A policy typo must not be
    /// able to empty a repository.
    #[test]
    fn the_latest_survives_even_a_zero_keep_and_a_zero_quota() {
        let records = vec![
            rec("serde", "1.0.0", 10, Some(100)),
            rec("serde", "2.0.0", 10, Some(200)),
            rec("tokio", "1.0.0", 10, Some(300)),
        ];

        let plan = plan_retention(
            &records,
            &RetentionPolicy::new().keep_last(0).quota(0),
            1_000,
        );
        let survivors: Vec<&str> = plan.to_keep().map(|r| r.id.version.as_str()).collect();
        assert_eq!(plan.kept_count, 2, "one survivor per name: {survivors:?}");
        assert!(survivors.contains(&"2.0.0") && survivors.contains(&"1.0.0"));
        assert!(
            plan.decisions
                .iter()
                .filter(|d| !d.delete)
                .all(|d| d.reasons.contains(&RetentionReason::ProtectedLatest)),
            "the survivors are exactly the ones the safety net saved"
        );

        // With the net explicitly OFF, the same policy really does reclaim everything.
        let bare = RetentionPolicy::new()
            .keep_last(0)
            .quota(0)
            .protect_latest(false);
        let plan = plan_retention(&records, &bare, 1_000);
        assert_eq!(
            plan.kept_count, 0,
            "opting out of the net is the operator's choice"
        );
        assert_eq!(plan.reclaimed_bytes, 30);
    }

    /// The planner is a TOTAL function: duplicate coordinates, zero-byte records,
    /// enormous sizes, an empty name, and `created_at` at both ends of the `i64`
    /// range (the age arithmetic must saturate, not overflow) all yield a plan
    /// rather than a panic.
    #[test]
    fn hostile_inputs_yield_a_plan_not_a_panic() {
        let huge = u64::MAX / 8; // enormous, but summable without overflowing the roll-up
        let records = vec![
            rec("dup", "1.0.0", 0, None),
            rec("dup", "1.0.0", 0, None), // the same coordinate twice
            rec("", "", huge, Some(i64::MIN)),
            rec("huge", "1.0.0", huge, Some(i64::MAX)),
            rec("huge", "0.9.0", huge, None),
        ];
        let policy = RetentionPolicy::new().keep_last(1).max_age(1).quota(1);
        let plan = plan_retention(&records, &policy, 0);

        assert_eq!(
            plan.decisions.len(),
            records.len(),
            "every record is accounted for"
        );
        assert_eq!(plan.kept_count + plan.delete_count, records.len());
        // The same plan twice: identical, byte for byte.
        assert_eq!(
            plan,
            plan_retention(&records, &policy, 0),
            "the planner is deterministic"
        );
    }

    /// A policy is CONFIG: it round-trips through RON, and a policy written before
    /// a dimension existed still parses — with `protect_latest_per_name` defaulting
    /// to ON. RED-when-broken if that default ever flips: every old config on disk
    /// would silently start reclaiming the newest version of every name.
    #[test]
    fn a_policy_round_trips_through_ron_and_defaults_to_protecting_the_latest() {
        let older: RetentionPolicy = ron::from_str("(keep_last_n: Some(3))")
            .expect("a policy naming only one dimension must parse");
        assert!(
            older.protect_latest_per_name,
            "an unspecified safety net is ON — never off by omission"
        );
        assert_eq!(older.keep_last_n, Some(3));
        assert_eq!(older.max_age_secs, None);
        assert_eq!(older.protect_property, None);

        let full = RetentionPolicy::new()
            .keep_last(5)
            .max_age(86_400)
            .keep_min(2)
            .quota(1 << 30)
            .protect_latest(false)
            .protect_property_kv("keep", "true");
        let text = ron::to_string(&full).unwrap();
        assert_eq!(
            ron::from_str::<RetentionPolicy>(&text).unwrap(),
            full,
            "every dimension survives a config round-trip"
        );

        // `is_noop` is what a caller checks before planning at all.
        assert!(RetentionPolicy::new().is_noop());
        assert!(!full.is_noop());
        assert!(
            RetentionPolicy::new().keep_min(1).is_noop(),
            "a floor alone never selects anything for deletion"
        );
    }
}