use lex_store::{
policy::{GcRetention, PolicyFile},
Operation, OperationKind, StageTransition, Store, DEFAULT_BRANCH,
};
use std::collections::BTreeSet;
fn fresh() -> (Store, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let s = Store::open(tmp.path()).unwrap();
(s, tmp)
}
fn make_add_op(sig: &str, stage: &str) -> (Operation, StageTransition) {
let op = Operation::new(
OperationKind::AddFunction {
sig_id: sig.into(),
stage_id: stage.into(),
effects: BTreeSet::new(),
budget_cost: None,
},
[],
);
let t = StageTransition::Create {
sig_id: sig.into(),
stage_id: stage.into(),
};
(op, t)
}
#[test]
fn fresh_store_plans_no_deletions() {
let (s, _tmp) = fresh();
let plan = s.plan_gc(&[]).unwrap();
assert!(plan.is_empty());
assert_eq!(plan.retained.len(), 0);
}
#[test]
fn ops_reachable_from_branches_are_always_retained() {
let (s, _tmp) = fresh();
let (op, t) = make_add_op("fa", "stg-fa");
let id_a = s.apply_operation(DEFAULT_BRANCH, op, t).unwrap();
let op_b = Operation::new(
OperationKind::ModifyBody {
sig_id: "fa".into(),
from_stage_id: "stg-fa".into(),
to_stage_id: "stg-fa-2".into(),
from_budget: None,
to_budget: None,
},
[id_a.clone()],
);
let id_b = s.apply_operation(DEFAULT_BRANCH, op_b, StageTransition::Replace {
sig_id: "fa".into(),
from: "stg-fa".into(),
to: "stg-fa-2".into(),
}).unwrap();
let plan = s.plan_gc(&[]).unwrap();
assert!(plan.is_empty(), "no orphans → nothing to delete");
assert!(plan.retained.contains_key(&id_a));
assert!(plan.retained.contains_key(&id_b));
}
#[test]
fn parent_of_retained_orphan_is_retained_transitively() {
let (s, _tmp) = fresh();
let (op, t) = make_add_op("fa", "stg-fa");
let id_a = s.apply_operation(DEFAULT_BRANCH, op, t).unwrap();
let op_b = Operation::new(
OperationKind::ModifyBody {
sig_id: "fa".into(),
from_stage_id: "stg-fa".into(),
to_stage_id: "stg-fa-2".into(),
from_budget: None,
to_budget: None,
},
[id_a.clone()],
);
let id_b = s.apply_operation(DEFAULT_BRANCH, op_b, StageTransition::Replace {
sig_id: "fa".into(),
from: "stg-fa".into(),
to: "stg-fa-2".into(),
}).unwrap();
let plan = s.plan_gc(&[]).unwrap();
assert_eq!(
plan.retained.get(&id_a),
Some(&lex_store::RetentionReason::ReachableFromBranch)
);
assert_eq!(
plan.retained.get(&id_b),
Some(&lex_store::RetentionReason::ReachableFromBranch)
);
assert!(plan.is_empty());
}
#[test]
fn predicate_match_retains_orphaned_ops() {
let (s, _tmp) = fresh();
let (op_a, t_a) = make_add_op("fa", "stg-fa");
let id_a = s.apply_operation(DEFAULT_BRANCH, op_a, t_a).unwrap();
let orphan = Operation::new(
OperationKind::AddFunction {
sig_id: "fb".into(), stage_id: "stg-fb".into(),
effects: BTreeSet::new(), budget_cost: None,
},
[],
);
let orphan_record = lex_vcs::OperationRecord::new(
orphan,
StageTransition::Create { sig_id: "fb".into(), stage_id: "stg-fb".into() },
);
let id_orphan = orphan_record.op_id.clone();
let log = lex_vcs::OpLog::open(s.root()).unwrap();
log.put(&orphan_record).unwrap();
let plan = s.plan_gc(&[]).unwrap();
assert_eq!(plan.to_delete, vec![id_orphan.clone()]);
assert!(plan.retained.contains_key(&id_a));
let plan = s.plan_gc(&[lex_vcs::Predicate::All]).unwrap();
assert!(plan.is_empty());
match plan.retained.get(&id_orphan) {
Some(lex_store::RetentionReason::MatchedPredicate(0)) => {}
other => panic!("orphan should be retained by predicate index 0; got {other:?}"),
}
}
#[test]
fn apply_gc_deletes_orphans_and_is_idempotent() {
let (s, _tmp) = fresh();
let (op_a, t_a) = make_add_op("fa", "stg-fa");
s.apply_operation(DEFAULT_BRANCH, op_a, t_a).unwrap();
let orphan = lex_vcs::OperationRecord::new(
Operation::new(
OperationKind::AddFunction {
sig_id: "fb".into(), stage_id: "stg-fb".into(),
effects: BTreeSet::new(), budget_cost: None,
},
[],
),
StageTransition::Create { sig_id: "fb".into(), stage_id: "stg-fb".into() },
);
let id_orphan = orphan.op_id.clone();
let log = lex_vcs::OpLog::open(s.root()).unwrap();
log.put(&orphan).unwrap();
let plan = s.plan_gc(&[]).unwrap();
assert_eq!(plan.to_delete.len(), 1);
let removed = s.apply_gc(&plan).unwrap();
assert_eq!(removed, 1);
assert!(log.get(&id_orphan).unwrap().is_none(),
"orphan must be gone after apply_gc");
let plan2 = s.plan_gc(&[]).unwrap();
assert!(plan2.is_empty());
let removed2 = s.apply_gc(&plan2).unwrap();
assert_eq!(removed2, 0);
}
#[test]
fn policy_json_retain_predicates_are_honored() {
let (s, _tmp) = fresh();
let (op_a, t_a) = make_add_op("fa", "stg-fa");
s.apply_operation(DEFAULT_BRANCH, op_a, t_a).unwrap();
let orphan = lex_vcs::OperationRecord::new(
Operation::new(
OperationKind::AddFunction {
sig_id: "fb".into(), stage_id: "stg-fb".into(),
effects: BTreeSet::new(), budget_cost: None,
},
[],
),
StageTransition::Create { sig_id: "fb".into(), stage_id: "stg-fb".into() },
);
let id_orphan = orphan.op_id.clone();
let log = lex_vcs::OpLog::open(s.root()).unwrap();
log.put(&orphan).unwrap();
let policy = PolicyFile {
gc_retention: GcRetention {
retain: vec![serde_json::json!({"predicate": "all"})],
},
..Default::default()
};
lex_store::policy::save(s.root(), &policy).unwrap();
let plan = s.plan_gc(&[]).unwrap();
assert!(plan.is_empty(), "policy retain rule must keep the orphan");
assert!(plan.retained.contains_key(&id_orphan));
}
#[test]
fn evict_rewrites_packed_ops_into_a_smaller_pack() {
let (s, tmp) = fresh();
let log = lex_vcs::OpLog::open(s.root()).unwrap();
for i in 0..3 {
let rec = lex_vcs::OperationRecord::new(
Operation::new(
OperationKind::AddFunction {
sig_id: format!("f{i}"),
stage_id: format!("s{i}"),
effects: BTreeSet::new(),
budget_cost: None,
},
[],
),
StageTransition::Create {
sig_id: format!("f{i}"),
stage_id: format!("s{i}"),
},
);
log.put(&rec).unwrap();
}
log.repack(0).unwrap();
let live = log.list_all().unwrap()[0].clone();
s.apply_operation(
DEFAULT_BRANCH,
live.op.clone(),
live.produces.clone(),
).unwrap();
let plan = s.plan_gc(&[]).unwrap();
assert_eq!(plan.to_delete.len(), 2, "two orphans");
let removed = s.apply_gc(&plan).unwrap();
assert_eq!(removed, 2);
let surviving_id = live.op_id.clone();
assert!(log.get(&surviving_id).unwrap().is_some());
let n_packs = std::fs::read_dir(tmp.path().join("ops")).unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
.count();
assert_eq!(n_packs, 1, "old pack deleted, new pack written");
let all = log.list_all().unwrap();
assert_eq!(all.len(), 1);
}