use std::collections::BTreeSet;
use crate::Engine;
use crate::anchor::AnchorGrain;
use crate::engine::query::ResolvedAnchor;
use crate::entity::EntityId;
use crate::ingest::cursor::build_glob_set;
use crate::ingest::resolve::{ResolvedIngest, ResolvedSource};
use crate::pipeline::PatternMode;
use globset::GlobSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ExclusionReason {
OtherBinding,
OutOfScope,
}
impl ExclusionReason {
pub fn as_wire(self) -> &'static str {
match self {
ExclusionReason::OtherBinding => "other-binding",
ExclusionReason::OutOfScope => "out-of-scope",
}
}
}
#[derive(Debug, Clone)]
pub struct ExcludedAnchor {
pub entity: EntityId,
pub artifact: String,
pub reason: ExclusionReason,
}
#[derive(Debug, Clone)]
pub struct DanglingAnchor {
pub entity: EntityId,
pub artifact: String,
}
#[derive(Debug, Clone, Default)]
pub struct AnchorPopulation {
pub included: Vec<(EntityId, ResolvedAnchor)>,
pub excluded: Vec<ExcludedAnchor>,
pub dangling: Vec<DanglingAnchor>,
pub without_provenance: usize,
pub unreconciled: Option<&'static str>,
}
impl AnchorPopulation {
pub fn distinct_artifacts(&self) -> usize {
self.included
.iter()
.map(|(_, r)| r.anchor.artifact.as_str())
.collect::<BTreeSet<_>>()
.len()
}
pub fn excluded_count(&self, reason: ExclusionReason) -> usize {
self.excluded.iter().filter(|e| e.reason == reason).count()
}
}
pub fn population_for(
engine: &Engine,
resolved: &ResolvedIngest,
binding_hash: Option<&str>,
) -> AnchorPopulation {
let scope = scope_matcher(resolved);
let mut out = AnchorPopulation {
unreconciled: engine
.entity_set_is_reconcilable(&resolved.destination_mem)
.err(),
..Default::default()
};
for (eid, resolved_anchor) in engine.mem_anchors_resolved(&resolved.destination_mem) {
let anchor = &resolved_anchor.anchor;
if out.unreconciled.is_none() && engine.entity_is_absent(&eid) {
out.dangling.push(DanglingAnchor {
entity: eid,
artifact: anchor.artifact.clone(),
});
continue;
}
match (anchor.binding.as_deref(), binding_hash) {
(Some(theirs), Some(ours)) if theirs != ours => {
out.excluded.push(ExcludedAnchor {
entity: eid,
artifact: anchor.artifact.clone(),
reason: ExclusionReason::OtherBinding,
});
continue;
}
_ => {}
}
if let Some(matcher) = &scope
&& !in_declared_scope(matcher, anchor)
{
out.excluded.push(ExcludedAnchor {
entity: eid,
artifact: anchor.artifact.clone(),
reason: ExclusionReason::OutOfScope,
});
continue;
}
if anchor.binding.is_none() {
out.without_provenance += 1;
}
out.included.push((eid, resolved_anchor));
}
out.excluded.sort_by(|a, b| {
(a.reason, &a.artifact, &a.entity.0).cmp(&(b.reason, &b.artifact, &b.entity.0))
});
out.dangling
.sort_by(|a, b| (&a.entity.0, &a.artifact).cmp(&(&b.entity.0, &b.artifact)));
out
}
fn scope_matcher(resolved: &ResolvedIngest) -> Option<ScopeMatcher> {
let mut per_source: Vec<SourceScope> = Vec::new();
for source in &resolved.sources {
if let ResolvedSource::Primary(p) = source {
let mut allows: Vec<&str> = Vec::new();
let mut denies: Vec<&str> = Vec::new();
for rule in &p.scope {
match rule.mode {
PatternMode::Allow => allows.push(rule.path.as_str()),
PatternMode::Deny => denies.push(rule.path.as_str()),
}
}
if allows.is_empty() {
continue;
}
let pointer = p.pointer.trim_end_matches('/');
let pointer = if pointer == "." { "" } else { pointer };
let allows_j: Vec<String> = allows
.iter()
.map(|a| crate::engine::query::join_pointer(pointer, a))
.collect();
let denies_j: Vec<String> = denies
.iter()
.map(|d| crate::engine::query::join_pointer(pointer, d))
.collect();
let allow_refs: Vec<&str> = allows_j.iter().map(String::as_str).collect();
let deny_refs: Vec<&str> = denies_j.iter().map(String::as_str).collect();
let Some(allow_set) = build_glob_set(&allow_refs) else {
continue;
};
per_source.push(SourceScope {
pointer: pointer.to_string(),
allow: allow_set,
deny: if deny_refs.is_empty() {
None
} else {
build_glob_set(&deny_refs)
},
allow_heads: allow_refs.iter().map(|p| literal_head(p)).collect(),
});
}
}
if per_source.is_empty() {
return None;
}
let ws_deny = super::check_path::DenyOracle::new(&resolved.deny_paths);
Some(ScopeMatcher {
per_source,
ws_deny,
})
}
struct ScopeMatcher {
per_source: Vec<SourceScope>,
ws_deny: super::check_path::DenyOracle,
}
struct SourceScope {
pointer: String,
allow: GlobSet,
deny: Option<GlobSet>,
allow_heads: Vec<String>,
}
impl SourceScope {
fn admits(&self, artifact: &str, grain: AnchorGrain) -> bool {
for candidate in self.readings(artifact) {
let path = candidate.trim_end_matches('/');
if self.deny.as_ref().is_some_and(|d| d.is_match(path)) {
continue;
}
if self.allow.is_match(path)
|| (grain == AnchorGrain::Tree && self.covers_tree_for(path))
{
return true;
}
}
false
}
fn readings(&self, artifact: &str) -> Vec<String> {
crate::engine::query::artifact_candidates(&self.pointer, artifact)
}
fn covers_tree_for(&self, dir: &str) -> bool {
let dir = format!("{}/", dir.trim_end_matches('/'));
self.allow_heads
.iter()
.any(|h| h.starts_with(&dir) || dir.starts_with(h.as_str()))
}
}
fn literal_head(pattern: &str) -> String {
let cut = pattern.find(['*', '?', '[', '{']).unwrap_or(pattern.len());
let head = &pattern[..cut];
match head.rfind('/') {
Some(i) => head[..=i].to_string(),
None => String::new(),
}
}
fn in_declared_scope(matcher: &ScopeMatcher, anchor: &crate::anchor::Anchor) -> bool {
if anchor.grain == AnchorGrain::Entity {
return true;
}
let path = anchor.artifact.trim_end_matches('/');
if matcher.ws_deny.is_denied(path) {
return false;
}
matcher
.per_source
.iter()
.any(|s| s.admits(&anchor.artifact, anchor.grain))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Engine;
use crate::anchor::{Anchor, AnchorHashStability, AnchorProvenanceClass, AnchorSidecar};
use crate::binding::BuildMode;
use crate::binding::{
BINDING_VERSION, Binding, BuildOperation, DEFAULT_ADJUDICATION_CAP,
DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
};
use crate::ingest::resolve::resolve_binding_run;
use crate::pipeline::{IngestTrigger, MediumType};
use crate::pipeline::{PatternEntry, PatternMode, Source};
use crate::workspace::{
Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
};
use crate::workspace_store::WorkspaceStoreAdapter;
fn anchor(artifact: &str, binding: Option<&str>, grain: AnchorGrain) -> Anchor {
Anchor {
artifact: artifact.to_string(),
grain,
class: AnchorProvenanceClass::Anchored,
at_version: None,
hash: None,
hash_stability: AnchorHashStability::Stable,
derived_from: vec![],
binding: binding.map(str::to_string),
source: Some("src".to_string()),
span_unvalidated: false,
hash_source: None,
}
}
fn fixture(
tmp: &std::path::Path,
scope: &str,
files: &[&str],
anchors: Vec<Anchor>,
) -> (Engine, ResolvedIngest) {
fixture_with(tmp, scope, files, anchors, true, MountLifecycle::Eager)
}
fn fixture_with(
tmp: &std::path::Path,
scope: &str,
files: &[&str],
anchors: Vec<Anchor>,
write_entity: bool,
lifecycle: MountLifecycle,
) -> (Engine, 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();
for f in files {
let p = root.join(f);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "x\n").unwrap();
}
crate::FileWorkspaceStore::new()
.save_state(
&root,
&Workspace {
mounts: vec![Mount {
mem: "engine".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle,
cross_linkable: false,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
},
)
.unwrap();
if write_entity {
std::fs::write(
mem_dir.join("e.md"),
"---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
)
.unwrap();
}
let mut sidecar = AnchorSidecar::default();
sidecar.set("engine--e", anchors);
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![Source {
name: "src".to_string(),
medium_type: MediumType::Filesystem,
pointer: String::new(),
change_detection: None,
scope: vec![PatternEntry {
path: scope.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: None,
operations: Operations {
build: Some(BuildOperation {
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
post_actions: None,
}),
sync: None,
verify: Some(VerifyOperation {
trigger: IngestTrigger::Manual,
batch_size: 20,
adjudication_cap: DEFAULT_ADJUDICATION_CAP,
full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
}),
},
};
let engine = Engine::from_workspace_root(&root).unwrap();
let resolved = resolve_binding_run("engine/src", &binding).unwrap();
(engine, resolved)
}
#[test]
fn membership_joins_each_source_scope_onto_its_pointer() {
let scope_source = |pointer: &str, pattern: &str| Source {
name: "src".to_string(),
medium_type: MediumType::Filesystem,
pointer: pointer.to_string(),
change_detection: None,
scope: vec![PatternEntry {
path: pattern.to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: None,
};
let resolved_with = |source: Source| {
let binding = Binding {
version: BINDING_VERSION,
intent: None,
sources: vec![source],
reference_mems: Vec::new(),
destination_mem: "engine".to_string(),
deny_paths: Vec::new(),
coverage_semantics: None,
rules: None,
prune: None,
operations: Operations {
build: Some(BuildOperation {
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
post_actions: None,
}),
sync: None,
verify: Some(VerifyOperation {
trigger: IngestTrigger::Manual,
batch_size: 20,
adjudication_cap: DEFAULT_ADJUDICATION_CAP,
full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
}),
},
};
resolve_binding_run("engine/src", &binding).unwrap()
};
let r = resolved_with(scope_source("../dev", "**/*.md"));
let m = scope_matcher(&r).expect("a scoped source yields a matcher");
assert!(
in_declared_scope(
&m,
&anchor("../dev/institute/CHARTER.md", None, AnchorGrain::File)
),
"an anchor under the pointer is in scope after the join"
);
assert!(
!in_declared_scope(&m, &anchor("../other/README.md", None, AnchorGrain::File)),
"a path outside the pointer stays out"
);
let r2 = resolved_with(scope_source("src", "**/*"));
let m2 = scope_matcher(&r2).unwrap();
assert!(
in_declared_scope(&m2, &anchor("src/a.md", None, AnchorGrain::File)),
"under the pointer, in scope"
);
assert!(
in_declared_scope(&m2, &anchor("other/b.md", None, AnchorGrain::File)),
"source-relative reading denotes src/other/b.md, which the scope covers"
);
let r3 = resolved_with(scope_source("src", "*.rs"));
let m3 = scope_matcher(&r3).unwrap();
assert!(
!in_declared_scope(&m3, &anchor("other/b.md", None, AnchorGrain::File)),
"neither reading is admitted by a scope that selects only *.rs"
);
let r0 = resolved_with(scope_source("", "dev/**/*.md"));
let m0 = scope_matcher(&r0).unwrap();
assert!(in_declared_scope(
&m0,
&anchor("dev/notes.md", None, AnchorGrain::File)
));
let r4 = resolved_with(scope_source(".", "dev/**/*.md"));
let m4 = scope_matcher(&r4).unwrap();
assert!(
in_declared_scope(&m4, &anchor("dev/notes.md", None, AnchorGrain::File)),
"a `.` pointer reads exactly as an empty one"
);
}
#[test]
fn each_binding_answers_for_its_own_anchors() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs", "src/b.rs"],
vec![
anchor("src/a.rs", Some("hash-A"), AnchorGrain::File),
anchor("src/b.rs", Some("hash-B"), AnchorGrain::File),
],
);
let a = population_for(&engine, &r, Some("hash-A"));
let b = population_for(&engine, &r, Some("hash-B"));
assert_eq!(a.included.len(), 1);
assert_eq!(b.included.len(), 1);
assert_ne!(
a.included[0].1.anchor.artifact, b.included[0].1.anchor.artifact,
"the two populations differ"
);
assert_eq!(a.excluded.len(), 1);
assert_eq!(a.excluded[0].reason, ExclusionReason::OtherBinding);
assert_eq!(a.excluded[0].artifact, "src/b.rs");
}
#[test]
fn an_out_of_scope_anchor_is_excluded_and_named() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs", "docs/b.md"],
vec![
anchor("src/a.rs", Some("h"), AnchorGrain::File),
anchor("docs/b.md", Some("h"), AnchorGrain::File),
],
);
let pop = population_for(&engine, &r, Some("h"));
assert_eq!(pop.included.len(), 1);
assert_eq!(pop.included[0].1.anchor.artifact, "src/a.rs");
assert_eq!(pop.excluded.len(), 1, "named, not dropped");
assert_eq!(pop.excluded[0].reason, ExclusionReason::OutOfScope);
}
#[test]
fn exclusion_never_mutates_the_sidecar() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![anchor("docs/b.md", Some("h"), AnchorGrain::File)],
);
let before = engine.mem_anchors_resolved("engine").len();
let _ = population_for(&engine, &r, Some("h"));
assert_eq!(engine.mem_anchors_resolved("engine").len(), before);
}
#[test]
fn the_distinct_artifact_count_sits_beside_the_row_count() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![
anchor("src/a.rs", Some("h"), AnchorGrain::File),
anchor("src/a.rs", Some("h"), AnchorGrain::Span),
],
);
let pop = population_for(&engine, &r, Some("h"));
assert_eq!(pop.included.len(), 2, "two rows, not merged");
assert_eq!(pop.distinct_artifacts(), 1, "one artifact");
}
#[test]
fn anchors_without_provenance_are_kept_and_counted() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![anchor("src/a.rs", None, AnchorGrain::File)],
);
let pop = population_for(&engine, &r, Some("h"));
assert_eq!(pop.included.len(), 1);
assert_eq!(pop.without_provenance, 1);
}
#[test]
fn a_deleted_artifact_stays_in_scope_so_its_orphaning_is_reported() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/present.rs"],
vec![
anchor("src/present.rs", Some("h"), AnchorGrain::File),
anchor("src/gone.rs", Some("h"), AnchorGrain::File),
],
);
let pop = population_for(&engine, &r, Some("h"));
assert_eq!(pop.included.len(), 2, "the deleted file is still in scope");
assert!(pop.excluded.is_empty());
}
#[test]
fn a_tree_anchor_is_in_scope_when_the_scope_reaches_under_it() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/sub/a.rs"],
vec![
anchor("src/sub/", Some("h"), AnchorGrain::Tree),
anchor("docs/", Some("h"), AnchorGrain::Tree),
],
);
let pop = population_for(&engine, &r, Some("h"));
assert_eq!(pop.included.len(), 1);
assert_eq!(pop.included[0].1.anchor.artifact, "src/sub/");
assert_eq!(pop.excluded.len(), 1);
assert_eq!(pop.excluded[0].artifact, "docs/");
}
#[test]
fn another_bindings_anchor_is_not_this_bindings_evidence() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![anchor("src/a.rs", Some("theirs"), AnchorGrain::File)],
);
let pop = population_for(&engine, &r, Some("ours"));
assert!(
pop.included.is_empty(),
"the covering anchor belongs to the other binding"
);
assert_eq!(pop.excluded.len(), 1);
assert_eq!(pop.excluded[0].reason, ExclusionReason::OtherBinding);
}
#[test]
fn a_row_whose_entity_is_gone_is_dangling_and_is_its_own_class() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture_with(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![anchor("src/a.rs", Some("h"), AnchorGrain::File)],
false,
MountLifecycle::Eager,
);
let pop = population_for(&engine, &r, Some("h"));
assert_eq!(pop.dangling.len(), 1, "the row is reported");
assert_eq!(pop.dangling[0].entity.as_ref(), "engine--e");
assert_eq!(pop.dangling[0].artifact, "src/a.rs");
assert!(pop.included.is_empty(), "and raises no figure");
assert!(
pop.excluded.is_empty(),
"an exclusion bucket would name it legal, which it is not"
);
assert_eq!(pop.unreconciled, None);
}
#[test]
fn detecting_a_dangling_row_never_touches_the_sidecar() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture_with(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![anchor("src/a.rs", Some("h"), AnchorGrain::File)],
false,
MountLifecycle::Eager,
);
let path = tmp
.path()
.join("mem")
.join(crate::anchor::ANCHOR_SIDECAR_PATH);
let before = std::fs::read(&path).unwrap();
let pop = population_for(&engine, &r, Some("h"));
assert_eq!(pop.dangling.len(), 1);
assert_eq!(
std::fs::read(&path).unwrap(),
before,
"the sidecar is evidence, not a mess to tidy"
);
}
#[test]
fn a_mem_whose_entities_are_not_loaded_says_so_instead_of_reporting_clean() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture_with(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![anchor("src/a.rs", Some("h"), AnchorGrain::File)],
false,
MountLifecycle::Lazy,
);
let pop = population_for(&engine, &r, Some("h"));
assert!(
pop.unreconciled.is_some(),
"the surface must be able to say the entity end was not examined"
);
assert!(
pop.dangling.is_empty(),
"and must not fabricate dangling rows from an unloaded store"
);
assert_eq!(
pop.included.len(),
1,
"the artifact end is still adjudicable and is still reported"
);
}
#[test]
fn an_entity_that_exists_produces_no_dangling_row() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![anchor("src/a.rs", Some("h"), AnchorGrain::File)],
);
let pop = population_for(&engine, &r, Some("h"));
assert!(pop.dangling.is_empty());
assert_eq!(pop.included.len(), 1);
}
#[test]
fn the_fallback_counter_counts_only_what_survived_scope() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![anchor("docs/b.md", None, AnchorGrain::File)],
);
let pop = population_for(&engine, &r, Some("h"));
assert!(pop.included.is_empty());
assert_eq!(
pop.without_provenance, 0,
"an excluded anchor was never kept by the fallback"
);
}
#[test]
fn the_row_count_is_the_populations_own_size() {
let tmp = tempfile::tempdir().unwrap();
let (engine, r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![
anchor("src/a.rs", Some("h"), AnchorGrain::File),
anchor("src/a.rs", Some("h"), AnchorGrain::Span),
anchor("src/b.rs", Some("h"), AnchorGrain::File),
],
);
let pop = population_for(&engine, &r, Some("h"));
assert_eq!(pop.included.len(), 3);
assert_eq!(pop.distinct_artifacts(), 2);
assert!(
pop.included.len() >= pop.distinct_artifacts(),
"rows can never be fewer than the artifacts they cover"
);
}
#[test]
fn an_unscoped_source_excludes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let (engine, mut r) = fixture(
tmp.path(),
"src/**/*.rs",
&["src/a.rs"],
vec![anchor("anywhere/x.md", Some("h"), AnchorGrain::File)],
);
if let Some(ResolvedSource::Primary(p)) = r.sources.first_mut() {
p.scope.clear();
}
let pop = population_for(&engine, &r, Some("h"));
assert_eq!(pop.included.len(), 1);
assert!(pop.excluded.is_empty());
}
}