use std::collections::BTreeMap;
use std::path::Path;
use crate::Engine;
use crate::anchor::{AnchorProvenanceClass, AnchorState, AnchorVersion};
use crate::binding::{Binding, PruneGuarantee};
use super::resolve::ResolvedIngest;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PruneMode {
NeverClobber,
ConflictFlag,
}
impl PruneMode {
pub fn from_guarantee(guarantee: PruneGuarantee) -> Self {
match guarantee {
PruneGuarantee::NeverClobber => PruneMode::NeverClobber,
PruneGuarantee::ConflictFlag => PruneMode::ConflictFlag,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PruneMerge {
Clean,
Conflict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PruneDisposition {
CleanDelete,
ConflictFlag,
DerivedFlagged,
}
impl PruneDisposition {
pub fn as_wire(&self) -> &'static str {
match self {
PruneDisposition::CleanDelete => "clean-delete",
PruneDisposition::ConflictFlag => "conflict-flag",
PruneDisposition::DerivedFlagged => "derived-flagged",
}
}
}
pub fn classify_prune_candidate(
class: AnchorProvenanceClass,
mode: PruneMode,
base_retrievable: bool,
merge: Option<PruneMerge>,
) -> Option<PruneDisposition> {
match class {
AnchorProvenanceClass::Authored => None,
AnchorProvenanceClass::Derived => Some(PruneDisposition::DerivedFlagged),
AnchorProvenanceClass::Anchored | AnchorProvenanceClass::InformedBy => match mode {
PruneMode::ConflictFlag => Some(PruneDisposition::ConflictFlag),
PruneMode::NeverClobber => {
if base_retrievable && matches!(merge, Some(PruneMerge::Clean)) {
Some(PruneDisposition::CleanDelete)
} else {
Some(PruneDisposition::ConflictFlag)
}
}
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PruneProposal {
pub entity: String,
pub artifacts: Vec<String>,
pub class: String,
pub disposition: PruneDisposition,
pub base_retrievable: bool,
pub derived_inputs: Vec<String>,
}
pub fn prune_proposals(
engine: &Engine,
_workspace_root: &Path,
binding: &Binding,
resolved: &ResolvedIngest,
) -> Vec<PruneProposal> {
let Some(prune) = binding.prune.as_ref() else {
return Vec::new();
};
let mode = PruneMode::from_guarantee(prune.guarantee);
struct Acc {
classes: Vec<AnchorProvenanceClass>,
artifacts: Vec<String>,
base_retrievable: bool,
derived_inputs: Vec<String>,
all_orphaned: bool,
any: bool,
}
let mut by_entity: BTreeMap<String, Acc> = BTreeMap::new();
for (eid, resolved_anchor) in engine.mem_anchors_resolved(&resolved.destination_mem) {
let entry = by_entity.entry(eid.as_ref().to_string()).or_insert(Acc {
classes: Vec::new(),
artifacts: Vec::new(),
base_retrievable: false,
derived_inputs: Vec::new(),
all_orphaned: true,
any: false,
});
entry.any = true;
let anchor = &resolved_anchor.anchor;
entry.classes.push(anchor.class);
entry.artifacts.push(anchor.artifact.clone());
if matches!(anchor.at_version, Some(AnchorVersion::Commit(_))) {
entry.base_retrievable = true;
}
if anchor.class == AnchorProvenanceClass::Derived {
entry
.derived_inputs
.extend(anchor.derived_from.iter().cloned());
}
match resolved_anchor.state {
Some(AnchorState::Orphaned) => {}
_ => entry.all_orphaned = false,
}
}
let mut proposals: Vec<PruneProposal> = Vec::new();
for (entity, acc) in by_entity {
if !acc.any || !acc.all_orphaned {
continue;
}
let dominant = if acc.classes.contains(&AnchorProvenanceClass::Authored) {
AnchorProvenanceClass::Authored
} else if acc.classes.contains(&AnchorProvenanceClass::Derived) {
AnchorProvenanceClass::Derived
} else if acc.classes.contains(&AnchorProvenanceClass::Anchored) {
AnchorProvenanceClass::Anchored
} else {
AnchorProvenanceClass::InformedBy
};
let Some(disposition) =
classify_prune_candidate(dominant, mode, acc.base_retrievable, None)
else {
continue;
};
let mut artifacts = acc.artifacts;
artifacts.sort();
artifacts.dedup();
let mut derived_inputs = acc.derived_inputs;
derived_inputs.sort();
derived_inputs.dedup();
proposals.push(PruneProposal {
entity,
artifacts,
class: dominant.as_wire().to_string(),
disposition,
base_retrievable: acc.base_retrievable,
derived_inputs,
});
}
proposals
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authored_is_never_a_prune_target() {
for mode in [PruneMode::NeverClobber, PruneMode::ConflictFlag] {
assert_eq!(
classify_prune_candidate(
AnchorProvenanceClass::Authored,
mode,
true,
Some(PruneMerge::Clean),
),
None,
"authored must never be proposed for deletion"
);
}
}
#[test]
fn derived_is_flagged_not_deleted() {
for mode in [PruneMode::NeverClobber, PruneMode::ConflictFlag] {
assert_eq!(
classify_prune_candidate(
AnchorProvenanceClass::Derived,
mode,
true,
Some(PruneMerge::Clean),
),
Some(PruneDisposition::DerivedFlagged),
"derived is flagged, never a clean delete"
);
}
}
#[test]
fn conflict_flag_mode_never_clean_deletes() {
for base in [true, false] {
for merge in [None, Some(PruneMerge::Clean), Some(PruneMerge::Conflict)] {
assert_eq!(
classify_prune_candidate(
AnchorProvenanceClass::Anchored,
PruneMode::ConflictFlag,
base,
merge,
),
Some(PruneDisposition::ConflictFlag),
"conflict-flag mode never auto-clean-deletes"
);
}
}
}
#[test]
fn never_clobber_clean_delete_needs_base_and_clean_merge() {
let anchored = AnchorProvenanceClass::Anchored;
assert_eq!(
classify_prune_candidate(
anchored,
PruneMode::NeverClobber,
true,
Some(PruneMerge::Clean)
),
Some(PruneDisposition::CleanDelete)
);
assert_eq!(
classify_prune_candidate(
anchored,
PruneMode::NeverClobber,
false,
Some(PruneMerge::Clean)
),
Some(PruneDisposition::ConflictFlag),
"no base leg degrades to conflict-flag"
);
assert_eq!(
classify_prune_candidate(
anchored,
PruneMode::NeverClobber,
true,
Some(PruneMerge::Conflict)
),
Some(PruneDisposition::ConflictFlag),
"a divergent merge is never a clean delete"
);
assert_eq!(
classify_prune_candidate(anchored, PruneMode::NeverClobber, true, None),
Some(PruneDisposition::ConflictFlag),
"an undetermined merge conservatively conflict-flags"
);
}
#[test]
fn informed_by_follows_the_same_mode_rules() {
assert_eq!(
classify_prune_candidate(
AnchorProvenanceClass::InformedBy,
PruneMode::ConflictFlag,
false,
None,
),
Some(PruneDisposition::ConflictFlag)
);
}
use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorSidecar};
use crate::binding::{
BINDING_VERSION, BuildMode, BuildOperation, DEFAULT_ADJUDICATION_CAP,
DEFAULT_FULL_RESYNC_EVERY, Operations, PruneConfig, VerifyOperation,
};
use crate::ingest::render::render_sync_brief_for;
use crate::ingest::resolve::resolve_binding_run;
use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
use crate::pipeline_store::write_binding;
use crate::workspace::{
Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
};
use crate::workspace_store::WorkspaceStoreAdapter;
fn orphan_anchor(
artifact: &str,
class: AnchorProvenanceClass,
derived_from: Vec<&str>,
commit: Option<&str>,
) -> Anchor {
Anchor {
artifact: artifact.to_string(),
grain: AnchorGrain::File,
class,
at_version: commit.map(|c| AnchorVersion::Commit(c.to_string())),
hash: class.is_hash_bearing().then(|| "recorded".to_string()),
hash_stability: AnchorHashStability::Stable,
derived_from: derived_from.into_iter().map(str::to_string).collect(),
binding: None,
source: None,
}
}
fn setup(
tmp: &Path,
guarantee: PruneGuarantee,
entity_anchors: &[(&str, Vec<Anchor>)],
) -> (Engine, std::path::PathBuf, Binding, ResolvedIngest) {
let root = tmp.to_path_buf();
let mem_dir = root.join("mem");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(
root.join(".memstead").join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
let mount = Mount {
mem: "engine".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
};
crate::FileWorkspaceStore::new()
.save_state(
&root,
&Workspace {
mounts: vec![mount],
settings: WorkspaceSettings::default(),
},
)
.unwrap();
let mut sidecar = AnchorSidecar::default();
for (eid, anchors) in entity_anchors {
sidecar.set(eid, anchors.clone());
}
std::fs::write(
mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
sidecar.to_bytes(),
)
.unwrap();
let binding = Binding {
version: BINDING_VERSION,
intent: None,
sources: vec![crate::pipeline::Source {
name: "graph".to_string(),
medium_type: MediumType::Filesystem,
pointer: String::new(),
change_detection: None,
scope: vec![PatternEntry {
path: "src/**/*.rs".to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: None,
}],
reference_mems: Vec::new(),
destination_mem: "engine".to_string(),
deny_paths: Vec::new(),
coverage_semantics: None,
rules: None,
prune: Some(PruneConfig { guarantee }),
operations: Operations {
build: Some(BuildOperation {
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
post_actions: None,
}),
sync: Some(crate::binding::SyncOperation {
trigger: IngestTrigger::Manual,
batch_size: 20,
}),
verify: Some(VerifyOperation {
trigger: IngestTrigger::Manual,
batch_size: 20,
adjudication_cap: DEFAULT_ADJUDICATION_CAP,
full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
}),
},
};
write_binding(&root, "engine", "graph", &binding).unwrap();
let engine = Engine::from_workspace_root(&root).unwrap();
let resolved = resolve_binding_run("engine/graph", &binding).unwrap();
(engine, root, binding, resolved)
}
#[test]
fn f2_conflict_flag_on_non_git_surfaces_both_sides_no_auto_delete() {
let tmp = tempfile::tempdir().unwrap();
let (engine, root, binding, resolved) = setup(
tmp.path(),
PruneGuarantee::NeverClobber,
&[(
"engine--removed",
vec![orphan_anchor(
"src/removed.rs",
AnchorProvenanceClass::Anchored,
vec![],
None, )],
)],
);
let proposals = prune_proposals(&engine, &root, &binding, &resolved);
assert_eq!(proposals.len(), 1, "the orphaned entity is a candidate");
let p = &proposals[0];
assert_eq!(p.entity, "engine--removed");
assert!(
!p.base_retrievable,
"non-git anchor has no retrievable base"
);
assert_eq!(
p.disposition,
PruneDisposition::ConflictFlag,
"no base leg → conflict-flag degradation, never a clean delete"
);
let brief = render_sync_brief_for(&engine, &root, "engine/graph").unwrap();
assert!(brief.contains("Prune — proposed removals"));
assert!(brief.contains("source side:"), "source side surfaced");
assert!(brief.contains("model side:"), "model side surfaced");
assert!(
brief.contains("never overwrites a model-side edit"),
"no auto-overwrite is stated"
);
let after = engine.mem_anchors_resolved("engine");
assert!(
after.iter().any(|(e, _)| e.as_ref() == "engine--removed"),
"prune must not delete the entity's anchors — it only proposes"
);
}
#[test]
fn f3_authored_excluded_and_derived_flagged_not_deleted() {
let tmp = tempfile::tempdir().unwrap();
let (engine, root, binding, resolved) = setup(
tmp.path(),
PruneGuarantee::ConflictFlag,
&[
(
"engine--handwritten",
vec![orphan_anchor(
"src/authored.rs",
AnchorProvenanceClass::Authored,
vec![],
None,
)],
),
(
"engine--synthesised",
vec![orphan_anchor(
"src/derived.rs",
AnchorProvenanceClass::Derived,
vec!["src/in_a.rs", "src/in_b.rs"],
None,
)],
),
(
"engine--plain",
vec![orphan_anchor(
"src/plain.rs",
AnchorProvenanceClass::Anchored,
vec![],
None,
)],
),
],
);
let proposals = prune_proposals(&engine, &root, &binding, &resolved);
assert!(
!proposals.iter().any(|p| p.entity == "engine--handwritten"),
"an authored entity is never proposed for deletion"
);
let derived = proposals
.iter()
.find(|p| p.entity == "engine--synthesised")
.expect("the derived entity is flagged");
assert_eq!(derived.disposition, PruneDisposition::DerivedFlagged);
assert_eq!(derived.class, "derived");
assert_eq!(
derived.derived_inputs,
vec!["src/in_a.rs".to_string(), "src/in_b.rs".to_string()],
"the derived entity carries its inputs to re-examine"
);
let plain = proposals
.iter()
.find(|p| p.entity == "engine--plain")
.expect("a plain anchored entity is proposed");
assert_eq!(plain.disposition, PruneDisposition::ConflictFlag);
let brief = render_sync_brief_for(&engine, &root, "engine/graph").unwrap();
assert!(brief.contains("flagged, NOT proposed for deletion"));
assert!(brief.contains("`engine--synthesised`"));
assert!(
!brief.contains("engine--handwritten"),
"the authored entity never appears in a prune proposal"
);
assert!(brief.contains("nothing is auto-deleted"));
}
#[test]
fn git_pinned_anchor_reports_a_retrievable_base_leg() {
let tmp = tempfile::tempdir().unwrap();
let (engine, root, binding, resolved) = setup(
tmp.path(),
PruneGuarantee::NeverClobber,
&[(
"engine--pinned",
vec![orphan_anchor(
"src/pinned.rs",
AnchorProvenanceClass::Anchored,
vec![],
Some("deadbeef"),
)],
)],
);
let proposals = prune_proposals(&engine, &root, &binding, &resolved);
assert_eq!(proposals.len(), 1);
assert!(
proposals[0].base_retrievable,
"a git-pinned anchor exposes a retrievable base leg"
);
assert_eq!(proposals[0].disposition, PruneDisposition::ConflictFlag);
}
#[test]
fn entity_with_a_surviving_anchor_is_not_pruned() {
let tmp = tempfile::tempdir().unwrap();
let (engine, root, binding, resolved) = setup(
tmp.path(),
PruneGuarantee::ConflictFlag,
&[(
"engine--partly-gone",
vec![
orphan_anchor("src/gone.rs", AnchorProvenanceClass::Anchored, vec![], None),
orphan_anchor(
"src/present.rs",
AnchorProvenanceClass::InformedBy,
vec![],
None,
),
],
)],
);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
let proposals = prune_proposals(&engine, &root, &binding, &resolved);
assert!(
proposals.is_empty(),
"an entity whose basis is not entirely gone is not a prune candidate"
);
}
}