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};
58use super::resolve::{ResolvedIngest, ResolvedSource};
59use super::slice::Slice;
60
61const STATE_DIR: &str = "state";
64const ADVANCE_DIR: &str = "advance";
66
67#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76pub struct AdvanceState {
77 pub binding: String,
79 pub frozen_slice: Slice,
81 pub dispositions: BTreeMap<String, String>,
83 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
91 pub exclusions: BTreeMap<String, String>,
92 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
99 pub exclusion_sources: BTreeMap<String, String>,
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub dropped_exclusions: Vec<DroppedExclusion>,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110pub struct DroppedExclusion {
111 pub artifact: String,
112 pub source: String,
115 pub rationale: String,
116 pub dropped_at: String,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
121pub struct ActiveExclusion {
122 pub artifact: String,
123 pub source: String,
124 pub rationale: String,
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
130pub struct ExclusionLedger {
131 pub active: Vec<ActiveExclusion>,
132 pub dropped: Vec<DroppedExclusion>,
133}
134
135pub fn reconcile_exclusions(
145 engine: &Engine,
146 workspace_root: &Path,
147 resolved: &ResolvedIngest,
148) -> Result<ExclusionLedger, StoreError> {
149 let Ok((mem, name)) = split_binding_id(&resolved.name) else {
150 return Ok(ExclusionLedger::default());
151 };
152 let Some(mut state) = read_advance_store(workspace_root, &mem, &name)? else {
153 return Ok(ExclusionLedger::default());
154 };
155 let declared: BTreeSet<String> = resolved
156 .sources
157 .iter()
158 .filter_map(|s| match s {
159 ResolvedSource::Primary(p) => Some(p.name.clone()),
160 ResolvedSource::Reference { .. } => None,
161 })
162 .collect();
163 let mut changed = false;
164 let mut membership: Option<BTreeMap<String, String>> = None;
165 let mut dropped_now: Vec<DroppedExclusion> = Vec::new();
166 let mut active: Vec<ActiveExclusion> = Vec::new();
167 for (artifact, rationale) in state.exclusions.clone() {
168 let recorded = state.exclusion_sources.get(&artifact).cloned();
169 let source = match recorded {
170 Some(s) if declared.contains(&s) => Some(s),
171 Some(s) => {
172 dropped_now.push(DroppedExclusion {
173 artifact: artifact.clone(),
174 source: s,
175 rationale: rationale.clone(),
176 dropped_at: crate::engine::mutation::iso_now(),
177 });
178 None
179 }
180 None => {
181 let facets = membership.get_or_insert_with(|| {
182 let mut m = BTreeMap::new();
183 for s in &resolved.sources {
184 if let ResolvedSource::Primary(p) = s {
185 for f in enumerate_source_artifacts(
186 engine,
187 p,
188 &resolved.deny_paths,
189 workspace_root,
190 ) {
191 m.entry(f).or_insert_with(|| p.name.clone());
192 }
193 }
194 }
195 m
196 });
197 match facets.get(&artifact).cloned() {
198 Some(f) => {
199 state.exclusion_sources.insert(artifact.clone(), f.clone());
200 changed = true;
201 Some(f)
202 }
203 None => {
204 dropped_now.push(DroppedExclusion {
205 artifact: artifact.clone(),
206 source: "unattributed".to_string(),
207 rationale: rationale.clone(),
208 dropped_at: crate::engine::mutation::iso_now(),
209 });
210 None
211 }
212 }
213 }
214 };
215 match source {
216 Some(source) => active.push(ActiveExclusion {
217 artifact,
218 source,
219 rationale,
220 }),
221 None => {
222 state.exclusions.remove(&artifact);
223 state.exclusion_sources.remove(&artifact);
224 changed = true;
225 }
226 }
227 }
228 let mut dropped = std::mem::take(&mut state.dropped_exclusions);
231 if !dropped.is_empty() {
232 changed = true;
233 }
234 dropped.extend(dropped_now);
235 if changed {
236 if state.exclusions.is_empty()
237 && state.frozen_slice == Slice::default()
238 && state.dispositions.is_empty()
239 {
240 delete_advance_store(workspace_root, &mem, &name)?;
241 } else {
242 write_advance_store(workspace_root, &mem, &name, &state)?;
243 }
244 }
245 Ok(ExclusionLedger { active, dropped })
246}
247
248pub const EXCLUDED_VERDICT: &str = "excluded";
254
255#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
267#[serde(untagged)]
268pub enum DispositionInput {
269 Verdict(String),
271 Reasoned {
273 disposition: String,
275 rationale: String,
277 },
278}
279
280impl DispositionInput {
281 pub fn verdict(&self) -> &str {
283 match self {
284 DispositionInput::Verdict(v) => v,
285 DispositionInput::Reasoned { disposition, .. } => disposition,
286 }
287 }
288
289 pub fn rationale(&self) -> Option<&str> {
291 match self {
292 DispositionInput::Verdict(_) => None,
293 DispositionInput::Reasoned { rationale, .. } => Some(rationale),
294 }
295 }
296}
297
298impl AdvanceState {
299 pub fn disposed(&self) -> usize {
302 self.dispositions.len()
303 }
304
305 pub fn pending(&self) -> usize {
309 artifact_set(&self.frozen_slice)
310 .iter()
311 .filter(|a| !self.dispositions.contains_key(a.as_str()))
312 .count()
313 }
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct AdvanceOutcome {
319 pub binding: String,
321 pub remainder: Slice,
324 pub disposed: usize,
326 pub pending: usize,
328 pub completed: bool,
331 pub tokens_written: Vec<String>,
334 pub warnings: Vec<String>,
337}
338
339#[derive(Debug, thiserror::Error)]
341pub enum AdvanceError {
342 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
344 MalformedId(String),
345 #[error(
354 "disposition names {} artifact id(s) the engine did not present: {}; the advance gate \
355 accepts only ids from the presented slice, verbatim in their workspace-relative form \
356 ({printed} presented){}",
357 artifacts.len(),
358 fmt_list(artifacts),
359 fmt_suggestions(suggestions)
360 )]
361 UnknownArtifact {
362 artifacts: Vec<String>,
364 printed: usize,
366 suggestions: Vec<(String, String)>,
370 },
371 #[error("advance store error: {0}")]
373 Store(#[source] StoreError),
374 #[error("could not advance baseline token: {0}")]
376 Engine(String),
377}
378
379fn fmt_list(names: &[String]) -> String {
381 if names.is_empty() {
382 "(none)".to_string()
383 } else {
384 names.join(", ")
385 }
386}
387
388fn fmt_suggestions(suggestions: &[(String, String)]) -> String {
392 if suggestions.is_empty() {
393 return String::new();
394 }
395 let pairs = suggestions
396 .iter()
397 .map(|(supplied, corrected)| format!("`{supplied}` → `{corrected}`"))
398 .collect::<Vec<_>>()
399 .join(", ");
400 format!(
401 ". Some supplied ids look medium-relative; the slice presents them workspace-relative — \
402 retry with {pairs} (the medium-relative form is never accepted)"
403 )
404}
405
406fn derive_corrected_ids(
411 unknown: &[String],
412 resolved: &ResolvedIngest,
413 printed: &BTreeSet<String>,
414) -> Vec<(String, String)> {
415 let medium_roots: Vec<&str> = resolved
416 .sources
417 .iter()
418 .filter_map(|s| match s {
419 ResolvedSource::Primary(p) if !p.pointer.is_empty() => Some(p.pointer.as_str()),
420 _ => None,
421 })
422 .collect();
423 unknown
424 .iter()
425 .filter_map(|id| {
426 medium_roots.iter().find_map(|root| {
427 let candidate = format!("{}/{id}", root.trim_end_matches('/'));
428 printed
429 .contains(candidate.as_str())
430 .then(|| (id.clone(), candidate))
431 })
432 })
433 .collect()
434}
435
436fn split_binding_id(binding_id: &str) -> Result<(String, String), AdvanceError> {
440 binding_id
441 .split_once('/')
442 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
443 .map(|(m, n)| (m.to_string(), n.to_string()))
444 .ok_or_else(|| AdvanceError::MalformedId(binding_id.to_string()))
445}
446
447pub(crate) fn is_single_component(value: &str) -> bool {
451 !value.is_empty()
452 && value != "."
453 && value != ".."
454 && !value.contains('/')
455 && !value.contains('\\')
456 && !value.contains(':')
457 && !value.contains('\0')
458}
459
460pub fn advance_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
462 workspace_root
463 .join(WORKSPACE_STORE_DIR)
464 .join(STATE_DIR)
465 .join(ADVANCE_DIR)
466 .join(mem)
467 .join(format!("{name}.json"))
468}
469
470pub fn read_advance_store(
474 workspace_root: &Path,
475 mem: &str,
476 name: &str,
477) -> Result<Option<AdvanceState>, StoreError> {
478 let path = advance_store_path(workspace_root, mem, name);
479 match std::fs::read(&path) {
480 Ok(bytes) => serde_json::from_slice(&bytes)
481 .map(Some)
482 .map_err(|e| StoreError::Parse {
483 path,
484 message: e.to_string(),
485 }),
486 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
487 Err(e) => Err(StoreError::Io { path, source: e }),
488 }
489}
490
491pub fn write_advance_store(
494 workspace_root: &Path,
495 mem: &str,
496 name: &str,
497 state: &AdvanceState,
498) -> Result<(), StoreError> {
499 super::findings::ensure_selfignoring_store_dir(
502 &workspace_root
503 .join(WORKSPACE_STORE_DIR)
504 .join(STATE_DIR)
505 .join(ADVANCE_DIR),
506 )?;
507 let path = advance_store_path(workspace_root, mem, name);
508 if let Some(parent) = path.parent() {
509 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
510 path: parent.to_path_buf(),
511 source: e,
512 })?;
513 }
514 let bytes = serde_json::to_vec_pretty(state).map_err(|e| StoreError::Parse {
515 path: path.clone(),
516 message: e.to_string(),
517 })?;
518 std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
519}
520
521pub fn delete_advance_store(
524 workspace_root: &Path,
525 mem: &str,
526 name: &str,
527) -> Result<(), StoreError> {
528 let path = advance_store_path(workspace_root, mem, name);
529 match std::fs::remove_file(&path) {
530 Ok(()) => Ok(()),
531 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
532 Err(e) => Err(StoreError::Io { path, source: e }),
533 }
534}
535
536fn union_slice(into: &mut Slice, from: &Slice) {
538 into.added.extend(from.added.iter().cloned());
539 into.modified.extend(from.modified.iter().cloned());
540 into.deleted.extend(from.deleted.iter().cloned());
541 for v in [&mut into.added, &mut into.modified, &mut into.deleted] {
542 v.sort();
543 v.dedup();
544 }
545}
546
547fn artifact_set(slice: &Slice) -> BTreeSet<String> {
550 slice
551 .added
552 .iter()
553 .chain(slice.modified.iter())
554 .chain(slice.deleted.iter())
555 .cloned()
556 .collect()
557}
558
559fn subtract_disposed(frozen: &Slice, dispositions: &BTreeMap<String, String>) -> Slice {
562 let keep = |v: &[String]| -> Vec<String> {
563 v.iter()
564 .filter(|a| !dispositions.contains_key(*a))
565 .cloned()
566 .collect()
567 };
568 Slice {
569 added: keep(&frozen.added),
570 modified: keep(&frozen.modified),
571 deleted: keep(&frozen.deleted),
572 }
573}
574
575pub fn advance_baseline(
594 engine: &mut Engine,
595 workspace_root: &Path,
596 resolved: &ResolvedIngest,
597 dispositions: &BTreeMap<String, DispositionInput>,
598) -> Result<AdvanceOutcome, AdvanceError> {
599 let binding_id = resolved.name.clone();
600 let (mem, name) = split_binding_id(&binding_id)?;
601
602 let cursor = compute_source_cursor(engine, resolved, workspace_root);
606
607 let mut state = read_advance_store(workspace_root, &mem, &name)
609 .map_err(AdvanceError::Store)?
610 .unwrap_or_else(|| AdvanceState {
611 binding: binding_id.clone(),
612 ..Default::default()
613 });
614
615 union_slice(&mut state.frozen_slice, &cursor.union);
617 let printed = artifact_set(&state.frozen_slice);
618
619 let mut unknown: Vec<String> = dispositions
622 .keys()
623 .filter(|a| !printed.contains(a.as_str()))
624 .cloned()
625 .collect();
626 if !unknown.is_empty() {
627 unknown.sort();
628 unknown.dedup();
629 let suggestions = derive_corrected_ids(&unknown, resolved, &printed);
634 return Err(AdvanceError::UnknownArtifact {
635 artifacts: unknown,
636 printed: printed.len(),
637 suggestions,
638 });
639 }
640
641 for (artifact, input) in dispositions {
646 state
647 .dispositions
648 .insert(artifact.clone(), input.verdict().to_string());
649 if input.verdict() == EXCLUDED_VERDICT {
650 state.exclusions.insert(
651 artifact.clone(),
652 input.rationale().unwrap_or("").to_string(),
653 );
654 } else {
655 state.exclusions.remove(artifact);
656 state.exclusion_sources.remove(artifact);
657 }
658 }
659
660 let auto_worked: Vec<String> = printed
667 .iter()
668 .filter(|art| !state.dispositions.contains_key(art.as_str()))
669 .filter(|art| {
670 let (base, key) = crate::preparation::split_unit_id(art);
675 engine
676 .anchors_referencing_artifact(base)
677 .iter()
678 .any(|(eid, a)| {
679 eid.mem() == resolved.destination_mem.as_str()
680 && (key.is_none() || a.artifact == **art)
681 })
682 })
683 .cloned()
684 .collect();
685 for art in auto_worked {
686 state.dispositions.insert(art, "worked".to_string());
687 }
688
689 let remainder = subtract_disposed(&state.frozen_slice, &state.dispositions);
691 let pending = remainder.added.len() + remainder.modified.len() + remainder.deleted.len();
692 let completed = pending == 0;
693
694 let mut warnings: Vec<String> = Vec::new();
695 let mut tokens_written: Vec<String> = Vec::new();
696 if completed {
697 let note = format!(
701 "projection advance {binding_id}: {} artifact(s) disposed, baseline advanced",
702 state.dispositions.len()
703 );
704 for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
705 let outcome = engine
706 .set_mem_sync_state(&resolved.destination_mem, &c.key, &c.token, Some(¬e))
707 .map_err(|e| AdvanceError::Engine(e.to_string()))?;
708 warnings.extend(outcome.warnings.iter().map(ToString::to_string));
709 tokens_written.push(c.key.clone());
710 }
711 if state.exclusions.is_empty() {
717 delete_advance_store(workspace_root, &mem, &name).map_err(AdvanceError::Store)?;
718 } else {
719 let durable = AdvanceState {
720 binding: binding_id.clone(),
721 frozen_slice: Slice::default(),
722 dispositions: BTreeMap::new(),
723 exclusions: state.exclusions.clone(),
724 exclusion_sources: state.exclusion_sources.clone(),
725 dropped_exclusions: state.dropped_exclusions.clone(),
726 };
727 write_advance_store(workspace_root, &mem, &name, &durable)
728 .map_err(AdvanceError::Store)?;
729 }
730 } else {
731 write_advance_store(workspace_root, &mem, &name, &state).map_err(AdvanceError::Store)?;
733 }
734
735 Ok(AdvanceOutcome {
736 binding: binding_id,
737 remainder,
738 disposed: state.dispositions.len(),
739 pending,
740 completed,
741 tokens_written,
742 warnings,
743 })
744}
745
746#[derive(Debug, Clone, PartialEq, Eq)]
748pub struct ExcludeOutcome {
749 pub binding: String,
751 pub excluded: usize,
753 pub added: usize,
755}
756
757#[derive(Debug, thiserror::Error)]
759pub enum ExcludeError {
760 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
762 MalformedId(String),
763 #[error(
766 "exclusion names {} artifact id(s) not in the binding's enumerable source S(D): {}; \
767 only an in-scope source member can be declared excluded ({printed} enumerated)",
768 artifacts.len(),
769 fmt_list(artifacts)
770 )]
771 NotSourceMember {
772 artifacts: Vec<String>,
774 printed: usize,
776 },
777 #[error(
782 "the binding's source enumeration is incomplete — {reason} — so `S(D)` membership \
783 cannot be decided; fix the named scope pattern(s), then re-declare the exclusions"
784 )]
785 PartialEnumeration {
786 facet: String,
788 reason: String,
790 },
791 #[error("advance store error: {0}")]
793 Store(#[source] StoreError),
794}
795
796pub fn record_exclusions(
811 engine: &Engine,
812 workspace_root: &Path,
813 resolved: &ResolvedIngest,
814 exclusions: &BTreeMap<String, String>,
815) -> Result<ExcludeOutcome, ExcludeError> {
816 let binding_id = resolved.name.clone();
817 let (mem, name) =
818 split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
819
820 let mut s_d: BTreeSet<String> = BTreeSet::new();
827 let mut facet_of: BTreeMap<String, String> = BTreeMap::new();
830 for source in &resolved.sources {
831 if let ResolvedSource::Primary(p) = source {
832 let walked = enumerate_source_artifacts_reported(
833 engine,
834 p,
835 &resolved.deny_paths,
836 workspace_root,
837 );
838 if let Some(reason) = walked.partiality_reason() {
839 return Err(ExcludeError::PartialEnumeration {
840 facet: p.name.clone(),
841 reason,
842 });
843 }
844 for f in &walked.files {
845 facet_of.entry(f.clone()).or_insert_with(|| p.name.clone());
846 }
847 s_d.extend(walked.files);
848 }
849 }
850
851 let mut not_member: Vec<String> = exclusions
854 .keys()
855 .filter(|a| !s_d.contains(a.as_str()))
856 .cloned()
857 .collect();
858 if !not_member.is_empty() {
859 not_member.sort();
860 not_member.dedup();
861 return Err(ExcludeError::NotSourceMember {
862 artifacts: not_member,
863 printed: s_d.len(),
864 });
865 }
866
867 let mut state = read_advance_store(workspace_root, &mem, &name)
870 .map_err(ExcludeError::Store)?
871 .unwrap_or_else(|| AdvanceState {
872 binding: binding_id.clone(),
873 ..Default::default()
874 });
875 let mut added = 0usize;
876 for (artifact, rationale) in exclusions {
877 if state
878 .exclusions
879 .insert(artifact.clone(), rationale.clone())
880 .is_none()
881 {
882 added += 1;
883 }
884 if let Some(facet) = facet_of.get(artifact) {
885 state
886 .exclusion_sources
887 .insert(artifact.clone(), facet.clone());
888 }
889 }
890 write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
891
892 Ok(ExcludeOutcome {
893 binding: binding_id,
894 excluded: state.exclusions.len(),
895 added,
896 })
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use crate::binding::BuildMode;
903 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
904 use crate::storage::FilesystemMemWriter;
905 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
906 use tempfile::TempDir;
907
908 fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
911 Slice {
912 added: added.iter().map(|s| s.to_string()).collect(),
913 modified: modified.iter().map(|s| s.to_string()).collect(),
914 deleted: deleted.iter().map(|s| s.to_string()).collect(),
915 }
916 }
917
918 fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
919 pairs
920 .iter()
921 .map(|(a, d)| (a.to_string(), d.to_string()))
922 .collect()
923 }
924
925 fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
928 pairs
929 .iter()
930 .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
931 .collect()
932 }
933
934 #[test]
936 fn advance_store_round_trips_and_delete_is_idempotent() {
937 let tmp = TempDir::new().unwrap();
938 let root = tmp.path();
939 assert!(
940 read_advance_store(root, "engine", "graph")
941 .unwrap()
942 .is_none()
943 );
944
945 let state = AdvanceState {
946 binding: "engine/graph".to_string(),
947 frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
948 dispositions: disp(&[("a.rs", "worked")]),
949 exclusions: BTreeMap::new(),
950 ..Default::default()
951 };
952 write_advance_store(root, "engine", "graph", &state).unwrap();
953 assert!(
954 advance_store_path(root, "engine", "graph")
955 .ends_with("state/advance/engine/graph.json")
956 );
957 let back = read_advance_store(root, "engine", "graph")
958 .unwrap()
959 .unwrap();
960 assert_eq!(back, state);
961
962 delete_advance_store(root, "engine", "graph").unwrap();
963 assert!(
964 read_advance_store(root, "engine", "graph")
965 .unwrap()
966 .is_none()
967 );
968 delete_advance_store(root, "engine", "graph").unwrap();
970 }
971
972 #[test]
974 fn subtract_disposed_removes_disposed_from_every_class() {
975 let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
976 let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
977 assert_eq!(out, slice(&[], &[], &["b.rs"]));
978 }
979
980 fn git(repo: &Path, args: &[&str]) {
983 let out = std::process::Command::new("git")
984 .args(args)
985 .current_dir(repo)
986 .env("GIT_AUTHOR_NAME", "t")
987 .env("GIT_AUTHOR_EMAIL", "t@t")
988 .env("GIT_COMMITTER_NAME", "t")
989 .env("GIT_COMMITTER_EMAIL", "t@t")
990 .output()
991 .unwrap();
992 assert!(
993 out.status.success(),
994 "git {args:?}: {}",
995 String::from_utf8_lossy(&out.stderr)
996 );
997 }
998
999 fn head_sha(repo: &Path) -> String {
1000 String::from_utf8(
1001 std::process::Command::new("git")
1002 .args(["rev-parse", "HEAD"])
1003 .current_dir(repo)
1004 .output()
1005 .unwrap()
1006 .stdout,
1007 )
1008 .unwrap()
1009 .trim()
1010 .to_string()
1011 }
1012
1013 fn resolved_engine_graph() -> ResolvedIngest {
1017 use super::super::resolve::{ResolvedSource, Source};
1018 ResolvedIngest {
1019 name: "engine/graph".to_string(),
1020 mode: BuildMode::Discovery,
1021 trigger: IngestTrigger::Loop,
1022 batch_size: 20,
1023 deny_paths: vec![],
1024 projection_ref: "engine/graph".to_string(),
1025 projection_mem: "engine".to_string(),
1026 projection_name: "graph".to_string(),
1027 intent: None,
1028 sources: vec![ResolvedSource::Primary(Source {
1029 name: "source-tree".to_string(),
1030 medium_type: MediumType::Codebase,
1031 pointer: String::new(),
1032 change_detection: Some("git".to_string()),
1033 scope: vec![PatternEntry {
1034 path: "**/*.rs".to_string(),
1035 mode: PatternMode::Allow,
1036 }],
1037 engagement: None,
1038 preparation: None,
1039 })],
1040 destination_mem: "engine".to_string(),
1041 rules: None,
1042 post_actions: None,
1043 }
1044 }
1045
1046 fn engine_at(root: &Path) -> Engine {
1050 let config_path = root.join(".memstead").join("config.json");
1054 if !config_path.exists() {
1055 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1056 std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
1057 }
1058 let mount = Mount {
1059 mem: "engine".to_string(),
1060 schema: Some("default@1.0.0".parse().unwrap()),
1061 storage: MountStorage::Folder {
1062 path: root.to_path_buf(),
1063 },
1064 capability: MountCapability::Write,
1065 lifecycle: MountLifecycle::Eager,
1066 cross_linkable: false,
1067 migration_target: None,
1068 };
1069 Engine::from_mounts(vec![(
1070 mount,
1071 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
1072 as Box<dyn crate::backend::MemBackend>,
1073 )])
1074 .unwrap()
1075 }
1076
1077 fn synced_key() -> &'static str {
1078 "engine/graph/source-tree#synced"
1079 }
1080
1081 #[test]
1094 fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
1095 let tmp = TempDir::new().unwrap();
1096 let root = tmp.path();
1097
1098 git(root, &["init", "-q"]);
1100 std::fs::write(root.join("a.rs"), "one").unwrap();
1101 std::fs::write(root.join("b.rs"), "bee").unwrap();
1102 git(root, &["add", "a.rs", "b.rs"]);
1103 git(root, &["commit", "-qm", "base"]);
1104 let baseline = head_sha(root);
1105
1106 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1108 std::fs::remove_file(root.join("b.rs")).unwrap();
1109 git(root, &["add", "-A"]);
1110 git(root, &["commit", "-qm", "head1"]);
1111
1112 let resolved = resolved_engine_graph();
1113
1114 {
1116 let mut engine = engine_at(root);
1117 engine
1118 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1119 .unwrap();
1120 }
1121
1122 {
1124 let mut engine = engine_at(root);
1125 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1126 .unwrap();
1127 assert!(!out.completed, "one artifact still pending");
1128 assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
1129 assert_eq!(out.pending, 1);
1130 assert_eq!(out.disposed, 1);
1131 }
1132 let on_disk = read_advance_store(root, "engine", "graph")
1134 .unwrap()
1135 .unwrap();
1136 assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
1137
1138 let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
1141 {
1142 let mut engine = engine_at(root);
1143 let err = advance_baseline(
1144 &mut engine,
1145 root,
1146 &resolved,
1147 &input(&[("never-presented.rs", "worked")]),
1148 )
1149 .unwrap_err();
1150 assert!(
1151 matches!(err, AdvanceError::UnknownArtifact { .. }),
1152 "expected UnknownArtifact, got {err:?}"
1153 );
1154 }
1155 let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
1156 assert_eq!(before, after, "refused call must not touch the store");
1157
1158 std::fs::write(root.join("c.rs"), "cee").unwrap();
1160 git(root, &["add", "-A"]);
1161 git(root, &["commit", "-qm", "head2"]);
1162
1163 {
1167 let mut engine = engine_at(root);
1168 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1169 assert!(!out.completed);
1170 assert_eq!(
1171 out.remainder,
1172 slice(&["c.rs"], &[], &["b.rs"]),
1173 "re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
1174 );
1175 assert_eq!(out.disposed, 1, "no new disposition this call");
1176 }
1177
1178 let head2 = head_sha(root);
1180 {
1181 let mut engine = engine_at(root);
1182 let out = advance_baseline(
1183 &mut engine,
1184 root,
1185 &resolved,
1186 &input(&[("b.rs", "worked"), ("c.rs", "worked")]),
1187 )
1188 .unwrap();
1189 assert!(out.completed, "every artifact disposed → complete");
1190 assert_eq!(out.pending, 0);
1191 assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
1192
1193 let token = engine
1195 .mem_config_for("engine")
1196 .and_then(|c| c.sync_state.get(synced_key()).cloned());
1197 assert_eq!(token.as_deref(), Some(head2.as_str()));
1198 }
1199 assert!(
1201 read_advance_store(root, "engine", "graph")
1202 .unwrap()
1203 .is_none()
1204 );
1205 }
1206
1207 #[test]
1213 fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
1214 let tmp = TempDir::new().unwrap();
1215 let root = tmp.path();
1216
1217 git(root, &["init", "-q"]);
1219 std::fs::write(root.join("a.rs"), "one").unwrap();
1220 git(root, &["add", "a.rs"]);
1221 git(root, &["commit", "-qm", "base"]);
1222 let baseline = head_sha(root);
1223 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1224 git(root, &["add", "-A"]);
1225 git(root, &["commit", "-qm", "head1"]);
1226
1227 let resolved = resolved_engine_graph();
1228 {
1229 let mut engine = engine_at(root);
1230 engine
1231 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1232 .unwrap();
1233 }
1234
1235 let excluded = {
1239 let mut m = BTreeMap::new();
1240 m.insert(
1241 "a.rs".to_string(),
1242 DispositionInput::Reasoned {
1243 disposition: EXCLUDED_VERDICT.to_string(),
1244 rationale: "mined; warrants no destination entity".to_string(),
1245 },
1246 );
1247 m
1248 };
1249 {
1250 let mut engine = engine_at(root);
1251 let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
1252 assert!(out.completed, "the sole slice artifact was disposed");
1253 }
1254 let retained = read_advance_store(root, "engine", "graph")
1255 .unwrap()
1256 .expect("an authored exclusion keeps the store alive past completion");
1257 assert!(
1258 retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
1259 "transient progress is dropped on completion"
1260 );
1261 assert_eq!(
1262 retained.exclusions.get("a.rs").map(String::as_str),
1263 Some("mined; warrants no destination entity"),
1264 "the durable exclusion + its rationale persist"
1265 );
1266
1267 std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
1271 git(root, &["add", "-A"]);
1272 git(root, &["commit", "-qm", "head2"]);
1273 {
1274 let mut engine = engine_at(root);
1275 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1276 .unwrap();
1277 assert!(out.completed);
1278 }
1279 assert!(
1280 read_advance_store(root, "engine", "graph")
1281 .unwrap()
1282 .is_none(),
1283 "re-judging the artifact cleared the exclusion; nothing durable remains"
1284 );
1285 }
1286
1287 #[test]
1295 fn advance_unknown_artifact_names_dialect_and_suggests_corrected_id() {
1296 let tmp = TempDir::new().unwrap();
1297 let root = tmp.path();
1298
1299 git(root, &["init", "-q"]);
1302 std::fs::create_dir_all(root.join("sub")).unwrap();
1303 std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1304 git(root, &["add", "-A"]);
1305 git(root, &["commit", "-qm", "base"]);
1306 let baseline = head_sha(root);
1307 std::fs::write(root.join("sub").join("a.rs"), "one-longer").unwrap();
1308 git(root, &["add", "-A"]);
1309 git(root, &["commit", "-qm", "head1"]);
1310
1311 let mut resolved = resolved_engine_graph();
1312 if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1313 p.pointer = "sub".to_string();
1314 }
1315 {
1316 let mut engine = engine_at(root);
1317 engine
1318 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1319 .unwrap();
1320 }
1321
1322 {
1326 let mut engine = engine_at(root);
1327 let err = advance_baseline(
1328 &mut engine,
1329 root,
1330 &resolved,
1331 &input(&[("a.rs", "worked"), ("zzz.rs", "worked")]),
1332 )
1333 .unwrap_err();
1334 let AdvanceError::UnknownArtifact {
1335 artifacts,
1336 suggestions,
1337 ..
1338 } = &err
1339 else {
1340 panic!("expected UnknownArtifact, got {err:?}");
1341 };
1342 assert_eq!(artifacts, &vec!["a.rs".to_string(), "zzz.rs".to_string()]);
1343 assert_eq!(
1344 suggestions,
1345 &vec![("a.rs".to_string(), "sub/a.rs".to_string())],
1346 "only the medium-relative id gets a corrected form; zzz.rs has none"
1347 );
1348 let msg = err.to_string();
1349 assert!(
1350 msg.contains("workspace-relative"),
1351 "names the dialect: {msg}"
1352 );
1353 assert!(
1354 msg.contains("`a.rs` → `sub/a.rs`"),
1355 "carries the concrete corrected id: {msg}"
1356 );
1357 assert!(
1358 msg.contains("never accepted"),
1359 "states the dialect does not widen: {msg}"
1360 );
1361 }
1362 assert!(
1364 read_advance_store(root, "engine", "graph")
1365 .unwrap()
1366 .is_none(),
1367 "a refused call must not create the advance store"
1368 );
1369
1370 {
1372 let mut engine = engine_at(root);
1373 let out = advance_baseline(
1374 &mut engine,
1375 root,
1376 &resolved,
1377 &input(&[("sub/a.rs", "worked")]),
1378 )
1379 .unwrap();
1380 assert!(out.completed, "the sole slice artifact was disposed");
1381 }
1382 }
1383
1384 #[test]
1389 fn record_exclusions_gates_on_source_membership_and_merges() {
1390 let tmp = TempDir::new().unwrap();
1391 let root = tmp.path();
1392
1393 git(root, &["init", "-q"]);
1396 std::fs::write(root.join("a.rs"), "one").unwrap();
1397 std::fs::write(root.join("b.rs"), "two").unwrap();
1398 git(root, &["add", "-A"]);
1399 git(root, &["commit", "-qm", "base"]);
1400
1401 let resolved = resolved_engine_graph();
1402
1403 let out = record_exclusions(
1405 &Engine::from_mounts(Vec::new()).unwrap(),
1406 root,
1407 &resolved,
1408 &BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
1409 )
1410 .unwrap();
1411 assert_eq!((out.added, out.excluded), (1, 1));
1412 let state = read_advance_store(root, "engine", "graph")
1413 .unwrap()
1414 .unwrap();
1415 assert_eq!(
1416 state.exclusions.get("a.rs").map(String::as_str),
1417 Some("mined; no entity")
1418 );
1419
1420 let err = record_exclusions(
1422 &Engine::from_mounts(Vec::new()).unwrap(),
1423 root,
1424 &resolved,
1425 &BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
1426 )
1427 .unwrap_err();
1428 assert!(
1429 matches!(err, ExcludeError::NotSourceMember { .. }),
1430 "got {err:?}"
1431 );
1432 assert_eq!(
1433 read_advance_store(root, "engine", "graph")
1434 .unwrap()
1435 .unwrap()
1436 .exclusions
1437 .len(),
1438 1,
1439 "refused call left the ledger unchanged"
1440 );
1441
1442 let out2 = record_exclusions(
1444 &Engine::from_mounts(Vec::new()).unwrap(),
1445 root,
1446 &resolved,
1447 &BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
1448 )
1449 .unwrap();
1450 assert_eq!((out2.added, out2.excluded), (1, 2));
1451 }
1452
1453 #[test]
1458 fn record_exclusions_refuses_partial_enumeration() {
1459 let tmp = TempDir::new().unwrap();
1460 let root = tmp.path();
1461
1462 git(root, &["init", "-q"]);
1463 std::fs::create_dir_all(root.join("sub")).unwrap();
1464 std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1465 git(root, &["add", "-A"]);
1466 git(root, &["commit", "-qm", "base"]);
1467
1468 let mut resolved = resolved_engine_graph();
1471 if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1472 p.pointer = "sub".to_string();
1473 p.scope.push(PatternEntry {
1474 path: "sub/nested.rs".to_string(),
1475 mode: PatternMode::Allow,
1476 });
1477 }
1478
1479 let err = record_exclusions(
1482 &Engine::from_mounts(Vec::new()).unwrap(),
1483 root,
1484 &resolved,
1485 &BTreeMap::from([("sub/a.rs".to_string(), "mined; no entity".to_string())]),
1486 )
1487 .unwrap_err();
1488 assert!(
1489 matches!(err, ExcludeError::PartialEnumeration { .. }),
1490 "got {err:?}"
1491 );
1492 assert!(
1493 err.to_string().contains("incomplete"),
1494 "the refusal names the partiality: {err}"
1495 );
1496 assert!(
1497 read_advance_store(root, "engine", "graph")
1498 .unwrap()
1499 .is_none(),
1500 "a refused call must not create the advance store"
1501 );
1502 }
1503
1504 #[test]
1507 fn disposition_input_parses_bare_and_reasoned_forms() {
1508 let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
1509 r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
1510 )
1511 .unwrap();
1512 assert_eq!(map["a.rs"].verdict(), "worked");
1513 assert_eq!(map["a.rs"].rationale(), None);
1514 assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
1515 assert_eq!(map["b.rs"].rationale(), Some("generated"));
1516 }
1517
1518 #[test]
1525 fn advance_auto_worked_matches_source_dialect_anchors() {
1526 use crate::binding::{BINDING_VERSION, Binding, Operations};
1527 use crate::vcs::Actor;
1528 use indexmap::IndexMap;
1529
1530 let tmp = TempDir::new().unwrap();
1531 let root = tmp.path();
1532
1533 git(root, &["init", "-q"]);
1534 std::fs::create_dir_all(root.join("srcdir")).unwrap();
1535 std::fs::write(root.join(".keep"), "x").unwrap();
1536 git(root, &["add", ".keep"]);
1537 git(root, &["commit", "-qm", "base"]);
1538 let baseline = head_sha(root);
1539
1540 let binding = Binding {
1543 version: BINDING_VERSION,
1544 intent: None,
1545 sources: vec![crate::pipeline::Source {
1546 name: "source-tree".to_string(),
1547 medium_type: crate::pipeline::MediumType::Codebase,
1548 pointer: "srcdir".to_string(),
1549 change_detection: Some("git".to_string()),
1550 scope: vec![PatternEntry {
1551 path: "**/*.rs".to_string(),
1552 mode: PatternMode::Allow,
1553 }],
1554 engagement: None,
1555 preparation: None,
1556 }],
1557 reference_mems: Vec::new(),
1558 destination_mem: "engine".to_string(),
1559 deny_paths: Vec::new(),
1560 coverage_semantics: None,
1561 rules: None,
1562 prune: None,
1563 operations: Operations {
1564 build: None,
1565 sync: None,
1566 verify: None,
1567 },
1568 };
1569 let dir = root.join(".memstead").join("projections").join("engine");
1570 std::fs::create_dir_all(&dir).unwrap();
1571 std::fs::write(
1572 dir.join("graph.json"),
1573 serde_json::to_string_pretty(&binding).unwrap(),
1574 )
1575 .unwrap();
1576
1577 let mut resolved = resolved_engine_graph();
1580 if let [ResolvedSource::Primary(p)] = resolved.sources.as_mut_slice() {
1581 p.pointer = "srcdir".to_string();
1582 } else {
1583 panic!("fixture shape");
1584 }
1585
1586 {
1587 let mut engine = engine_at(root);
1588 engine
1589 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1590 .unwrap();
1591 }
1592
1593 std::fs::write(root.join("srcdir").join("f.rs"), "fn f() {}").unwrap();
1594 git(root, &["add", "-A"]);
1595 git(root, &["commit", "-qm", "head1"]);
1596
1597 let mut sections = IndexMap::new();
1600 sections.insert("identity".to_string(), "Covers f.".to_string());
1601 sections.insert("purpose".to_string(), "Track f.rs.".to_string());
1602 {
1603 let mut engine = engine_at(root);
1604 engine.set_workspace_root(root.to_path_buf());
1605 engine
1606 .create_entity(
1607 crate::CreateEntityArgs {
1608 mem: "engine".to_string(),
1609 title: "Covers F".to_string(),
1610 entity_type: "spec".to_string(),
1611 sections,
1612 metadata: IndexMap::new(),
1613 relations: Vec::new(),
1614 anchors: vec![crate::anchor::AnchorInput {
1615 artifact: Some("f.rs".to_string()),
1616 grain: Some("file".to_string()),
1617 class: Some("anchored".to_string()),
1618 hash: Some("h".to_string()),
1619 hash_stability: Some("stable".to_string()),
1620 source: Some("source-tree".to_string()),
1621 ..Default::default()
1622 }],
1623 dry_run: false,
1624 },
1625 Actor::Agent,
1626 None,
1627 Some("source-dialect anchored write"),
1628 )
1629 .unwrap();
1630 }
1631
1632 let mut engine = engine_at(root);
1635 engine.set_workspace_root(root.to_path_buf());
1636 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1637 assert!(
1638 out.completed,
1639 "the source-dialect anchor auto-worked the joined slice artifact: {out:?}"
1640 );
1641 assert_eq!(out.disposed, 1);
1642 }
1643
1644 #[test]
1650 fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
1651 use crate::vcs::Actor;
1652 use indexmap::IndexMap;
1653
1654 let tmp = TempDir::new().unwrap();
1655 let root = tmp.path();
1656
1657 git(root, &["init", "-q"]);
1660 std::fs::write(root.join(".keep"), "x").unwrap();
1661 git(root, &["add", ".keep"]);
1662 git(root, &["commit", "-qm", "base"]);
1663 let baseline = head_sha(root);
1664
1665 let resolved = resolved_engine_graph();
1666 {
1667 let mut engine = engine_at(root);
1668 engine
1669 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1670 .unwrap();
1671 }
1672
1673 std::fs::write(root.join("a.rs"), "one").unwrap();
1675 std::fs::write(root.join("b.rs"), "bee").unwrap();
1676 git(root, &["add", "a.rs", "b.rs"]);
1677 git(root, &["commit", "-qm", "head1"]);
1678
1679 let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
1683 artifact: Some(artifact.to_string()),
1684 grain: Some("file".to_string()),
1685 class: Some("anchored".to_string()),
1686 hash: Some("h".to_string()),
1687 hash_stability: Some("stable".to_string()),
1688 ..Default::default()
1689 };
1690 let mut sections = IndexMap::new();
1691 sections.insert("identity".to_string(), "Covers a.".to_string());
1692 sections.insert("purpose".to_string(), "Track a.rs.".to_string());
1693 {
1694 let mut engine = engine_at(root);
1695 engine
1696 .create_entity(
1697 crate::CreateEntityArgs {
1698 mem: "engine".to_string(),
1699 title: "Covers A".to_string(),
1700 entity_type: "spec".to_string(),
1701 sections,
1702 metadata: IndexMap::new(),
1703 relations: Vec::new(),
1704 anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
1705 dry_run: false,
1706 },
1707 Actor::Agent,
1708 None,
1709 Some("anchored write"),
1710 )
1711 .unwrap();
1712 }
1713
1714 {
1718 let mut engine = engine_at(root);
1719 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1720 assert!(!out.completed, "b.rs still pending");
1721 assert_eq!(
1722 out.remainder,
1723 slice(&["b.rs"], &[], &[]),
1724 "a.rs auto-worked from its anchor; zzz.rs never became a slice member"
1725 );
1726 assert_eq!(out.disposed, 1, "only a.rs auto-worked");
1727 assert_eq!(out.pending, 1);
1728 }
1729
1730 std::fs::write(root.join("c.rs"), "cee").unwrap();
1734 git(root, &["add", "-A"]);
1735 git(root, &["commit", "-qm", "head2"]);
1736 {
1737 let mut engine = engine_at(root);
1738 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1739 assert_eq!(
1740 out.remainder,
1741 slice(&["b.rs", "c.rs"], &[], &[]),
1742 "auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
1743 );
1744 assert_eq!(
1745 out.disposed, 1,
1746 "still only a.rs auto-worked; c.rs unanchored"
1747 );
1748 assert!(!out.completed);
1749 }
1750 }
1751
1752 fn a3_workspace(root: &std::path::Path, files: &[&str]) {
1756 let mem_dir = root.join("mem");
1757 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1758 std::fs::write(
1759 mem_dir.join(".memstead").join("config.json"),
1760 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1761 )
1762 .unwrap();
1763 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1764 std::fs::write(
1765 root.join(".memstead").join("workspace.toml"),
1766 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1767 )
1768 .unwrap();
1769 let mount = crate::workspace::Mount {
1770 mem: "engine".to_string(),
1771 schema: Some("default@1.0.0".parse().unwrap()),
1772 storage: crate::workspace::MountStorage::Folder {
1773 path: mem_dir.clone(),
1774 },
1775 capability: crate::workspace::MountCapability::Write,
1776 lifecycle: crate::workspace::MountLifecycle::Eager,
1777 cross_linkable: false,
1778 migration_target: None,
1779 };
1780 crate::workspace_store::WorkspaceStoreAdapter::save_state(
1781 &crate::FileWorkspaceStore::new(),
1782 root,
1783 &crate::workspace::Workspace {
1784 mounts: vec![mount],
1785 settings: crate::workspace::WorkspaceSettings::default(),
1786 },
1787 )
1788 .unwrap();
1789 let out = std::process::Command::new("git")
1790 .args(["init", "-q"])
1791 .current_dir(root)
1792 .output()
1793 .unwrap();
1794 assert!(out.status.success());
1795 for f in files {
1796 let p = root.join(f);
1797 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1798 std::fs::write(p, "fn x() {}\n").unwrap();
1799 }
1800 }
1801
1802 fn a3_binding(
1803 sources: &[(&str, &str)],
1804 deny: &[&str],
1805 batch_size: u32,
1806 ) -> crate::binding::Binding {
1807 crate::binding::Binding {
1808 version: crate::binding::BINDING_VERSION,
1809 intent: None,
1810 sources: sources
1811 .iter()
1812 .map(|(name, glob)| crate::pipeline::Source {
1813 name: name.to_string(),
1814 medium_type: crate::pipeline::MediumType::Codebase,
1815 pointer: String::new(),
1816 change_detection: Some("git".to_string()),
1817 scope: vec![crate::pipeline::PatternEntry {
1818 path: glob.to_string(),
1819 mode: crate::pipeline::PatternMode::Allow,
1820 }],
1821 engagement: None,
1822 preparation: None,
1823 })
1824 .collect(),
1825 reference_mems: Vec::new(),
1826 destination_mem: "engine".to_string(),
1827 deny_paths: deny.iter().map(|d| d.to_string()).collect(),
1828 coverage_semantics: None,
1829 rules: None,
1830 prune: None,
1831 operations: crate::binding::Operations {
1832 build: Some(crate::binding::BuildOperation {
1833 mode: crate::binding::BuildMode::Discovery,
1834 trigger: crate::pipeline::IngestTrigger::Loop,
1835 batch_size,
1836 post_actions: None,
1837 }),
1838 sync: None,
1839 verify: Some(crate::binding::VerifyOperation {
1840 trigger: crate::pipeline::IngestTrigger::Manual,
1841 batch_size,
1842 adjudication_cap: crate::binding::DEFAULT_ADJUDICATION_CAP,
1843 full_resync_every: 0,
1845 }),
1846 },
1847 }
1848 }
1849
1850 #[test]
1854 fn exclusions_survive_edits_and_drop_with_their_source() {
1855 let tmp = tempfile::tempdir().unwrap();
1856 let root = tmp.path();
1857 a3_workspace(root, &["src/a.rs", "docs/x.md"]);
1858 let engine = Engine::from_workspace_root(root).unwrap();
1859 let two = |batch: u32| {
1860 a3_binding(
1861 &[("graph", "src/**/*.rs"), ("docs", "docs/**/*.md")],
1862 &[],
1863 batch,
1864 )
1865 };
1866
1867 let b = two(20);
1868 crate::pipeline_store::write_binding(root, "engine", "graph", &b).unwrap();
1869 let resolved = crate::ingest::resolve::resolve_binding_run("engine/graph", &b).unwrap();
1870 let mut ex = BTreeMap::new();
1871 ex.insert("docs/x.md".to_string(), "index page, mined".to_string());
1872 record_exclusions(&engine, root, &resolved, &ex).unwrap();
1873 let ledger = reconcile_exclusions(&engine, root, &resolved).unwrap();
1874 assert_eq!(ledger.active.len(), 1);
1875 assert_eq!(ledger.active[0].source, "docs");
1876 assert_eq!(ledger.active[0].rationale, "index page, mined");
1877 assert!(ledger.dropped.is_empty());
1878
1879 let edited = two(5);
1881 crate::pipeline_store::write_binding(root, "engine", "graph", &edited).unwrap();
1882 let resolved =
1883 crate::ingest::resolve::resolve_binding_run("engine/graph", &edited).unwrap();
1884 let ledger = reconcile_exclusions(&engine, root, &resolved).unwrap();
1885 assert_eq!(ledger.active.len(), 1, "{ledger:?}");
1886 assert_eq!(ledger.active[0].artifact, "docs/x.md");
1887 assert_eq!(ledger.active[0].rationale, "index page, mined");
1888 assert!(ledger.dropped.is_empty());
1889
1890 let without = a3_binding(&[("graph", "src/**/*.rs")], &[], 5);
1892 crate::pipeline_store::write_binding(root, "engine", "graph", &without).unwrap();
1893 let resolved =
1894 crate::ingest::resolve::resolve_binding_run("engine/graph", &without).unwrap();
1895 let ledger = reconcile_exclusions(&engine, root, &resolved).unwrap();
1896 assert!(ledger.active.is_empty(), "{ledger:?}");
1897 assert_eq!(ledger.dropped.len(), 1);
1898 assert_eq!(ledger.dropped[0].artifact, "docs/x.md");
1899 assert_eq!(ledger.dropped[0].source, "docs");
1900 assert_eq!(ledger.dropped[0].rationale, "index page, mined");
1901 let again = reconcile_exclusions(&engine, root, &resolved).unwrap();
1902 assert!(
1903 again.active.is_empty() && again.dropped.is_empty(),
1904 "reported once: {again:?}"
1905 );
1906 assert!(
1907 read_advance_store(root, "engine", "graph")
1908 .unwrap()
1909 .is_none()
1910 );
1911 }
1912}