1use std::collections::{BTreeMap, BTreeSet};
50use std::path::{Path, PathBuf};
51
52use crate::Engine;
53use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
54
55use super::cursor::{
56 compute_source_cursor, enumerate_source_artifacts, enumerate_source_artifacts_reported,
57 medium_base, normalize_lexical, relative_path,
58};
59use super::resolve::{ResolvedIngest, ResolvedSource};
60use super::slice::Slice;
61
62const STATE_DIR: &str = "state";
65const ADVANCE_DIR: &str = "advance";
67
68#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77pub struct AdvanceState {
78 pub binding: String,
80 pub frozen_slice: Slice,
82 pub dispositions: BTreeMap<String, String>,
84 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92 pub exclusions: BTreeMap<String, String>,
93 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
100 pub exclusion_sources: BTreeMap<String, String>,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub dropped_exclusions: Vec<DroppedExclusion>,
106}
107
108#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111pub struct DroppedExclusion {
112 pub artifact: String,
113 pub source: String,
116 pub rationale: String,
117 pub dropped_at: String,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
122pub struct ActiveExclusion {
123 pub artifact: String,
124 pub source: String,
125 pub rationale: String,
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
131pub struct ExclusionLedger {
132 pub active: Vec<ActiveExclusion>,
133 pub dropped: Vec<DroppedExclusion>,
134}
135
136pub fn reconcile_exclusions(
146 engine: &Engine,
147 workspace_root: &Path,
148 resolved: &ResolvedIngest,
149) -> Result<ExclusionLedger, StoreError> {
150 let Ok((mem, name)) = split_binding_id(&resolved.name) else {
151 return Ok(ExclusionLedger::default());
152 };
153 let Some(mut state) = read_advance_store(workspace_root, &mem, &name)? else {
154 return Ok(ExclusionLedger::default());
155 };
156 let declared: BTreeSet<String> = resolved
157 .sources
158 .iter()
159 .filter_map(|s| match s {
160 ResolvedSource::Primary(p) => Some(p.name.clone()),
161 ResolvedSource::Reference { .. } => None,
162 })
163 .collect();
164 let mut changed = false;
165 let mut membership: Option<BTreeMap<String, String>> = None;
166 let mut dropped_now: Vec<DroppedExclusion> = Vec::new();
167 let mut active: Vec<ActiveExclusion> = Vec::new();
168 for (artifact, rationale) in state.exclusions.clone() {
169 let recorded = state.exclusion_sources.get(&artifact).cloned();
170 let source = match recorded {
171 Some(s) if declared.contains(&s) => Some(s),
172 Some(s) => {
173 dropped_now.push(DroppedExclusion {
174 artifact: artifact.clone(),
175 source: s,
176 rationale: rationale.clone(),
177 dropped_at: crate::engine::mutation::iso_now(),
178 });
179 None
180 }
181 None => {
182 let facets = membership.get_or_insert_with(|| {
183 let mut m = BTreeMap::new();
184 for s in &resolved.sources {
185 if let ResolvedSource::Primary(p) = s {
186 for f in enumerate_source_artifacts(
187 engine,
188 p,
189 &resolved.deny_paths,
190 workspace_root,
191 ) {
192 m.entry(f).or_insert_with(|| p.name.clone());
193 }
194 }
195 }
196 m
197 });
198 match facets.get(&artifact).cloned() {
199 Some(f) => {
200 state.exclusion_sources.insert(artifact.clone(), f.clone());
201 changed = true;
202 Some(f)
203 }
204 None => {
205 dropped_now.push(DroppedExclusion {
206 artifact: artifact.clone(),
207 source: "unattributed".to_string(),
208 rationale: rationale.clone(),
209 dropped_at: crate::engine::mutation::iso_now(),
210 });
211 None
212 }
213 }
214 }
215 };
216 match source {
217 Some(source) => active.push(ActiveExclusion {
218 artifact,
219 source,
220 rationale,
221 }),
222 None => {
223 state.exclusions.remove(&artifact);
224 state.exclusion_sources.remove(&artifact);
225 changed = true;
226 }
227 }
228 }
229 let mut dropped = std::mem::take(&mut state.dropped_exclusions);
232 if !dropped.is_empty() {
233 changed = true;
234 }
235 dropped.extend(dropped_now);
236 if changed {
237 if state.exclusions.is_empty()
238 && state.frozen_slice == Slice::default()
239 && state.dispositions.is_empty()
240 {
241 delete_advance_store(workspace_root, &mem, &name)?;
242 } else {
243 write_advance_store(workspace_root, &mem, &name, &state)?;
244 }
245 }
246 Ok(ExclusionLedger { active, dropped })
247}
248
249pub const EXCLUDED_VERDICT: &str = "excluded";
255
256#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
268#[serde(untagged)]
269pub enum DispositionInput {
270 Verdict(String),
272 Reasoned {
274 disposition: String,
276 rationale: String,
278 },
279}
280
281impl DispositionInput {
282 pub fn verdict(&self) -> &str {
284 match self {
285 DispositionInput::Verdict(v) => v,
286 DispositionInput::Reasoned { disposition, .. } => disposition,
287 }
288 }
289
290 pub fn rationale(&self) -> Option<&str> {
292 match self {
293 DispositionInput::Verdict(_) => None,
294 DispositionInput::Reasoned { rationale, .. } => Some(rationale),
295 }
296 }
297}
298
299impl AdvanceState {
300 pub fn disposed(&self) -> usize {
303 self.dispositions.len()
304 }
305
306 pub fn pending(&self) -> usize {
310 artifact_set(&self.frozen_slice)
311 .iter()
312 .filter(|a| !self.dispositions.contains_key(a.as_str()))
313 .count()
314 }
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct AdvanceOutcome {
320 pub binding: String,
322 pub remainder: Slice,
325 pub disposed: usize,
327 pub pending: usize,
329 pub completed: bool,
332 pub tokens_written: Vec<String>,
335 pub warnings: Vec<String>,
338}
339
340#[derive(Debug, thiserror::Error)]
342pub enum AdvanceError {
343 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
345 MalformedId(String),
346 #[error(
355 "disposition names {} artifact id(s) the engine did not present: {}; the advance gate \
356 accepts only ids from the presented slice, verbatim in their workspace-relative form \
357 ({printed} presented){}",
358 artifacts.len(),
359 fmt_list(artifacts),
360 fmt_suggestions(suggestions)
361 )]
362 UnknownArtifact {
363 artifacts: Vec<String>,
365 printed: usize,
367 suggestions: Vec<(String, String)>,
371 },
372 #[error("advance store error: {0}")]
374 Store(#[source] StoreError),
375 #[error("could not advance baseline token: {0}")]
377 Engine(String),
378}
379
380fn fmt_list(names: &[String]) -> String {
382 if names.is_empty() {
383 "(none)".to_string()
384 } else {
385 names.join(", ")
386 }
387}
388
389fn fmt_suggestions(suggestions: &[(String, String)]) -> String {
393 if suggestions.is_empty() {
394 return String::new();
395 }
396 let pairs = suggestions
397 .iter()
398 .map(|(supplied, corrected)| format!("`{supplied}` → `{corrected}`"))
399 .collect::<Vec<_>>()
400 .join(", ");
401 format!(
402 ". Some supplied ids look medium-relative; the slice presents them workspace-relative — \
403 retry with {pairs} (the medium-relative form is never accepted)"
404 )
405}
406
407fn derive_corrected_ids(
412 unknown: &[String],
413 resolved: &ResolvedIngest,
414 printed: &BTreeSet<String>,
415) -> Vec<(String, String)> {
416 let medium_roots: Vec<&str> = resolved
417 .sources
418 .iter()
419 .filter_map(|s| match s {
420 ResolvedSource::Primary(p) if !p.pointer.is_empty() => Some(p.pointer.as_str()),
421 _ => None,
422 })
423 .collect();
424 unknown
425 .iter()
426 .filter_map(|id| {
427 medium_roots.iter().find_map(|root| {
428 let candidate = format!("{}/{id}", root.trim_end_matches('/'));
429 printed
430 .contains(candidate.as_str())
431 .then(|| (id.clone(), candidate))
432 })
433 })
434 .collect()
435}
436
437fn split_binding_id(binding_id: &str) -> Result<(String, String), AdvanceError> {
441 binding_id
442 .split_once('/')
443 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
444 .map(|(m, n)| (m.to_string(), n.to_string()))
445 .ok_or_else(|| AdvanceError::MalformedId(binding_id.to_string()))
446}
447
448pub(crate) fn is_single_component(value: &str) -> bool {
452 !value.is_empty()
453 && value != "."
454 && value != ".."
455 && !value.contains('/')
456 && !value.contains('\\')
457 && !value.contains(':')
458 && !value.contains('\0')
459}
460
461pub fn advance_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
463 workspace_root
464 .join(WORKSPACE_STORE_DIR)
465 .join(STATE_DIR)
466 .join(ADVANCE_DIR)
467 .join(mem)
468 .join(format!("{name}.json"))
469}
470
471pub fn read_advance_store(
475 workspace_root: &Path,
476 mem: &str,
477 name: &str,
478) -> Result<Option<AdvanceState>, StoreError> {
479 let path = advance_store_path(workspace_root, mem, name);
480 match std::fs::read(&path) {
481 Ok(bytes) => serde_json::from_slice(&bytes)
482 .map(Some)
483 .map_err(|e| StoreError::Parse {
484 path,
485 message: e.to_string(),
486 }),
487 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
488 Err(e) => Err(StoreError::Io { path, source: e }),
489 }
490}
491
492pub fn write_advance_store(
495 workspace_root: &Path,
496 mem: &str,
497 name: &str,
498 state: &AdvanceState,
499) -> Result<(), StoreError> {
500 super::findings::ensure_selfignoring_store_dir(
503 &workspace_root
504 .join(WORKSPACE_STORE_DIR)
505 .join(STATE_DIR)
506 .join(ADVANCE_DIR),
507 )?;
508 let path = advance_store_path(workspace_root, mem, name);
509 if let Some(parent) = path.parent() {
510 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
511 path: parent.to_path_buf(),
512 source: e,
513 })?;
514 }
515 let bytes = serde_json::to_vec_pretty(state).map_err(|e| StoreError::Parse {
516 path: path.clone(),
517 message: e.to_string(),
518 })?;
519 std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
520}
521
522pub fn delete_advance_store(
525 workspace_root: &Path,
526 mem: &str,
527 name: &str,
528) -> Result<(), StoreError> {
529 let path = advance_store_path(workspace_root, mem, name);
530 match std::fs::remove_file(&path) {
531 Ok(()) => Ok(()),
532 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
533 Err(e) => Err(StoreError::Io { path, source: e }),
534 }
535}
536
537fn union_slice(into: &mut Slice, from: &Slice) {
539 into.added.extend(from.added.iter().cloned());
540 into.modified.extend(from.modified.iter().cloned());
541 into.deleted.extend(from.deleted.iter().cloned());
542 for v in [&mut into.added, &mut into.modified, &mut into.deleted] {
543 v.sort();
544 v.dedup();
545 }
546}
547
548fn artifact_set(slice: &Slice) -> BTreeSet<String> {
551 slice
552 .added
553 .iter()
554 .chain(slice.modified.iter())
555 .chain(slice.deleted.iter())
556 .cloned()
557 .collect()
558}
559
560fn subtract_disposed(frozen: &Slice, dispositions: &BTreeMap<String, String>) -> Slice {
563 let keep = |v: &[String]| -> Vec<String> {
564 v.iter()
565 .filter(|a| !dispositions.contains_key(*a))
566 .cloned()
567 .collect()
568 };
569 Slice {
570 added: keep(&frozen.added),
571 modified: keep(&frozen.modified),
572 deleted: keep(&frozen.deleted),
573 }
574}
575
576pub fn advance_baseline(
595 engine: &mut Engine,
596 workspace_root: &Path,
597 resolved: &ResolvedIngest,
598 dispositions: &BTreeMap<String, DispositionInput>,
599) -> Result<AdvanceOutcome, AdvanceError> {
600 let binding_id = resolved.name.clone();
601 let (mem, name) = split_binding_id(&binding_id)?;
602
603 let cursor = compute_source_cursor(engine, resolved, workspace_root);
607
608 let mut state = read_advance_store(workspace_root, &mem, &name)
610 .map_err(AdvanceError::Store)?
611 .unwrap_or_else(|| AdvanceState {
612 binding: binding_id.clone(),
613 ..Default::default()
614 });
615
616 union_slice(&mut state.frozen_slice, &cursor.union);
618 let printed = artifact_set(&state.frozen_slice);
619
620 let mut unknown: Vec<String> = dispositions
623 .keys()
624 .filter(|a| !printed.contains(a.as_str()))
625 .cloned()
626 .collect();
627 if !unknown.is_empty() {
628 unknown.sort();
629 unknown.dedup();
630 let suggestions = derive_corrected_ids(&unknown, resolved, &printed);
635 return Err(AdvanceError::UnknownArtifact {
636 artifacts: unknown,
637 printed: printed.len(),
638 suggestions,
639 });
640 }
641
642 for (artifact, input) in dispositions {
647 state
648 .dispositions
649 .insert(artifact.clone(), input.verdict().to_string());
650 if input.verdict() == EXCLUDED_VERDICT {
651 state.exclusions.insert(
652 artifact.clone(),
653 input.rationale().unwrap_or("").to_string(),
654 );
655 } else {
656 state.exclusions.remove(artifact);
657 state.exclusion_sources.remove(artifact);
658 }
659 }
660
661 let auto_worked: Vec<String> = printed
668 .iter()
669 .filter(|art| !state.dispositions.contains_key(art.as_str()))
670 .filter(|art| {
671 let (base, key) = crate::preparation::split_unit_id(art);
676 engine
677 .anchors_referencing_artifact(base)
678 .iter()
679 .any(|(eid, a)| {
680 eid.mem() == resolved.destination_mem.as_str()
681 && (key.is_none() || a.artifact == **art)
682 })
683 })
684 .cloned()
685 .collect();
686 for art in auto_worked {
687 state.dispositions.insert(art, "worked".to_string());
688 }
689
690 let remainder = subtract_disposed(&state.frozen_slice, &state.dispositions);
692 let pending = remainder.added.len() + remainder.modified.len() + remainder.deleted.len();
693 let completed = pending == 0;
694
695 let mut warnings: Vec<String> = Vec::new();
696 let mut tokens_written: Vec<String> = Vec::new();
697 if completed {
698 let note = format!(
702 "projection advance {binding_id}: {} artifact(s) disposed, baseline advanced",
703 state.dispositions.len()
704 );
705 for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
706 let outcome = engine
707 .set_mem_sync_state(&resolved.destination_mem, &c.key, &c.token, Some(¬e))
708 .map_err(|e| AdvanceError::Engine(e.to_string()))?;
709 warnings.extend(outcome.warnings.iter().map(ToString::to_string));
710 tokens_written.push(c.key.clone());
711 }
712 if state.exclusions.is_empty() {
718 delete_advance_store(workspace_root, &mem, &name).map_err(AdvanceError::Store)?;
719 } else {
720 let durable = AdvanceState {
721 binding: binding_id.clone(),
722 frozen_slice: Slice::default(),
723 dispositions: BTreeMap::new(),
724 exclusions: state.exclusions.clone(),
725 exclusion_sources: state.exclusion_sources.clone(),
726 dropped_exclusions: state.dropped_exclusions.clone(),
727 };
728 write_advance_store(workspace_root, &mem, &name, &durable)
729 .map_err(AdvanceError::Store)?;
730 }
731 } else {
732 write_advance_store(workspace_root, &mem, &name, &state).map_err(AdvanceError::Store)?;
734 }
735
736 Ok(AdvanceOutcome {
737 binding: binding_id,
738 remainder,
739 disposed: state.dispositions.len(),
740 pending,
741 completed,
742 tokens_written,
743 warnings,
744 })
745}
746
747#[derive(Debug, Clone, PartialEq, Eq)]
749pub struct ExcludeOutcome {
750 pub recorded: Vec<(String, String)>,
754 pub binding: String,
756 pub excluded: usize,
758 pub added: usize,
760}
761
762#[derive(Debug, thiserror::Error)]
764pub enum ExcludeError {
765 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
767 MalformedId(String),
768 #[error(
771 "exclusion names {} artifact id(s) not in the binding's enumerable source S(D): {}; \
772 only an in-scope source member can be declared excluded ({printed} enumerated)",
773 artifacts.len(),
774 fmt_list(artifacts)
775 )]
776 NotSourceMember {
777 artifacts: Vec<String>,
779 printed: usize,
781 nearest: BTreeMap<String, Vec<String>>,
785 },
786 #[error(
791 "the binding's source enumeration is incomplete — {reason} — so `S(D)` membership \
792 cannot be decided; fix the named scope pattern(s), then re-declare the exclusions"
793 )]
794 PartialEnumeration {
795 facet: String,
797 reason: String,
799 },
800 #[error(
805 "exclusion id {} is ambiguous: it resolves under {} of the binding's sources, as {}; \
806 re-declare it with one of those workspace-relative ids",
807 fmt_list(&ambiguous.keys().cloned().collect::<Vec<_>>()),
808 ambiguous.values().map(|c| c.len()).max().unwrap_or(0),
809 fmt_list(&ambiguous.values().flatten().cloned().collect::<Vec<_>>())
810 )]
811 AmbiguousArtifact {
812 ambiguous: BTreeMap<String, Vec<String>>,
815 },
816 #[error("advance store error: {0}")]
818 Store(#[source] StoreError),
819}
820
821pub fn record_exclusions(
836 engine: &Engine,
837 workspace_root: &Path,
838 resolved: &ResolvedIngest,
839 exclusions: &BTreeMap<String, String>,
840) -> Result<ExcludeOutcome, ExcludeError> {
841 let binding_id = resolved.name.clone();
842 let (mem, name) =
843 split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
844
845 let mut s_d: BTreeSet<String> = BTreeSet::new();
852 let mut facet_of: BTreeMap<String, String> = BTreeMap::new();
855 for source in &resolved.sources {
856 if let ResolvedSource::Primary(p) = source {
857 let walked = enumerate_source_artifacts_reported(
858 engine,
859 p,
860 &resolved.deny_paths,
861 workspace_root,
862 );
863 if let Some(reason) = walked.partiality_reason() {
864 return Err(ExcludeError::PartialEnumeration {
865 facet: p.name.clone(),
866 reason,
867 });
868 }
869 for f in &walked.files {
870 facet_of.entry(f.clone()).or_insert_with(|| p.name.clone());
871 }
872 s_d.extend(walked.files);
873 }
874 }
875
876 let bases: Vec<PathBuf> = resolved
885 .sources
886 .iter()
887 .filter_map(|s| match s {
888 ResolvedSource::Primary(p) => Some(medium_base(&p.pointer, workspace_root)),
889 ResolvedSource::Reference { .. } => None,
890 })
891 .collect();
892 let mut canonical: BTreeMap<String, String> = BTreeMap::new();
893 let mut not_member: Vec<String> = Vec::new();
894 let mut ambiguous: BTreeMap<String, Vec<String>> = BTreeMap::new();
895 for requested in exclusions.keys() {
896 if s_d.contains(requested.as_str()) {
897 canonical.insert(requested.clone(), requested.clone());
898 continue;
899 }
900 match crate::engine::query::resolve_across_sources(bases.iter(), requested, |base, id| {
907 let candidate = relative_path(workspace_root, &normalize_lexical(&base.join(id)))
908 .to_string_lossy()
909 .to_string();
910 s_d.contains(candidate.as_str()).then_some(candidate)
911 }) {
912 crate::engine::query::CrossSourceArtifact::Unique(c) => {
913 canonical.insert(requested.clone(), c);
914 }
915 crate::engine::query::CrossSourceArtifact::Ambiguous(cands) => {
916 ambiguous.insert(requested.clone(), cands);
917 }
918 crate::engine::query::CrossSourceArtifact::Unresolved => {
919 not_member.push(requested.clone())
920 }
921 }
922 }
923 if !ambiguous.is_empty() {
926 return Err(ExcludeError::AmbiguousArtifact { ambiguous });
927 }
928 if !not_member.is_empty() {
929 not_member.sort();
930 not_member.dedup();
931 let nearest = not_member
932 .iter()
933 .map(|id| (id.clone(), nearest_known_ids(id, &s_d)))
934 .collect();
935 return Err(ExcludeError::NotSourceMember {
936 artifacts: not_member,
937 printed: s_d.len(),
938 nearest,
939 });
940 }
941
942 let mut state = read_advance_store(workspace_root, &mem, &name)
945 .map_err(ExcludeError::Store)?
946 .unwrap_or_else(|| AdvanceState {
947 binding: binding_id.clone(),
948 ..Default::default()
949 });
950 let mut added = 0usize;
951 let mut recorded: Vec<(String, String)> = Vec::new();
952 for (requested, rationale) in exclusions {
953 let artifact = &canonical[requested];
954 if state
955 .exclusions
956 .insert(artifact.clone(), rationale.clone())
957 .is_none()
958 {
959 added += 1;
960 }
961 if let Some(facet) = facet_of.get(artifact) {
962 state
963 .exclusion_sources
964 .insert(artifact.clone(), facet.clone());
965 }
966 recorded.push((requested.clone(), artifact.clone()));
967 }
968 write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
969
970 Ok(ExcludeOutcome {
971 recorded,
972 binding: binding_id,
973 excluded: state.exclusions.len(),
974 added,
975 })
976}
977
978fn nearest_known_ids(unknown: &str, known: &BTreeSet<String>) -> Vec<String> {
982 let name = unknown.rsplit('/').next().unwrap_or(unknown);
983 let tail: Vec<&str> = unknown.rsplit('/').take(2).collect();
984 let mut scored: Vec<(usize, &String)> = known
985 .iter()
986 .filter_map(|k| {
987 let kname = k.rsplit('/').next().unwrap_or(k);
988 let ktail: Vec<&str> = k.rsplit('/').take(2).collect();
989 let same_dir = tail.len() > 1 && ktail.get(1) == tail.get(1);
990 let score = if kname == name && same_dir {
991 3
992 } else if kname == name {
993 2
994 } else if same_dir || k.contains(name) || name.contains(kname) {
995 1
996 } else {
997 0
998 };
999 (score > 0).then_some((score, k))
1000 })
1001 .collect();
1002 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1)));
1003 scored.into_iter().take(5).map(|(_, k)| k.clone()).collect()
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009 use crate::binding::BuildMode;
1010 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
1011 use crate::storage::FilesystemMemWriter;
1012 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1013 use tempfile::TempDir;
1014
1015 fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
1018 Slice {
1019 added: added.iter().map(|s| s.to_string()).collect(),
1020 modified: modified.iter().map(|s| s.to_string()).collect(),
1021 deleted: deleted.iter().map(|s| s.to_string()).collect(),
1022 }
1023 }
1024
1025 fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
1026 pairs
1027 .iter()
1028 .map(|(a, d)| (a.to_string(), d.to_string()))
1029 .collect()
1030 }
1031
1032 fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
1035 pairs
1036 .iter()
1037 .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
1038 .collect()
1039 }
1040
1041 #[test]
1043 fn advance_store_round_trips_and_delete_is_idempotent() {
1044 let tmp = TempDir::new().unwrap();
1045 let root = tmp.path();
1046 assert!(
1047 read_advance_store(root, "engine", "graph")
1048 .unwrap()
1049 .is_none()
1050 );
1051
1052 let state = AdvanceState {
1053 binding: "engine/graph".to_string(),
1054 frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
1055 dispositions: disp(&[("a.rs", "worked")]),
1056 exclusions: BTreeMap::new(),
1057 ..Default::default()
1058 };
1059 write_advance_store(root, "engine", "graph", &state).unwrap();
1060 assert!(
1061 advance_store_path(root, "engine", "graph")
1062 .ends_with("state/advance/engine/graph.json")
1063 );
1064 let back = read_advance_store(root, "engine", "graph")
1065 .unwrap()
1066 .unwrap();
1067 assert_eq!(back, state);
1068
1069 delete_advance_store(root, "engine", "graph").unwrap();
1070 assert!(
1071 read_advance_store(root, "engine", "graph")
1072 .unwrap()
1073 .is_none()
1074 );
1075 delete_advance_store(root, "engine", "graph").unwrap();
1077 }
1078
1079 #[test]
1081 fn subtract_disposed_removes_disposed_from_every_class() {
1082 let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
1083 let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
1084 assert_eq!(out, slice(&[], &[], &["b.rs"]));
1085 }
1086
1087 fn git(repo: &Path, args: &[&str]) {
1090 let out = std::process::Command::new("git")
1091 .args(args)
1092 .current_dir(repo)
1093 .env("GIT_AUTHOR_NAME", "t")
1094 .env("GIT_AUTHOR_EMAIL", "t@t")
1095 .env("GIT_COMMITTER_NAME", "t")
1096 .env("GIT_COMMITTER_EMAIL", "t@t")
1097 .output()
1098 .unwrap();
1099 assert!(
1100 out.status.success(),
1101 "git {args:?}: {}",
1102 String::from_utf8_lossy(&out.stderr)
1103 );
1104 }
1105
1106 fn head_sha(repo: &Path) -> String {
1107 String::from_utf8(
1108 std::process::Command::new("git")
1109 .args(["rev-parse", "HEAD"])
1110 .current_dir(repo)
1111 .output()
1112 .unwrap()
1113 .stdout,
1114 )
1115 .unwrap()
1116 .trim()
1117 .to_string()
1118 }
1119
1120 fn resolved_engine_graph() -> ResolvedIngest {
1124 use super::super::resolve::{ResolvedSource, Source};
1125 ResolvedIngest {
1126 name: "engine/graph".to_string(),
1127 mode: BuildMode::Discovery,
1128 trigger: IngestTrigger::Loop,
1129 batch_size: 20,
1130 deny_paths: vec![],
1131 projection_ref: "engine/graph".to_string(),
1132 projection_mem: "engine".to_string(),
1133 projection_name: "graph".to_string(),
1134 intent: None,
1135 sources: vec![ResolvedSource::Primary(Source {
1136 name: "source-tree".to_string(),
1137 medium_type: MediumType::Codebase,
1138 pointer: String::new(),
1139 change_detection: Some("git".to_string()),
1140 scope: vec![PatternEntry {
1141 path: "**/*.rs".to_string(),
1142 mode: PatternMode::Allow,
1143 }],
1144 engagement: None,
1145 preparation: None,
1146 })],
1147 destination_mem: "engine".to_string(),
1148 rules: None,
1149 post_actions: None,
1150 }
1151 }
1152
1153 fn engine_at(root: &Path) -> Engine {
1157 let config_path = root.join(".memstead").join("config.json");
1161 if !config_path.exists() {
1162 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1163 std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
1164 }
1165 let mount = Mount {
1166 mem: "engine".to_string(),
1167 schema: Some("default@1.0.0".parse().unwrap()),
1168 storage: MountStorage::Folder {
1169 path: root.to_path_buf(),
1170 },
1171 capability: MountCapability::Write,
1172 lifecycle: MountLifecycle::Eager,
1173 cross_linkable: false,
1174 migration_target: None,
1175 };
1176 Engine::from_mounts(vec![(
1177 mount,
1178 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
1179 as Box<dyn crate::backend::MemBackend>,
1180 )])
1181 .unwrap()
1182 }
1183
1184 fn synced_key() -> &'static str {
1185 "engine/graph/source-tree#synced"
1186 }
1187
1188 #[test]
1201 fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
1202 let tmp = TempDir::new().unwrap();
1203 let root = tmp.path();
1204
1205 git(root, &["init", "-q"]);
1207 std::fs::write(root.join("a.rs"), "one").unwrap();
1208 std::fs::write(root.join("b.rs"), "bee").unwrap();
1209 git(root, &["add", "a.rs", "b.rs"]);
1210 git(root, &["commit", "-qm", "base"]);
1211 let baseline = head_sha(root);
1212
1213 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1215 std::fs::remove_file(root.join("b.rs")).unwrap();
1216 git(root, &["add", "-A"]);
1217 git(root, &["commit", "-qm", "head1"]);
1218
1219 let resolved = resolved_engine_graph();
1220
1221 {
1223 let mut engine = engine_at(root);
1224 engine
1225 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1226 .unwrap();
1227 }
1228
1229 {
1231 let mut engine = engine_at(root);
1232 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1233 .unwrap();
1234 assert!(!out.completed, "one artifact still pending");
1235 assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
1236 assert_eq!(out.pending, 1);
1237 assert_eq!(out.disposed, 1);
1238 }
1239 let on_disk = read_advance_store(root, "engine", "graph")
1241 .unwrap()
1242 .unwrap();
1243 assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
1244
1245 let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
1248 {
1249 let mut engine = engine_at(root);
1250 let err = advance_baseline(
1251 &mut engine,
1252 root,
1253 &resolved,
1254 &input(&[("never-presented.rs", "worked")]),
1255 )
1256 .unwrap_err();
1257 assert!(
1258 matches!(err, AdvanceError::UnknownArtifact { .. }),
1259 "expected UnknownArtifact, got {err:?}"
1260 );
1261 }
1262 let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
1263 assert_eq!(before, after, "refused call must not touch the store");
1264
1265 std::fs::write(root.join("c.rs"), "cee").unwrap();
1267 git(root, &["add", "-A"]);
1268 git(root, &["commit", "-qm", "head2"]);
1269
1270 {
1274 let mut engine = engine_at(root);
1275 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1276 assert!(!out.completed);
1277 assert_eq!(
1278 out.remainder,
1279 slice(&["c.rs"], &[], &["b.rs"]),
1280 "re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
1281 );
1282 assert_eq!(out.disposed, 1, "no new disposition this call");
1283 }
1284
1285 let head2 = head_sha(root);
1287 {
1288 let mut engine = engine_at(root);
1289 let out = advance_baseline(
1290 &mut engine,
1291 root,
1292 &resolved,
1293 &input(&[("b.rs", "worked"), ("c.rs", "worked")]),
1294 )
1295 .unwrap();
1296 assert!(out.completed, "every artifact disposed → complete");
1297 assert_eq!(out.pending, 0);
1298 assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
1299
1300 let token = engine
1302 .mem_config_for("engine")
1303 .and_then(|c| c.sync_state.get(synced_key()).cloned());
1304 assert_eq!(token.as_deref(), Some(head2.as_str()));
1305 }
1306 assert!(
1308 read_advance_store(root, "engine", "graph")
1309 .unwrap()
1310 .is_none()
1311 );
1312 }
1313
1314 #[test]
1320 fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
1321 let tmp = TempDir::new().unwrap();
1322 let root = tmp.path();
1323
1324 git(root, &["init", "-q"]);
1326 std::fs::write(root.join("a.rs"), "one").unwrap();
1327 git(root, &["add", "a.rs"]);
1328 git(root, &["commit", "-qm", "base"]);
1329 let baseline = head_sha(root);
1330 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1331 git(root, &["add", "-A"]);
1332 git(root, &["commit", "-qm", "head1"]);
1333
1334 let resolved = resolved_engine_graph();
1335 {
1336 let mut engine = engine_at(root);
1337 engine
1338 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1339 .unwrap();
1340 }
1341
1342 let excluded = {
1346 let mut m = BTreeMap::new();
1347 m.insert(
1348 "a.rs".to_string(),
1349 DispositionInput::Reasoned {
1350 disposition: EXCLUDED_VERDICT.to_string(),
1351 rationale: "mined; warrants no destination entity".to_string(),
1352 },
1353 );
1354 m
1355 };
1356 {
1357 let mut engine = engine_at(root);
1358 let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
1359 assert!(out.completed, "the sole slice artifact was disposed");
1360 }
1361 let retained = read_advance_store(root, "engine", "graph")
1362 .unwrap()
1363 .expect("an authored exclusion keeps the store alive past completion");
1364 assert!(
1365 retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
1366 "transient progress is dropped on completion"
1367 );
1368 assert_eq!(
1369 retained.exclusions.get("a.rs").map(String::as_str),
1370 Some("mined; warrants no destination entity"),
1371 "the durable exclusion + its rationale persist"
1372 );
1373
1374 std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
1378 git(root, &["add", "-A"]);
1379 git(root, &["commit", "-qm", "head2"]);
1380 {
1381 let mut engine = engine_at(root);
1382 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1383 .unwrap();
1384 assert!(out.completed);
1385 }
1386 assert!(
1387 read_advance_store(root, "engine", "graph")
1388 .unwrap()
1389 .is_none(),
1390 "re-judging the artifact cleared the exclusion; nothing durable remains"
1391 );
1392 }
1393
1394 #[test]
1402 fn advance_unknown_artifact_names_dialect_and_suggests_corrected_id() {
1403 let tmp = TempDir::new().unwrap();
1404 let root = tmp.path();
1405
1406 git(root, &["init", "-q"]);
1409 std::fs::create_dir_all(root.join("sub")).unwrap();
1410 std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1411 git(root, &["add", "-A"]);
1412 git(root, &["commit", "-qm", "base"]);
1413 let baseline = head_sha(root);
1414 std::fs::write(root.join("sub").join("a.rs"), "one-longer").unwrap();
1415 git(root, &["add", "-A"]);
1416 git(root, &["commit", "-qm", "head1"]);
1417
1418 let mut resolved = resolved_engine_graph();
1419 if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1420 p.pointer = "sub".to_string();
1421 }
1422 {
1423 let mut engine = engine_at(root);
1424 engine
1425 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1426 .unwrap();
1427 }
1428
1429 {
1433 let mut engine = engine_at(root);
1434 let err = advance_baseline(
1435 &mut engine,
1436 root,
1437 &resolved,
1438 &input(&[("a.rs", "worked"), ("zzz.rs", "worked")]),
1439 )
1440 .unwrap_err();
1441 let AdvanceError::UnknownArtifact {
1442 artifacts,
1443 suggestions,
1444 ..
1445 } = &err
1446 else {
1447 panic!("expected UnknownArtifact, got {err:?}");
1448 };
1449 assert_eq!(artifacts, &vec!["a.rs".to_string(), "zzz.rs".to_string()]);
1450 assert_eq!(
1451 suggestions,
1452 &vec![("a.rs".to_string(), "sub/a.rs".to_string())],
1453 "only the medium-relative id gets a corrected form; zzz.rs has none"
1454 );
1455 let msg = err.to_string();
1456 assert!(
1457 msg.contains("workspace-relative"),
1458 "names the dialect: {msg}"
1459 );
1460 assert!(
1461 msg.contains("`a.rs` → `sub/a.rs`"),
1462 "carries the concrete corrected id: {msg}"
1463 );
1464 assert!(
1465 msg.contains("never accepted"),
1466 "states the dialect does not widen: {msg}"
1467 );
1468 }
1469 assert!(
1471 read_advance_store(root, "engine", "graph")
1472 .unwrap()
1473 .is_none(),
1474 "a refused call must not create the advance store"
1475 );
1476
1477 {
1479 let mut engine = engine_at(root);
1480 let out = advance_baseline(
1481 &mut engine,
1482 root,
1483 &resolved,
1484 &input(&[("sub/a.rs", "worked")]),
1485 )
1486 .unwrap();
1487 assert!(out.completed, "the sole slice artifact was disposed");
1488 }
1489 }
1490
1491 #[test]
1496 fn record_exclusions_gates_on_source_membership_and_merges() {
1497 let tmp = TempDir::new().unwrap();
1498 let root = tmp.path();
1499
1500 git(root, &["init", "-q"]);
1503 std::fs::write(root.join("a.rs"), "one").unwrap();
1504 std::fs::write(root.join("b.rs"), "two").unwrap();
1505 git(root, &["add", "-A"]);
1506 git(root, &["commit", "-qm", "base"]);
1507
1508 let resolved = resolved_engine_graph();
1509
1510 let out = record_exclusions(
1512 &Engine::from_mounts(Vec::new()).unwrap(),
1513 root,
1514 &resolved,
1515 &BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
1516 )
1517 .unwrap();
1518 assert_eq!((out.added, out.excluded), (1, 1));
1519 let state = read_advance_store(root, "engine", "graph")
1520 .unwrap()
1521 .unwrap();
1522 assert_eq!(
1523 state.exclusions.get("a.rs").map(String::as_str),
1524 Some("mined; no entity")
1525 );
1526
1527 let err = record_exclusions(
1529 &Engine::from_mounts(Vec::new()).unwrap(),
1530 root,
1531 &resolved,
1532 &BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
1533 )
1534 .unwrap_err();
1535 assert!(
1536 matches!(err, ExcludeError::NotSourceMember { .. }),
1537 "got {err:?}"
1538 );
1539 assert_eq!(
1540 read_advance_store(root, "engine", "graph")
1541 .unwrap()
1542 .unwrap()
1543 .exclusions
1544 .len(),
1545 1,
1546 "refused call left the ledger unchanged"
1547 );
1548
1549 let out2 = record_exclusions(
1551 &Engine::from_mounts(Vec::new()).unwrap(),
1552 root,
1553 &resolved,
1554 &BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
1555 )
1556 .unwrap();
1557 assert_eq!((out2.added, out2.excluded), (1, 2));
1558 }
1559
1560 #[test]
1565 fn record_exclusions_refuses_partial_enumeration() {
1566 let tmp = TempDir::new().unwrap();
1567 let root = tmp.path();
1568
1569 git(root, &["init", "-q"]);
1570 std::fs::create_dir_all(root.join("sub")).unwrap();
1571 std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1572 git(root, &["add", "-A"]);
1573 git(root, &["commit", "-qm", "base"]);
1574
1575 let mut resolved = resolved_engine_graph();
1578 if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1579 p.pointer = "sub".to_string();
1580 p.scope.push(PatternEntry {
1581 path: "sub/nested.rs".to_string(),
1582 mode: PatternMode::Allow,
1583 });
1584 }
1585
1586 let err = record_exclusions(
1589 &Engine::from_mounts(Vec::new()).unwrap(),
1590 root,
1591 &resolved,
1592 &BTreeMap::from([("sub/a.rs".to_string(), "mined; no entity".to_string())]),
1593 )
1594 .unwrap_err();
1595 assert!(
1596 matches!(err, ExcludeError::PartialEnumeration { .. }),
1597 "got {err:?}"
1598 );
1599 assert!(
1600 err.to_string().contains("incomplete"),
1601 "the refusal names the partiality: {err}"
1602 );
1603 assert!(
1604 read_advance_store(root, "engine", "graph")
1605 .unwrap()
1606 .is_none(),
1607 "a refused call must not create the advance store"
1608 );
1609 }
1610
1611 #[test]
1614 fn disposition_input_parses_bare_and_reasoned_forms() {
1615 let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
1616 r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
1617 )
1618 .unwrap();
1619 assert_eq!(map["a.rs"].verdict(), "worked");
1620 assert_eq!(map["a.rs"].rationale(), None);
1621 assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
1622 assert_eq!(map["b.rs"].rationale(), Some("generated"));
1623 }
1624
1625 #[test]
1632 fn advance_auto_worked_matches_source_dialect_anchors() {
1633 use crate::binding::{BINDING_VERSION, Binding, Operations};
1634 use crate::vcs::Actor;
1635 use indexmap::IndexMap;
1636
1637 let tmp = TempDir::new().unwrap();
1638 let root = tmp.path();
1639
1640 git(root, &["init", "-q"]);
1641 std::fs::create_dir_all(root.join("srcdir")).unwrap();
1642 std::fs::write(root.join(".keep"), "x").unwrap();
1643 git(root, &["add", ".keep"]);
1644 git(root, &["commit", "-qm", "base"]);
1645 let baseline = head_sha(root);
1646
1647 let binding = Binding {
1650 version: BINDING_VERSION,
1651 intent: None,
1652 sources: vec![crate::pipeline::Source {
1653 name: "source-tree".to_string(),
1654 medium_type: crate::pipeline::MediumType::Codebase,
1655 pointer: "srcdir".to_string(),
1656 change_detection: Some("git".to_string()),
1657 scope: vec![PatternEntry {
1658 path: "**/*.rs".to_string(),
1659 mode: PatternMode::Allow,
1660 }],
1661 engagement: None,
1662 preparation: None,
1663 }],
1664 reference_mems: Vec::new(),
1665 destination_mem: "engine".to_string(),
1666 deny_paths: Vec::new(),
1667 coverage_semantics: None,
1668 rules: None,
1669 prune: None,
1670 operations: Operations {
1671 build: None,
1672 sync: None,
1673 verify: None,
1674 },
1675 };
1676 let dir = root.join(".memstead").join("projections").join("engine");
1677 std::fs::create_dir_all(&dir).unwrap();
1678 std::fs::write(
1679 dir.join("graph.json"),
1680 serde_json::to_string_pretty(&binding).unwrap(),
1681 )
1682 .unwrap();
1683
1684 let mut resolved = resolved_engine_graph();
1687 if let [ResolvedSource::Primary(p)] = resolved.sources.as_mut_slice() {
1688 p.pointer = "srcdir".to_string();
1689 } else {
1690 panic!("fixture shape");
1691 }
1692
1693 {
1694 let mut engine = engine_at(root);
1695 engine
1696 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1697 .unwrap();
1698 }
1699
1700 std::fs::write(root.join("srcdir").join("f.rs"), "fn f() {}").unwrap();
1701 git(root, &["add", "-A"]);
1702 git(root, &["commit", "-qm", "head1"]);
1703
1704 let mut sections = IndexMap::new();
1707 sections.insert("identity".to_string(), "Covers f.".to_string());
1708 sections.insert("purpose".to_string(), "Track f.rs.".to_string());
1709 {
1710 let mut engine = engine_at(root);
1711 engine.set_workspace_root(root.to_path_buf());
1712 engine
1713 .create_entity(
1714 crate::CreateEntityArgs {
1715 mem: "engine".to_string(),
1716 title: "Covers F".to_string(),
1717 entity_type: "spec".to_string(),
1718 sections,
1719 metadata: IndexMap::new(),
1720 relations: Vec::new(),
1721 anchors: vec![crate::anchor::AnchorInput {
1722 artifact: Some("f.rs".to_string()),
1723 grain: Some("file".to_string()),
1724 class: Some("anchored".to_string()),
1725 hash: Some("h".to_string()),
1726 hash_stability: Some("stable".to_string()),
1727 source: Some("source-tree".to_string()),
1728 ..Default::default()
1729 }],
1730 dry_run: false,
1731 },
1732 Actor::Agent,
1733 None,
1734 Some("source-dialect anchored write"),
1735 )
1736 .unwrap();
1737 }
1738
1739 let mut engine = engine_at(root);
1742 engine.set_workspace_root(root.to_path_buf());
1743 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1744 assert!(
1745 out.completed,
1746 "the source-dialect anchor auto-worked the joined slice artifact: {out:?}"
1747 );
1748 assert_eq!(out.disposed, 1);
1749 }
1750
1751 #[test]
1757 fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
1758 use crate::vcs::Actor;
1759 use indexmap::IndexMap;
1760
1761 let tmp = TempDir::new().unwrap();
1762 let root = tmp.path();
1763
1764 git(root, &["init", "-q"]);
1767 std::fs::write(root.join(".keep"), "x").unwrap();
1768 git(root, &["add", ".keep"]);
1769 git(root, &["commit", "-qm", "base"]);
1770 let baseline = head_sha(root);
1771
1772 let resolved = resolved_engine_graph();
1773 {
1774 let mut engine = engine_at(root);
1775 engine
1776 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1777 .unwrap();
1778 }
1779
1780 std::fs::write(root.join("a.rs"), "one").unwrap();
1782 std::fs::write(root.join("b.rs"), "bee").unwrap();
1783 git(root, &["add", "a.rs", "b.rs"]);
1784 git(root, &["commit", "-qm", "head1"]);
1785
1786 let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
1790 artifact: Some(artifact.to_string()),
1791 grain: Some("file".to_string()),
1792 class: Some("anchored".to_string()),
1793 hash: Some("h".to_string()),
1794 hash_stability: Some("stable".to_string()),
1795 ..Default::default()
1796 };
1797 let mut sections = IndexMap::new();
1798 sections.insert("identity".to_string(), "Covers a.".to_string());
1799 sections.insert("purpose".to_string(), "Track a.rs.".to_string());
1800 {
1801 let mut engine = engine_at(root);
1802 engine
1803 .create_entity(
1804 crate::CreateEntityArgs {
1805 mem: "engine".to_string(),
1806 title: "Covers A".to_string(),
1807 entity_type: "spec".to_string(),
1808 sections,
1809 metadata: IndexMap::new(),
1810 relations: Vec::new(),
1811 anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
1812 dry_run: false,
1813 },
1814 Actor::Agent,
1815 None,
1816 Some("anchored write"),
1817 )
1818 .unwrap();
1819 }
1820
1821 {
1825 let mut engine = engine_at(root);
1826 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1827 assert!(!out.completed, "b.rs still pending");
1828 assert_eq!(
1829 out.remainder,
1830 slice(&["b.rs"], &[], &[]),
1831 "a.rs auto-worked from its anchor; zzz.rs never became a slice member"
1832 );
1833 assert_eq!(out.disposed, 1, "only a.rs auto-worked");
1834 assert_eq!(out.pending, 1);
1835 }
1836
1837 std::fs::write(root.join("c.rs"), "cee").unwrap();
1841 git(root, &["add", "-A"]);
1842 git(root, &["commit", "-qm", "head2"]);
1843 {
1844 let mut engine = engine_at(root);
1845 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1846 assert_eq!(
1847 out.remainder,
1848 slice(&["b.rs", "c.rs"], &[], &[]),
1849 "auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
1850 );
1851 assert_eq!(
1852 out.disposed, 1,
1853 "still only a.rs auto-worked; c.rs unanchored"
1854 );
1855 assert!(!out.completed);
1856 }
1857 }
1858
1859 fn a3_workspace(root: &std::path::Path, files: &[&str]) {
1863 let mem_dir = root.join("mem");
1864 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1865 std::fs::write(
1866 mem_dir.join(".memstead").join("config.json"),
1867 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1868 )
1869 .unwrap();
1870 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1871 std::fs::write(
1872 root.join(".memstead").join("workspace.toml"),
1873 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1874 )
1875 .unwrap();
1876 let mount = crate::workspace::Mount {
1877 mem: "engine".to_string(),
1878 schema: Some("default@1.0.0".parse().unwrap()),
1879 storage: crate::workspace::MountStorage::Folder {
1880 path: mem_dir.clone(),
1881 },
1882 capability: crate::workspace::MountCapability::Write,
1883 lifecycle: crate::workspace::MountLifecycle::Eager,
1884 cross_linkable: false,
1885 migration_target: None,
1886 };
1887 crate::workspace_store::WorkspaceStoreAdapter::save_state(
1888 &crate::FileWorkspaceStore::new(),
1889 root,
1890 &crate::workspace::Workspace {
1891 mounts: vec![mount],
1892 settings: crate::workspace::WorkspaceSettings::default(),
1893 },
1894 )
1895 .unwrap();
1896 let out = std::process::Command::new("git")
1897 .args(["init", "-q"])
1898 .current_dir(root)
1899 .output()
1900 .unwrap();
1901 assert!(out.status.success());
1902 for f in files {
1903 let p = root.join(f);
1904 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1905 std::fs::write(p, "fn x() {}\n").unwrap();
1906 }
1907 }
1908
1909 fn a3_binding(
1910 sources: &[(&str, &str)],
1911 deny: &[&str],
1912 batch_size: u32,
1913 ) -> crate::binding::Binding {
1914 crate::binding::Binding {
1915 version: crate::binding::BINDING_VERSION,
1916 intent: None,
1917 sources: sources
1918 .iter()
1919 .map(|(name, glob)| crate::pipeline::Source {
1920 name: name.to_string(),
1921 medium_type: crate::pipeline::MediumType::Codebase,
1922 pointer: String::new(),
1923 change_detection: Some("git".to_string()),
1924 scope: vec![crate::pipeline::PatternEntry {
1925 path: glob.to_string(),
1926 mode: crate::pipeline::PatternMode::Allow,
1927 }],
1928 engagement: None,
1929 preparation: None,
1930 })
1931 .collect(),
1932 reference_mems: Vec::new(),
1933 destination_mem: "engine".to_string(),
1934 deny_paths: deny.iter().map(|d| d.to_string()).collect(),
1935 coverage_semantics: None,
1936 rules: None,
1937 prune: None,
1938 operations: crate::binding::Operations {
1939 build: Some(crate::binding::BuildOperation {
1940 mode: crate::binding::BuildMode::Discovery,
1941 trigger: crate::pipeline::IngestTrigger::Loop,
1942 batch_size,
1943 post_actions: None,
1944 }),
1945 sync: None,
1946 verify: Some(crate::binding::VerifyOperation {
1947 trigger: crate::pipeline::IngestTrigger::Manual,
1948 batch_size,
1949 adjudication_cap: crate::binding::DEFAULT_ADJUDICATION_CAP,
1950 full_resync_every: 0,
1952 }),
1953 },
1954 }
1955 }
1956
1957 #[test]
1961 fn exclusions_survive_edits_and_drop_with_their_source() {
1962 let tmp = tempfile::tempdir().unwrap();
1963 let root = tmp.path();
1964 a3_workspace(root, &["src/a.rs", "docs/x.md"]);
1965 let engine = Engine::from_workspace_root(root).unwrap();
1966 let two = |batch: u32| {
1967 a3_binding(
1968 &[("graph", "src/**/*.rs"), ("docs", "docs/**/*.md")],
1969 &[],
1970 batch,
1971 )
1972 };
1973
1974 let b = two(20);
1975 crate::pipeline_store::write_binding(root, "engine", "graph", &b).unwrap();
1976 let resolved = crate::ingest::resolve::resolve_binding_run("engine/graph", &b).unwrap();
1977 let mut ex = BTreeMap::new();
1978 ex.insert("docs/x.md".to_string(), "index page, mined".to_string());
1979 record_exclusions(&engine, root, &resolved, &ex).unwrap();
1980 let ledger = reconcile_exclusions(&engine, root, &resolved).unwrap();
1981 assert_eq!(ledger.active.len(), 1);
1982 assert_eq!(ledger.active[0].source, "docs");
1983 assert_eq!(ledger.active[0].rationale, "index page, mined");
1984 assert!(ledger.dropped.is_empty());
1985
1986 let edited = two(5);
1988 crate::pipeline_store::write_binding(root, "engine", "graph", &edited).unwrap();
1989 let resolved =
1990 crate::ingest::resolve::resolve_binding_run("engine/graph", &edited).unwrap();
1991 let ledger = reconcile_exclusions(&engine, root, &resolved).unwrap();
1992 assert_eq!(ledger.active.len(), 1, "{ledger:?}");
1993 assert_eq!(ledger.active[0].artifact, "docs/x.md");
1994 assert_eq!(ledger.active[0].rationale, "index page, mined");
1995 assert!(ledger.dropped.is_empty());
1996
1997 let without = a3_binding(&[("graph", "src/**/*.rs")], &[], 5);
1999 crate::pipeline_store::write_binding(root, "engine", "graph", &without).unwrap();
2000 let resolved =
2001 crate::ingest::resolve::resolve_binding_run("engine/graph", &without).unwrap();
2002 let ledger = reconcile_exclusions(&engine, root, &resolved).unwrap();
2003 assert!(ledger.active.is_empty(), "{ledger:?}");
2004 assert_eq!(ledger.dropped.len(), 1);
2005 assert_eq!(ledger.dropped[0].artifact, "docs/x.md");
2006 assert_eq!(ledger.dropped[0].source, "docs");
2007 assert_eq!(ledger.dropped[0].rationale, "index page, mined");
2008 let again = reconcile_exclusions(&engine, root, &resolved).unwrap();
2009 assert!(
2010 again.active.is_empty() && again.dropped.is_empty(),
2011 "reported once: {again:?}"
2012 );
2013 assert!(
2014 read_advance_store(root, "engine", "graph")
2015 .unwrap()
2016 .is_none()
2017 );
2018 }
2019}