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::{compute_source_cursor, enumerate_source_artifacts_reported};
56use super::resolve::{ResolvedIngest, ResolvedSource};
57use super::slice::Slice;
58
59const STATE_DIR: &str = "state";
62const ADVANCE_DIR: &str = "advance";
64
65#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
74pub struct AdvanceState {
75 pub binding: String,
77 pub frozen_slice: Slice,
79 pub dispositions: BTreeMap<String, String>,
81 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
89 pub exclusions: BTreeMap<String, String>,
90}
91
92pub const EXCLUDED_VERDICT: &str = "excluded";
98
99#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111#[serde(untagged)]
112pub enum DispositionInput {
113 Verdict(String),
115 Reasoned {
117 disposition: String,
119 rationale: String,
121 },
122}
123
124impl DispositionInput {
125 pub fn verdict(&self) -> &str {
127 match self {
128 DispositionInput::Verdict(v) => v,
129 DispositionInput::Reasoned { disposition, .. } => disposition,
130 }
131 }
132
133 pub fn rationale(&self) -> Option<&str> {
135 match self {
136 DispositionInput::Verdict(_) => None,
137 DispositionInput::Reasoned { rationale, .. } => Some(rationale),
138 }
139 }
140}
141
142impl AdvanceState {
143 pub fn disposed(&self) -> usize {
146 self.dispositions.len()
147 }
148
149 pub fn pending(&self) -> usize {
153 artifact_set(&self.frozen_slice)
154 .iter()
155 .filter(|a| !self.dispositions.contains_key(a.as_str()))
156 .count()
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct AdvanceOutcome {
163 pub binding: String,
165 pub remainder: Slice,
168 pub disposed: usize,
170 pub pending: usize,
172 pub completed: bool,
175 pub tokens_written: Vec<String>,
178 pub warnings: Vec<String>,
181}
182
183#[derive(Debug, thiserror::Error)]
185pub enum AdvanceError {
186 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
188 MalformedId(String),
189 #[error(
198 "disposition names {} artifact id(s) the engine did not present: {}; the advance gate \
199 accepts only ids from the presented slice, verbatim in their workspace-relative form \
200 ({printed} presented){}",
201 artifacts.len(),
202 fmt_list(artifacts),
203 fmt_suggestions(suggestions)
204 )]
205 UnknownArtifact {
206 artifacts: Vec<String>,
208 printed: usize,
210 suggestions: Vec<(String, String)>,
214 },
215 #[error("advance store error: {0}")]
217 Store(#[source] StoreError),
218 #[error("could not advance baseline token: {0}")]
220 Engine(String),
221}
222
223fn fmt_list(names: &[String]) -> String {
225 if names.is_empty() {
226 "(none)".to_string()
227 } else {
228 names.join(", ")
229 }
230}
231
232fn fmt_suggestions(suggestions: &[(String, String)]) -> String {
236 if suggestions.is_empty() {
237 return String::new();
238 }
239 let pairs = suggestions
240 .iter()
241 .map(|(supplied, corrected)| format!("`{supplied}` → `{corrected}`"))
242 .collect::<Vec<_>>()
243 .join(", ");
244 format!(
245 ". Some supplied ids look medium-relative; the slice presents them workspace-relative — \
246 retry with {pairs} (the medium-relative form is never accepted)"
247 )
248}
249
250fn derive_corrected_ids(
255 unknown: &[String],
256 resolved: &ResolvedIngest,
257 printed: &BTreeSet<String>,
258) -> Vec<(String, String)> {
259 let medium_roots: Vec<&str> = resolved
260 .sources
261 .iter()
262 .filter_map(|s| match s {
263 ResolvedSource::Primary(p) if !p.pointer.is_empty() => Some(p.pointer.as_str()),
264 _ => None,
265 })
266 .collect();
267 unknown
268 .iter()
269 .filter_map(|id| {
270 medium_roots.iter().find_map(|root| {
271 let candidate = format!("{}/{id}", root.trim_end_matches('/'));
272 printed
273 .contains(candidate.as_str())
274 .then(|| (id.clone(), candidate))
275 })
276 })
277 .collect()
278}
279
280fn split_binding_id(binding_id: &str) -> Result<(String, String), AdvanceError> {
284 binding_id
285 .split_once('/')
286 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
287 .map(|(m, n)| (m.to_string(), n.to_string()))
288 .ok_or_else(|| AdvanceError::MalformedId(binding_id.to_string()))
289}
290
291pub(crate) fn is_single_component(value: &str) -> bool {
295 !value.is_empty()
296 && value != "."
297 && value != ".."
298 && !value.contains('/')
299 && !value.contains('\\')
300 && !value.contains(':')
301 && !value.contains('\0')
302}
303
304pub fn advance_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
306 workspace_root
307 .join(WORKSPACE_STORE_DIR)
308 .join(STATE_DIR)
309 .join(ADVANCE_DIR)
310 .join(mem)
311 .join(format!("{name}.json"))
312}
313
314pub fn read_advance_store(
318 workspace_root: &Path,
319 mem: &str,
320 name: &str,
321) -> Result<Option<AdvanceState>, StoreError> {
322 let path = advance_store_path(workspace_root, mem, name);
323 match std::fs::read(&path) {
324 Ok(bytes) => serde_json::from_slice(&bytes)
325 .map(Some)
326 .map_err(|e| StoreError::Parse {
327 path,
328 message: e.to_string(),
329 }),
330 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
331 Err(e) => Err(StoreError::Io { path, source: e }),
332 }
333}
334
335pub fn write_advance_store(
338 workspace_root: &Path,
339 mem: &str,
340 name: &str,
341 state: &AdvanceState,
342) -> Result<(), StoreError> {
343 super::findings::ensure_selfignoring_store_dir(
346 &workspace_root
347 .join(WORKSPACE_STORE_DIR)
348 .join(STATE_DIR)
349 .join(ADVANCE_DIR),
350 )?;
351 let path = advance_store_path(workspace_root, mem, name);
352 if let Some(parent) = path.parent() {
353 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
354 path: parent.to_path_buf(),
355 source: e,
356 })?;
357 }
358 let bytes = serde_json::to_vec_pretty(state).map_err(|e| StoreError::Parse {
359 path: path.clone(),
360 message: e.to_string(),
361 })?;
362 std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
363}
364
365pub fn delete_advance_store(
368 workspace_root: &Path,
369 mem: &str,
370 name: &str,
371) -> Result<(), StoreError> {
372 let path = advance_store_path(workspace_root, mem, name);
373 match std::fs::remove_file(&path) {
374 Ok(()) => Ok(()),
375 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
376 Err(e) => Err(StoreError::Io { path, source: e }),
377 }
378}
379
380fn union_slice(into: &mut Slice, from: &Slice) {
382 into.added.extend(from.added.iter().cloned());
383 into.modified.extend(from.modified.iter().cloned());
384 into.deleted.extend(from.deleted.iter().cloned());
385 for v in [&mut into.added, &mut into.modified, &mut into.deleted] {
386 v.sort();
387 v.dedup();
388 }
389}
390
391fn artifact_set(slice: &Slice) -> BTreeSet<String> {
394 slice
395 .added
396 .iter()
397 .chain(slice.modified.iter())
398 .chain(slice.deleted.iter())
399 .cloned()
400 .collect()
401}
402
403fn subtract_disposed(frozen: &Slice, dispositions: &BTreeMap<String, String>) -> Slice {
406 let keep = |v: &[String]| -> Vec<String> {
407 v.iter()
408 .filter(|a| !dispositions.contains_key(*a))
409 .cloned()
410 .collect()
411 };
412 Slice {
413 added: keep(&frozen.added),
414 modified: keep(&frozen.modified),
415 deleted: keep(&frozen.deleted),
416 }
417}
418
419pub fn advance_baseline(
438 engine: &mut Engine,
439 workspace_root: &Path,
440 resolved: &ResolvedIngest,
441 dispositions: &BTreeMap<String, DispositionInput>,
442) -> Result<AdvanceOutcome, AdvanceError> {
443 let binding_id = resolved.name.clone();
444 let (mem, name) = split_binding_id(&binding_id)?;
445
446 let cursor = compute_source_cursor(engine, resolved, workspace_root);
450
451 let mut state = read_advance_store(workspace_root, &mem, &name)
453 .map_err(AdvanceError::Store)?
454 .unwrap_or_else(|| AdvanceState {
455 binding: binding_id.clone(),
456 ..Default::default()
457 });
458
459 union_slice(&mut state.frozen_slice, &cursor.union);
461 let printed = artifact_set(&state.frozen_slice);
462
463 let mut unknown: Vec<String> = dispositions
466 .keys()
467 .filter(|a| !printed.contains(a.as_str()))
468 .cloned()
469 .collect();
470 if !unknown.is_empty() {
471 unknown.sort();
472 unknown.dedup();
473 let suggestions = derive_corrected_ids(&unknown, resolved, &printed);
478 return Err(AdvanceError::UnknownArtifact {
479 artifacts: unknown,
480 printed: printed.len(),
481 suggestions,
482 });
483 }
484
485 for (artifact, input) in dispositions {
490 state
491 .dispositions
492 .insert(artifact.clone(), input.verdict().to_string());
493 if input.verdict() == EXCLUDED_VERDICT {
494 state.exclusions.insert(
495 artifact.clone(),
496 input.rationale().unwrap_or("").to_string(),
497 );
498 } else {
499 state.exclusions.remove(artifact);
500 }
501 }
502
503 let auto_worked: Vec<String> = printed
510 .iter()
511 .filter(|art| !state.dispositions.contains_key(art.as_str()))
512 .filter(|art| {
513 let (base, key) = crate::preparation::split_unit_id(art);
518 engine
519 .anchors_referencing_artifact(base)
520 .iter()
521 .any(|(eid, a)| {
522 eid.mem() == resolved.destination_mem.as_str()
523 && (key.is_none() || a.artifact == **art)
524 })
525 })
526 .cloned()
527 .collect();
528 for art in auto_worked {
529 state.dispositions.insert(art, "worked".to_string());
530 }
531
532 let remainder = subtract_disposed(&state.frozen_slice, &state.dispositions);
534 let pending = remainder.added.len() + remainder.modified.len() + remainder.deleted.len();
535 let completed = pending == 0;
536
537 let mut warnings: Vec<String> = Vec::new();
538 let mut tokens_written: Vec<String> = Vec::new();
539 if completed {
540 let note = format!(
544 "projection advance {binding_id}: {} artifact(s) disposed, baseline advanced",
545 state.dispositions.len()
546 );
547 for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
548 let outcome = engine
549 .set_mem_sync_state(&resolved.destination_mem, &c.key, &c.token, Some(¬e))
550 .map_err(|e| AdvanceError::Engine(e.to_string()))?;
551 warnings.extend(outcome.warnings.iter().map(ToString::to_string));
552 tokens_written.push(c.key.clone());
553 }
554 if state.exclusions.is_empty() {
560 delete_advance_store(workspace_root, &mem, &name).map_err(AdvanceError::Store)?;
561 } else {
562 let durable = AdvanceState {
563 binding: binding_id.clone(),
564 frozen_slice: Slice::default(),
565 dispositions: BTreeMap::new(),
566 exclusions: state.exclusions.clone(),
567 };
568 write_advance_store(workspace_root, &mem, &name, &durable)
569 .map_err(AdvanceError::Store)?;
570 }
571 } else {
572 write_advance_store(workspace_root, &mem, &name, &state).map_err(AdvanceError::Store)?;
574 }
575
576 Ok(AdvanceOutcome {
577 binding: binding_id,
578 remainder,
579 disposed: state.dispositions.len(),
580 pending,
581 completed,
582 tokens_written,
583 warnings,
584 })
585}
586
587#[derive(Debug, Clone, PartialEq, Eq)]
589pub struct ExcludeOutcome {
590 pub binding: String,
592 pub excluded: usize,
594 pub added: usize,
596}
597
598#[derive(Debug, thiserror::Error)]
600pub enum ExcludeError {
601 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
603 MalformedId(String),
604 #[error(
607 "exclusion names {} artifact id(s) not in the binding's enumerable source S(D): {}; \
608 only an in-scope source member can be declared excluded ({printed} enumerated)",
609 artifacts.len(),
610 fmt_list(artifacts)
611 )]
612 NotSourceMember {
613 artifacts: Vec<String>,
615 printed: usize,
617 },
618 #[error(
623 "the binding's source enumeration is incomplete — {reason} — so `S(D)` membership \
624 cannot be decided; fix the named scope pattern(s), then re-declare the exclusions"
625 )]
626 PartialEnumeration {
627 facet: String,
629 reason: String,
631 },
632 #[error("advance store error: {0}")]
634 Store(#[source] StoreError),
635}
636
637pub fn record_exclusions(
652 engine: &Engine,
653 workspace_root: &Path,
654 resolved: &ResolvedIngest,
655 exclusions: &BTreeMap<String, String>,
656) -> Result<ExcludeOutcome, ExcludeError> {
657 let binding_id = resolved.name.clone();
658 let (mem, name) =
659 split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
660
661 let mut s_d: BTreeSet<String> = BTreeSet::new();
668 for source in &resolved.sources {
669 if let ResolvedSource::Primary(p) = source {
670 let walked = enumerate_source_artifacts_reported(
671 engine,
672 p,
673 &resolved.deny_paths,
674 workspace_root,
675 );
676 if let Some(reason) = walked.partiality_reason() {
677 return Err(ExcludeError::PartialEnumeration {
678 facet: p.name.clone(),
679 reason,
680 });
681 }
682 s_d.extend(walked.files);
683 }
684 }
685
686 let mut not_member: Vec<String> = exclusions
689 .keys()
690 .filter(|a| !s_d.contains(a.as_str()))
691 .cloned()
692 .collect();
693 if !not_member.is_empty() {
694 not_member.sort();
695 not_member.dedup();
696 return Err(ExcludeError::NotSourceMember {
697 artifacts: not_member,
698 printed: s_d.len(),
699 });
700 }
701
702 let mut state = read_advance_store(workspace_root, &mem, &name)
705 .map_err(ExcludeError::Store)?
706 .unwrap_or_else(|| AdvanceState {
707 binding: binding_id.clone(),
708 ..Default::default()
709 });
710 let mut added = 0usize;
711 for (artifact, rationale) in exclusions {
712 if state
713 .exclusions
714 .insert(artifact.clone(), rationale.clone())
715 .is_none()
716 {
717 added += 1;
718 }
719 }
720 write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
721
722 Ok(ExcludeOutcome {
723 binding: binding_id,
724 excluded: state.exclusions.len(),
725 added,
726 })
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732 use crate::binding::BuildMode;
733 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
734 use crate::storage::FilesystemMemWriter;
735 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
736 use tempfile::TempDir;
737
738 fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
741 Slice {
742 added: added.iter().map(|s| s.to_string()).collect(),
743 modified: modified.iter().map(|s| s.to_string()).collect(),
744 deleted: deleted.iter().map(|s| s.to_string()).collect(),
745 }
746 }
747
748 fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
749 pairs
750 .iter()
751 .map(|(a, d)| (a.to_string(), d.to_string()))
752 .collect()
753 }
754
755 fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
758 pairs
759 .iter()
760 .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
761 .collect()
762 }
763
764 #[test]
766 fn advance_store_round_trips_and_delete_is_idempotent() {
767 let tmp = TempDir::new().unwrap();
768 let root = tmp.path();
769 assert!(
770 read_advance_store(root, "engine", "graph")
771 .unwrap()
772 .is_none()
773 );
774
775 let state = AdvanceState {
776 binding: "engine/graph".to_string(),
777 frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
778 dispositions: disp(&[("a.rs", "worked")]),
779 exclusions: BTreeMap::new(),
780 };
781 write_advance_store(root, "engine", "graph", &state).unwrap();
782 assert!(
783 advance_store_path(root, "engine", "graph")
784 .ends_with("state/advance/engine/graph.json")
785 );
786 let back = read_advance_store(root, "engine", "graph")
787 .unwrap()
788 .unwrap();
789 assert_eq!(back, state);
790
791 delete_advance_store(root, "engine", "graph").unwrap();
792 assert!(
793 read_advance_store(root, "engine", "graph")
794 .unwrap()
795 .is_none()
796 );
797 delete_advance_store(root, "engine", "graph").unwrap();
799 }
800
801 #[test]
803 fn subtract_disposed_removes_disposed_from_every_class() {
804 let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
805 let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
806 assert_eq!(out, slice(&[], &[], &["b.rs"]));
807 }
808
809 fn git(repo: &Path, args: &[&str]) {
812 let out = std::process::Command::new("git")
813 .args(args)
814 .current_dir(repo)
815 .env("GIT_AUTHOR_NAME", "t")
816 .env("GIT_AUTHOR_EMAIL", "t@t")
817 .env("GIT_COMMITTER_NAME", "t")
818 .env("GIT_COMMITTER_EMAIL", "t@t")
819 .output()
820 .unwrap();
821 assert!(
822 out.status.success(),
823 "git {args:?}: {}",
824 String::from_utf8_lossy(&out.stderr)
825 );
826 }
827
828 fn head_sha(repo: &Path) -> String {
829 String::from_utf8(
830 std::process::Command::new("git")
831 .args(["rev-parse", "HEAD"])
832 .current_dir(repo)
833 .output()
834 .unwrap()
835 .stdout,
836 )
837 .unwrap()
838 .trim()
839 .to_string()
840 }
841
842 fn resolved_engine_graph() -> ResolvedIngest {
846 use super::super::resolve::{ResolvedSource, Source};
847 ResolvedIngest {
848 name: "engine/graph".to_string(),
849 mode: BuildMode::Discovery,
850 trigger: IngestTrigger::Loop,
851 batch_size: 20,
852 deny_paths: vec![],
853 projection_ref: "engine/graph".to_string(),
854 projection_mem: "engine".to_string(),
855 projection_name: "graph".to_string(),
856 intent: None,
857 sources: vec![ResolvedSource::Primary(Source {
858 name: "source-tree".to_string(),
859 medium_type: MediumType::Codebase,
860 pointer: String::new(),
861 change_detection: Some("git".to_string()),
862 scope: vec![PatternEntry {
863 path: "**/*.rs".to_string(),
864 mode: PatternMode::Allow,
865 }],
866 engagement: None,
867 preparation: None,
868 })],
869 destination_mem: "engine".to_string(),
870 rules: None,
871 post_actions: None,
872 }
873 }
874
875 fn engine_at(root: &Path) -> Engine {
879 let config_path = root.join(".memstead").join("config.json");
883 if !config_path.exists() {
884 std::fs::create_dir_all(root.join(".memstead")).unwrap();
885 std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
886 }
887 let mount = Mount {
888 mem: "engine".to_string(),
889 schema: Some("default@1.0.0".parse().unwrap()),
890 storage: MountStorage::Folder {
891 path: root.to_path_buf(),
892 },
893 capability: MountCapability::Write,
894 lifecycle: MountLifecycle::Eager,
895 cross_linkable: false,
896 migration_target: None,
897 };
898 Engine::from_mounts(vec![(
899 mount,
900 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
901 as Box<dyn crate::backend::MemBackend>,
902 )])
903 .unwrap()
904 }
905
906 fn synced_key() -> &'static str {
907 "engine/graph/source-tree#synced"
908 }
909
910 #[test]
923 fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
924 let tmp = TempDir::new().unwrap();
925 let root = tmp.path();
926
927 git(root, &["init", "-q"]);
929 std::fs::write(root.join("a.rs"), "one").unwrap();
930 std::fs::write(root.join("b.rs"), "bee").unwrap();
931 git(root, &["add", "a.rs", "b.rs"]);
932 git(root, &["commit", "-qm", "base"]);
933 let baseline = head_sha(root);
934
935 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
937 std::fs::remove_file(root.join("b.rs")).unwrap();
938 git(root, &["add", "-A"]);
939 git(root, &["commit", "-qm", "head1"]);
940
941 let resolved = resolved_engine_graph();
942
943 {
945 let mut engine = engine_at(root);
946 engine
947 .set_mem_sync_state("engine", synced_key(), &baseline, None)
948 .unwrap();
949 }
950
951 {
953 let mut engine = engine_at(root);
954 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
955 .unwrap();
956 assert!(!out.completed, "one artifact still pending");
957 assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
958 assert_eq!(out.pending, 1);
959 assert_eq!(out.disposed, 1);
960 }
961 let on_disk = read_advance_store(root, "engine", "graph")
963 .unwrap()
964 .unwrap();
965 assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
966
967 let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
970 {
971 let mut engine = engine_at(root);
972 let err = advance_baseline(
973 &mut engine,
974 root,
975 &resolved,
976 &input(&[("never-presented.rs", "worked")]),
977 )
978 .unwrap_err();
979 assert!(
980 matches!(err, AdvanceError::UnknownArtifact { .. }),
981 "expected UnknownArtifact, got {err:?}"
982 );
983 }
984 let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
985 assert_eq!(before, after, "refused call must not touch the store");
986
987 std::fs::write(root.join("c.rs"), "cee").unwrap();
989 git(root, &["add", "-A"]);
990 git(root, &["commit", "-qm", "head2"]);
991
992 {
996 let mut engine = engine_at(root);
997 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
998 assert!(!out.completed);
999 assert_eq!(
1000 out.remainder,
1001 slice(&["c.rs"], &[], &["b.rs"]),
1002 "re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
1003 );
1004 assert_eq!(out.disposed, 1, "no new disposition this call");
1005 }
1006
1007 let head2 = head_sha(root);
1009 {
1010 let mut engine = engine_at(root);
1011 let out = advance_baseline(
1012 &mut engine,
1013 root,
1014 &resolved,
1015 &input(&[("b.rs", "worked"), ("c.rs", "worked")]),
1016 )
1017 .unwrap();
1018 assert!(out.completed, "every artifact disposed → complete");
1019 assert_eq!(out.pending, 0);
1020 assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
1021
1022 let token = engine
1024 .mem_config_for("engine")
1025 .and_then(|c| c.sync_state.get(synced_key()).cloned());
1026 assert_eq!(token.as_deref(), Some(head2.as_str()));
1027 }
1028 assert!(
1030 read_advance_store(root, "engine", "graph")
1031 .unwrap()
1032 .is_none()
1033 );
1034 }
1035
1036 #[test]
1042 fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
1043 let tmp = TempDir::new().unwrap();
1044 let root = tmp.path();
1045
1046 git(root, &["init", "-q"]);
1048 std::fs::write(root.join("a.rs"), "one").unwrap();
1049 git(root, &["add", "a.rs"]);
1050 git(root, &["commit", "-qm", "base"]);
1051 let baseline = head_sha(root);
1052 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1053 git(root, &["add", "-A"]);
1054 git(root, &["commit", "-qm", "head1"]);
1055
1056 let resolved = resolved_engine_graph();
1057 {
1058 let mut engine = engine_at(root);
1059 engine
1060 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1061 .unwrap();
1062 }
1063
1064 let excluded = {
1068 let mut m = BTreeMap::new();
1069 m.insert(
1070 "a.rs".to_string(),
1071 DispositionInput::Reasoned {
1072 disposition: EXCLUDED_VERDICT.to_string(),
1073 rationale: "mined; warrants no destination entity".to_string(),
1074 },
1075 );
1076 m
1077 };
1078 {
1079 let mut engine = engine_at(root);
1080 let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
1081 assert!(out.completed, "the sole slice artifact was disposed");
1082 }
1083 let retained = read_advance_store(root, "engine", "graph")
1084 .unwrap()
1085 .expect("an authored exclusion keeps the store alive past completion");
1086 assert!(
1087 retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
1088 "transient progress is dropped on completion"
1089 );
1090 assert_eq!(
1091 retained.exclusions.get("a.rs").map(String::as_str),
1092 Some("mined; warrants no destination entity"),
1093 "the durable exclusion + its rationale persist"
1094 );
1095
1096 std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
1100 git(root, &["add", "-A"]);
1101 git(root, &["commit", "-qm", "head2"]);
1102 {
1103 let mut engine = engine_at(root);
1104 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1105 .unwrap();
1106 assert!(out.completed);
1107 }
1108 assert!(
1109 read_advance_store(root, "engine", "graph")
1110 .unwrap()
1111 .is_none(),
1112 "re-judging the artifact cleared the exclusion; nothing durable remains"
1113 );
1114 }
1115
1116 #[test]
1124 fn advance_unknown_artifact_names_dialect_and_suggests_corrected_id() {
1125 let tmp = TempDir::new().unwrap();
1126 let root = tmp.path();
1127
1128 git(root, &["init", "-q"]);
1131 std::fs::create_dir_all(root.join("sub")).unwrap();
1132 std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1133 git(root, &["add", "-A"]);
1134 git(root, &["commit", "-qm", "base"]);
1135 let baseline = head_sha(root);
1136 std::fs::write(root.join("sub").join("a.rs"), "one-longer").unwrap();
1137 git(root, &["add", "-A"]);
1138 git(root, &["commit", "-qm", "head1"]);
1139
1140 let mut resolved = resolved_engine_graph();
1141 if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1142 p.pointer = "sub".to_string();
1143 }
1144 {
1145 let mut engine = engine_at(root);
1146 engine
1147 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1148 .unwrap();
1149 }
1150
1151 {
1155 let mut engine = engine_at(root);
1156 let err = advance_baseline(
1157 &mut engine,
1158 root,
1159 &resolved,
1160 &input(&[("a.rs", "worked"), ("zzz.rs", "worked")]),
1161 )
1162 .unwrap_err();
1163 let AdvanceError::UnknownArtifact {
1164 artifacts,
1165 suggestions,
1166 ..
1167 } = &err
1168 else {
1169 panic!("expected UnknownArtifact, got {err:?}");
1170 };
1171 assert_eq!(artifacts, &vec!["a.rs".to_string(), "zzz.rs".to_string()]);
1172 assert_eq!(
1173 suggestions,
1174 &vec![("a.rs".to_string(), "sub/a.rs".to_string())],
1175 "only the medium-relative id gets a corrected form; zzz.rs has none"
1176 );
1177 let msg = err.to_string();
1178 assert!(
1179 msg.contains("workspace-relative"),
1180 "names the dialect: {msg}"
1181 );
1182 assert!(
1183 msg.contains("`a.rs` → `sub/a.rs`"),
1184 "carries the concrete corrected id: {msg}"
1185 );
1186 assert!(
1187 msg.contains("never accepted"),
1188 "states the dialect does not widen: {msg}"
1189 );
1190 }
1191 assert!(
1193 read_advance_store(root, "engine", "graph")
1194 .unwrap()
1195 .is_none(),
1196 "a refused call must not create the advance store"
1197 );
1198
1199 {
1201 let mut engine = engine_at(root);
1202 let out = advance_baseline(
1203 &mut engine,
1204 root,
1205 &resolved,
1206 &input(&[("sub/a.rs", "worked")]),
1207 )
1208 .unwrap();
1209 assert!(out.completed, "the sole slice artifact was disposed");
1210 }
1211 }
1212
1213 #[test]
1218 fn record_exclusions_gates_on_source_membership_and_merges() {
1219 let tmp = TempDir::new().unwrap();
1220 let root = tmp.path();
1221
1222 git(root, &["init", "-q"]);
1225 std::fs::write(root.join("a.rs"), "one").unwrap();
1226 std::fs::write(root.join("b.rs"), "two").unwrap();
1227 git(root, &["add", "-A"]);
1228 git(root, &["commit", "-qm", "base"]);
1229
1230 let resolved = resolved_engine_graph();
1231
1232 let out = record_exclusions(
1234 &Engine::from_mounts(Vec::new()).unwrap(),
1235 root,
1236 &resolved,
1237 &BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
1238 )
1239 .unwrap();
1240 assert_eq!((out.added, out.excluded), (1, 1));
1241 let state = read_advance_store(root, "engine", "graph")
1242 .unwrap()
1243 .unwrap();
1244 assert_eq!(
1245 state.exclusions.get("a.rs").map(String::as_str),
1246 Some("mined; no entity")
1247 );
1248
1249 let err = record_exclusions(
1251 &Engine::from_mounts(Vec::new()).unwrap(),
1252 root,
1253 &resolved,
1254 &BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
1255 )
1256 .unwrap_err();
1257 assert!(
1258 matches!(err, ExcludeError::NotSourceMember { .. }),
1259 "got {err:?}"
1260 );
1261 assert_eq!(
1262 read_advance_store(root, "engine", "graph")
1263 .unwrap()
1264 .unwrap()
1265 .exclusions
1266 .len(),
1267 1,
1268 "refused call left the ledger unchanged"
1269 );
1270
1271 let out2 = record_exclusions(
1273 &Engine::from_mounts(Vec::new()).unwrap(),
1274 root,
1275 &resolved,
1276 &BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
1277 )
1278 .unwrap();
1279 assert_eq!((out2.added, out2.excluded), (1, 2));
1280 }
1281
1282 #[test]
1287 fn record_exclusions_refuses_partial_enumeration() {
1288 let tmp = TempDir::new().unwrap();
1289 let root = tmp.path();
1290
1291 git(root, &["init", "-q"]);
1292 std::fs::create_dir_all(root.join("sub")).unwrap();
1293 std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1294 git(root, &["add", "-A"]);
1295 git(root, &["commit", "-qm", "base"]);
1296
1297 let mut resolved = resolved_engine_graph();
1300 if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1301 p.pointer = "sub".to_string();
1302 p.scope.push(PatternEntry {
1303 path: "sub/nested.rs".to_string(),
1304 mode: PatternMode::Allow,
1305 });
1306 }
1307
1308 let err = record_exclusions(
1311 &Engine::from_mounts(Vec::new()).unwrap(),
1312 root,
1313 &resolved,
1314 &BTreeMap::from([("sub/a.rs".to_string(), "mined; no entity".to_string())]),
1315 )
1316 .unwrap_err();
1317 assert!(
1318 matches!(err, ExcludeError::PartialEnumeration { .. }),
1319 "got {err:?}"
1320 );
1321 assert!(
1322 err.to_string().contains("incomplete"),
1323 "the refusal names the partiality: {err}"
1324 );
1325 assert!(
1326 read_advance_store(root, "engine", "graph")
1327 .unwrap()
1328 .is_none(),
1329 "a refused call must not create the advance store"
1330 );
1331 }
1332
1333 #[test]
1336 fn disposition_input_parses_bare_and_reasoned_forms() {
1337 let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
1338 r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
1339 )
1340 .unwrap();
1341 assert_eq!(map["a.rs"].verdict(), "worked");
1342 assert_eq!(map["a.rs"].rationale(), None);
1343 assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
1344 assert_eq!(map["b.rs"].rationale(), Some("generated"));
1345 }
1346
1347 #[test]
1354 fn advance_auto_worked_matches_source_dialect_anchors() {
1355 use crate::binding::{BINDING_VERSION, Binding, Operations};
1356 use crate::vcs::Actor;
1357 use indexmap::IndexMap;
1358
1359 let tmp = TempDir::new().unwrap();
1360 let root = tmp.path();
1361
1362 git(root, &["init", "-q"]);
1363 std::fs::create_dir_all(root.join("srcdir")).unwrap();
1364 std::fs::write(root.join(".keep"), "x").unwrap();
1365 git(root, &["add", ".keep"]);
1366 git(root, &["commit", "-qm", "base"]);
1367 let baseline = head_sha(root);
1368
1369 let binding = Binding {
1372 version: BINDING_VERSION,
1373 intent: None,
1374 sources: vec![crate::pipeline::Source {
1375 name: "source-tree".to_string(),
1376 medium_type: crate::pipeline::MediumType::Codebase,
1377 pointer: "srcdir".to_string(),
1378 change_detection: Some("git".to_string()),
1379 scope: vec![PatternEntry {
1380 path: "**/*.rs".to_string(),
1381 mode: PatternMode::Allow,
1382 }],
1383 engagement: None,
1384 preparation: None,
1385 }],
1386 reference_mems: Vec::new(),
1387 destination_mem: "engine".to_string(),
1388 deny_paths: Vec::new(),
1389 coverage_semantics: None,
1390 rules: None,
1391 prune: None,
1392 operations: Operations {
1393 build: None,
1394 sync: None,
1395 verify: None,
1396 },
1397 };
1398 let dir = root.join(".memstead").join("projections").join("engine");
1399 std::fs::create_dir_all(&dir).unwrap();
1400 std::fs::write(
1401 dir.join("graph.json"),
1402 serde_json::to_string_pretty(&binding).unwrap(),
1403 )
1404 .unwrap();
1405
1406 let mut resolved = resolved_engine_graph();
1409 if let [ResolvedSource::Primary(p)] = resolved.sources.as_mut_slice() {
1410 p.pointer = "srcdir".to_string();
1411 } else {
1412 panic!("fixture shape");
1413 }
1414
1415 {
1416 let mut engine = engine_at(root);
1417 engine
1418 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1419 .unwrap();
1420 }
1421
1422 std::fs::write(root.join("srcdir").join("f.rs"), "fn f() {}").unwrap();
1423 git(root, &["add", "-A"]);
1424 git(root, &["commit", "-qm", "head1"]);
1425
1426 let mut sections = IndexMap::new();
1429 sections.insert("identity".to_string(), "Covers f.".to_string());
1430 sections.insert("purpose".to_string(), "Track f.rs.".to_string());
1431 {
1432 let mut engine = engine_at(root);
1433 engine.set_workspace_root(root.to_path_buf());
1434 engine
1435 .create_entity(
1436 crate::CreateEntityArgs {
1437 mem: "engine".to_string(),
1438 title: "Covers F".to_string(),
1439 entity_type: "spec".to_string(),
1440 sections,
1441 metadata: IndexMap::new(),
1442 relations: Vec::new(),
1443 anchors: vec![crate::anchor::AnchorInput {
1444 artifact: Some("f.rs".to_string()),
1445 grain: Some("file".to_string()),
1446 class: Some("anchored".to_string()),
1447 hash: Some("h".to_string()),
1448 hash_stability: Some("stable".to_string()),
1449 source: Some("source-tree".to_string()),
1450 ..Default::default()
1451 }],
1452 dry_run: false,
1453 },
1454 Actor::Agent,
1455 None,
1456 Some("source-dialect anchored write"),
1457 )
1458 .unwrap();
1459 }
1460
1461 let mut engine = engine_at(root);
1464 engine.set_workspace_root(root.to_path_buf());
1465 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1466 assert!(
1467 out.completed,
1468 "the source-dialect anchor auto-worked the joined slice artifact: {out:?}"
1469 );
1470 assert_eq!(out.disposed, 1);
1471 }
1472
1473 #[test]
1479 fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
1480 use crate::vcs::Actor;
1481 use indexmap::IndexMap;
1482
1483 let tmp = TempDir::new().unwrap();
1484 let root = tmp.path();
1485
1486 git(root, &["init", "-q"]);
1489 std::fs::write(root.join(".keep"), "x").unwrap();
1490 git(root, &["add", ".keep"]);
1491 git(root, &["commit", "-qm", "base"]);
1492 let baseline = head_sha(root);
1493
1494 let resolved = resolved_engine_graph();
1495 {
1496 let mut engine = engine_at(root);
1497 engine
1498 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1499 .unwrap();
1500 }
1501
1502 std::fs::write(root.join("a.rs"), "one").unwrap();
1504 std::fs::write(root.join("b.rs"), "bee").unwrap();
1505 git(root, &["add", "a.rs", "b.rs"]);
1506 git(root, &["commit", "-qm", "head1"]);
1507
1508 let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
1512 artifact: Some(artifact.to_string()),
1513 grain: Some("file".to_string()),
1514 class: Some("anchored".to_string()),
1515 hash: Some("h".to_string()),
1516 hash_stability: Some("stable".to_string()),
1517 ..Default::default()
1518 };
1519 let mut sections = IndexMap::new();
1520 sections.insert("identity".to_string(), "Covers a.".to_string());
1521 sections.insert("purpose".to_string(), "Track a.rs.".to_string());
1522 {
1523 let mut engine = engine_at(root);
1524 engine
1525 .create_entity(
1526 crate::CreateEntityArgs {
1527 mem: "engine".to_string(),
1528 title: "Covers A".to_string(),
1529 entity_type: "spec".to_string(),
1530 sections,
1531 metadata: IndexMap::new(),
1532 relations: Vec::new(),
1533 anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
1534 dry_run: false,
1535 },
1536 Actor::Agent,
1537 None,
1538 Some("anchored write"),
1539 )
1540 .unwrap();
1541 }
1542
1543 {
1547 let mut engine = engine_at(root);
1548 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1549 assert!(!out.completed, "b.rs still pending");
1550 assert_eq!(
1551 out.remainder,
1552 slice(&["b.rs"], &[], &[]),
1553 "a.rs auto-worked from its anchor; zzz.rs never became a slice member"
1554 );
1555 assert_eq!(out.disposed, 1, "only a.rs auto-worked");
1556 assert_eq!(out.pending, 1);
1557 }
1558
1559 std::fs::write(root.join("c.rs"), "cee").unwrap();
1563 git(root, &["add", "-A"]);
1564 git(root, &["commit", "-qm", "head2"]);
1565 {
1566 let mut engine = engine_at(root);
1567 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1568 assert_eq!(
1569 out.remainder,
1570 slice(&["b.rs", "c.rs"], &[], &[]),
1571 "auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
1572 );
1573 assert_eq!(
1574 out.disposed, 1,
1575 "still only a.rs auto-worked; c.rs unanchored"
1576 );
1577 assert!(!out.completed);
1578 }
1579 }
1580}