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};
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("advance store error: {0}")]
620 Store(#[source] StoreError),
621}
622
623pub fn record_exclusions(
638 engine: &Engine,
639 workspace_root: &Path,
640 resolved: &ResolvedIngest,
641 exclusions: &BTreeMap<String, String>,
642) -> Result<ExcludeOutcome, ExcludeError> {
643 let binding_id = resolved.name.clone();
644 let (mem, name) =
645 split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
646
647 let mut s_d: BTreeSet<String> = BTreeSet::new();
650 for source in &resolved.sources {
651 if let ResolvedSource::Primary(p) = source {
652 for f in enumerate_source_artifacts(engine, p, &resolved.deny_paths, workspace_root) {
653 s_d.insert(f);
654 }
655 }
656 }
657
658 let mut not_member: Vec<String> = exclusions
661 .keys()
662 .filter(|a| !s_d.contains(a.as_str()))
663 .cloned()
664 .collect();
665 if !not_member.is_empty() {
666 not_member.sort();
667 not_member.dedup();
668 return Err(ExcludeError::NotSourceMember {
669 artifacts: not_member,
670 printed: s_d.len(),
671 });
672 }
673
674 let mut state = read_advance_store(workspace_root, &mem, &name)
677 .map_err(ExcludeError::Store)?
678 .unwrap_or_else(|| AdvanceState {
679 binding: binding_id.clone(),
680 ..Default::default()
681 });
682 let mut added = 0usize;
683 for (artifact, rationale) in exclusions {
684 if state
685 .exclusions
686 .insert(artifact.clone(), rationale.clone())
687 .is_none()
688 {
689 added += 1;
690 }
691 }
692 write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
693
694 Ok(ExcludeOutcome {
695 binding: binding_id,
696 excluded: state.exclusions.len(),
697 added,
698 })
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use crate::binding::BuildMode;
705 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
706 use crate::storage::FilesystemMemWriter;
707 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
708 use tempfile::TempDir;
709
710 fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
713 Slice {
714 added: added.iter().map(|s| s.to_string()).collect(),
715 modified: modified.iter().map(|s| s.to_string()).collect(),
716 deleted: deleted.iter().map(|s| s.to_string()).collect(),
717 }
718 }
719
720 fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
721 pairs
722 .iter()
723 .map(|(a, d)| (a.to_string(), d.to_string()))
724 .collect()
725 }
726
727 fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
730 pairs
731 .iter()
732 .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
733 .collect()
734 }
735
736 #[test]
738 fn advance_store_round_trips_and_delete_is_idempotent() {
739 let tmp = TempDir::new().unwrap();
740 let root = tmp.path();
741 assert!(
742 read_advance_store(root, "engine", "graph")
743 .unwrap()
744 .is_none()
745 );
746
747 let state = AdvanceState {
748 binding: "engine/graph".to_string(),
749 frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
750 dispositions: disp(&[("a.rs", "worked")]),
751 exclusions: BTreeMap::new(),
752 };
753 write_advance_store(root, "engine", "graph", &state).unwrap();
754 assert!(
755 advance_store_path(root, "engine", "graph")
756 .ends_with("state/advance/engine/graph.json")
757 );
758 let back = read_advance_store(root, "engine", "graph")
759 .unwrap()
760 .unwrap();
761 assert_eq!(back, state);
762
763 delete_advance_store(root, "engine", "graph").unwrap();
764 assert!(
765 read_advance_store(root, "engine", "graph")
766 .unwrap()
767 .is_none()
768 );
769 delete_advance_store(root, "engine", "graph").unwrap();
771 }
772
773 #[test]
775 fn subtract_disposed_removes_disposed_from_every_class() {
776 let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
777 let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
778 assert_eq!(out, slice(&[], &[], &["b.rs"]));
779 }
780
781 fn git(repo: &Path, args: &[&str]) {
784 let out = std::process::Command::new("git")
785 .args(args)
786 .current_dir(repo)
787 .env("GIT_AUTHOR_NAME", "t")
788 .env("GIT_AUTHOR_EMAIL", "t@t")
789 .env("GIT_COMMITTER_NAME", "t")
790 .env("GIT_COMMITTER_EMAIL", "t@t")
791 .output()
792 .unwrap();
793 assert!(
794 out.status.success(),
795 "git {args:?}: {}",
796 String::from_utf8_lossy(&out.stderr)
797 );
798 }
799
800 fn head_sha(repo: &Path) -> String {
801 String::from_utf8(
802 std::process::Command::new("git")
803 .args(["rev-parse", "HEAD"])
804 .current_dir(repo)
805 .output()
806 .unwrap()
807 .stdout,
808 )
809 .unwrap()
810 .trim()
811 .to_string()
812 }
813
814 fn resolved_engine_graph() -> ResolvedIngest {
818 use super::super::resolve::{ResolvedSource, Source};
819 ResolvedIngest {
820 name: "engine/graph".to_string(),
821 mode: BuildMode::Discovery,
822 trigger: IngestTrigger::Loop,
823 batch_size: 20,
824 deny_paths: vec![],
825 projection_ref: "engine/graph".to_string(),
826 projection_mem: "engine".to_string(),
827 projection_name: "graph".to_string(),
828 intent: None,
829 sources: vec![ResolvedSource::Primary(Source {
830 name: "source-tree".to_string(),
831 medium_type: MediumType::Codebase,
832 pointer: String::new(),
833 change_detection: Some("git".to_string()),
834 scope: vec![PatternEntry {
835 path: "**/*.rs".to_string(),
836 mode: PatternMode::Allow,
837 }],
838 engagement: None,
839 preparation: None,
840 })],
841 destination_mem: "engine".to_string(),
842 rules: None,
843 post_actions: None,
844 }
845 }
846
847 fn engine_at(root: &Path) -> Engine {
851 let config_path = root.join(".memstead").join("config.json");
855 if !config_path.exists() {
856 std::fs::create_dir_all(root.join(".memstead")).unwrap();
857 std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
858 }
859 let mount = Mount {
860 mem: "engine".to_string(),
861 schema: Some("default@1.0.0".parse().unwrap()),
862 storage: MountStorage::Folder {
863 path: root.to_path_buf(),
864 },
865 capability: MountCapability::Write,
866 lifecycle: MountLifecycle::Eager,
867 cross_linkable: false,
868 migration_target: None,
869 };
870 Engine::from_mounts(vec![(
871 mount,
872 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
873 as Box<dyn crate::backend::MemBackend>,
874 )])
875 .unwrap()
876 }
877
878 fn synced_key() -> &'static str {
879 "engine/graph/source-tree#synced"
880 }
881
882 #[test]
895 fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
896 let tmp = TempDir::new().unwrap();
897 let root = tmp.path();
898
899 git(root, &["init", "-q"]);
901 std::fs::write(root.join("a.rs"), "one").unwrap();
902 std::fs::write(root.join("b.rs"), "bee").unwrap();
903 git(root, &["add", "a.rs", "b.rs"]);
904 git(root, &["commit", "-qm", "base"]);
905 let baseline = head_sha(root);
906
907 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
909 std::fs::remove_file(root.join("b.rs")).unwrap();
910 git(root, &["add", "-A"]);
911 git(root, &["commit", "-qm", "head1"]);
912
913 let resolved = resolved_engine_graph();
914
915 {
917 let mut engine = engine_at(root);
918 engine
919 .set_mem_sync_state("engine", synced_key(), &baseline, None)
920 .unwrap();
921 }
922
923 {
925 let mut engine = engine_at(root);
926 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
927 .unwrap();
928 assert!(!out.completed, "one artifact still pending");
929 assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
930 assert_eq!(out.pending, 1);
931 assert_eq!(out.disposed, 1);
932 }
933 let on_disk = read_advance_store(root, "engine", "graph")
935 .unwrap()
936 .unwrap();
937 assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
938
939 let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
942 {
943 let mut engine = engine_at(root);
944 let err = advance_baseline(
945 &mut engine,
946 root,
947 &resolved,
948 &input(&[("never-presented.rs", "worked")]),
949 )
950 .unwrap_err();
951 assert!(
952 matches!(err, AdvanceError::UnknownArtifact { .. }),
953 "expected UnknownArtifact, got {err:?}"
954 );
955 }
956 let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
957 assert_eq!(before, after, "refused call must not touch the store");
958
959 std::fs::write(root.join("c.rs"), "cee").unwrap();
961 git(root, &["add", "-A"]);
962 git(root, &["commit", "-qm", "head2"]);
963
964 {
968 let mut engine = engine_at(root);
969 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
970 assert!(!out.completed);
971 assert_eq!(
972 out.remainder,
973 slice(&["c.rs"], &[], &["b.rs"]),
974 "re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
975 );
976 assert_eq!(out.disposed, 1, "no new disposition this call");
977 }
978
979 let head2 = head_sha(root);
981 {
982 let mut engine = engine_at(root);
983 let out = advance_baseline(
984 &mut engine,
985 root,
986 &resolved,
987 &input(&[("b.rs", "worked"), ("c.rs", "worked")]),
988 )
989 .unwrap();
990 assert!(out.completed, "every artifact disposed → complete");
991 assert_eq!(out.pending, 0);
992 assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
993
994 let token = engine
996 .mem_config_for("engine")
997 .and_then(|c| c.sync_state.get(synced_key()).cloned());
998 assert_eq!(token.as_deref(), Some(head2.as_str()));
999 }
1000 assert!(
1002 read_advance_store(root, "engine", "graph")
1003 .unwrap()
1004 .is_none()
1005 );
1006 }
1007
1008 #[test]
1014 fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
1015 let tmp = TempDir::new().unwrap();
1016 let root = tmp.path();
1017
1018 git(root, &["init", "-q"]);
1020 std::fs::write(root.join("a.rs"), "one").unwrap();
1021 git(root, &["add", "a.rs"]);
1022 git(root, &["commit", "-qm", "base"]);
1023 let baseline = head_sha(root);
1024 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1025 git(root, &["add", "-A"]);
1026 git(root, &["commit", "-qm", "head1"]);
1027
1028 let resolved = resolved_engine_graph();
1029 {
1030 let mut engine = engine_at(root);
1031 engine
1032 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1033 .unwrap();
1034 }
1035
1036 let excluded = {
1040 let mut m = BTreeMap::new();
1041 m.insert(
1042 "a.rs".to_string(),
1043 DispositionInput::Reasoned {
1044 disposition: EXCLUDED_VERDICT.to_string(),
1045 rationale: "mined; warrants no destination entity".to_string(),
1046 },
1047 );
1048 m
1049 };
1050 {
1051 let mut engine = engine_at(root);
1052 let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
1053 assert!(out.completed, "the sole slice artifact was disposed");
1054 }
1055 let retained = read_advance_store(root, "engine", "graph")
1056 .unwrap()
1057 .expect("an authored exclusion keeps the store alive past completion");
1058 assert!(
1059 retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
1060 "transient progress is dropped on completion"
1061 );
1062 assert_eq!(
1063 retained.exclusions.get("a.rs").map(String::as_str),
1064 Some("mined; warrants no destination entity"),
1065 "the durable exclusion + its rationale persist"
1066 );
1067
1068 std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
1072 git(root, &["add", "-A"]);
1073 git(root, &["commit", "-qm", "head2"]);
1074 {
1075 let mut engine = engine_at(root);
1076 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1077 .unwrap();
1078 assert!(out.completed);
1079 }
1080 assert!(
1081 read_advance_store(root, "engine", "graph")
1082 .unwrap()
1083 .is_none(),
1084 "re-judging the artifact cleared the exclusion; nothing durable remains"
1085 );
1086 }
1087
1088 #[test]
1096 fn advance_unknown_artifact_names_dialect_and_suggests_corrected_id() {
1097 let tmp = TempDir::new().unwrap();
1098 let root = tmp.path();
1099
1100 git(root, &["init", "-q"]);
1103 std::fs::create_dir_all(root.join("sub")).unwrap();
1104 std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1105 git(root, &["add", "-A"]);
1106 git(root, &["commit", "-qm", "base"]);
1107 let baseline = head_sha(root);
1108 std::fs::write(root.join("sub").join("a.rs"), "one-longer").unwrap();
1109 git(root, &["add", "-A"]);
1110 git(root, &["commit", "-qm", "head1"]);
1111
1112 let mut resolved = resolved_engine_graph();
1113 if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1114 p.pointer = "sub".to_string();
1115 }
1116 {
1117 let mut engine = engine_at(root);
1118 engine
1119 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1120 .unwrap();
1121 }
1122
1123 {
1127 let mut engine = engine_at(root);
1128 let err = advance_baseline(
1129 &mut engine,
1130 root,
1131 &resolved,
1132 &input(&[("a.rs", "worked"), ("zzz.rs", "worked")]),
1133 )
1134 .unwrap_err();
1135 let AdvanceError::UnknownArtifact {
1136 artifacts,
1137 suggestions,
1138 ..
1139 } = &err
1140 else {
1141 panic!("expected UnknownArtifact, got {err:?}");
1142 };
1143 assert_eq!(artifacts, &vec!["a.rs".to_string(), "zzz.rs".to_string()]);
1144 assert_eq!(
1145 suggestions,
1146 &vec![("a.rs".to_string(), "sub/a.rs".to_string())],
1147 "only the medium-relative id gets a corrected form; zzz.rs has none"
1148 );
1149 let msg = err.to_string();
1150 assert!(
1151 msg.contains("workspace-relative"),
1152 "names the dialect: {msg}"
1153 );
1154 assert!(
1155 msg.contains("`a.rs` → `sub/a.rs`"),
1156 "carries the concrete corrected id: {msg}"
1157 );
1158 assert!(
1159 msg.contains("never accepted"),
1160 "states the dialect does not widen: {msg}"
1161 );
1162 }
1163 assert!(
1165 read_advance_store(root, "engine", "graph")
1166 .unwrap()
1167 .is_none(),
1168 "a refused call must not create the advance store"
1169 );
1170
1171 {
1173 let mut engine = engine_at(root);
1174 let out = advance_baseline(
1175 &mut engine,
1176 root,
1177 &resolved,
1178 &input(&[("sub/a.rs", "worked")]),
1179 )
1180 .unwrap();
1181 assert!(out.completed, "the sole slice artifact was disposed");
1182 }
1183 }
1184
1185 #[test]
1190 fn record_exclusions_gates_on_source_membership_and_merges() {
1191 let tmp = TempDir::new().unwrap();
1192 let root = tmp.path();
1193
1194 git(root, &["init", "-q"]);
1197 std::fs::write(root.join("a.rs"), "one").unwrap();
1198 std::fs::write(root.join("b.rs"), "two").unwrap();
1199 git(root, &["add", "-A"]);
1200 git(root, &["commit", "-qm", "base"]);
1201
1202 let resolved = resolved_engine_graph();
1203
1204 let out = record_exclusions(
1206 &Engine::from_mounts(Vec::new()).unwrap(),
1207 root,
1208 &resolved,
1209 &BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
1210 )
1211 .unwrap();
1212 assert_eq!((out.added, out.excluded), (1, 1));
1213 let state = read_advance_store(root, "engine", "graph")
1214 .unwrap()
1215 .unwrap();
1216 assert_eq!(
1217 state.exclusions.get("a.rs").map(String::as_str),
1218 Some("mined; no entity")
1219 );
1220
1221 let err = record_exclusions(
1223 &Engine::from_mounts(Vec::new()).unwrap(),
1224 root,
1225 &resolved,
1226 &BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
1227 )
1228 .unwrap_err();
1229 assert!(
1230 matches!(err, ExcludeError::NotSourceMember { .. }),
1231 "got {err:?}"
1232 );
1233 assert_eq!(
1234 read_advance_store(root, "engine", "graph")
1235 .unwrap()
1236 .unwrap()
1237 .exclusions
1238 .len(),
1239 1,
1240 "refused call left the ledger unchanged"
1241 );
1242
1243 let out2 = record_exclusions(
1245 &Engine::from_mounts(Vec::new()).unwrap(),
1246 root,
1247 &resolved,
1248 &BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
1249 )
1250 .unwrap();
1251 assert_eq!((out2.added, out2.excluded), (1, 2));
1252 }
1253
1254 #[test]
1257 fn disposition_input_parses_bare_and_reasoned_forms() {
1258 let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
1259 r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
1260 )
1261 .unwrap();
1262 assert_eq!(map["a.rs"].verdict(), "worked");
1263 assert_eq!(map["a.rs"].rationale(), None);
1264 assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
1265 assert_eq!(map["b.rs"].rationale(), Some("generated"));
1266 }
1267
1268 #[test]
1275 fn advance_auto_worked_matches_source_dialect_anchors() {
1276 use crate::binding::{BINDING_VERSION, Binding, Operations};
1277 use crate::vcs::Actor;
1278 use indexmap::IndexMap;
1279
1280 let tmp = TempDir::new().unwrap();
1281 let root = tmp.path();
1282
1283 git(root, &["init", "-q"]);
1284 std::fs::create_dir_all(root.join("srcdir")).unwrap();
1285 std::fs::write(root.join(".keep"), "x").unwrap();
1286 git(root, &["add", ".keep"]);
1287 git(root, &["commit", "-qm", "base"]);
1288 let baseline = head_sha(root);
1289
1290 let binding = Binding {
1293 version: BINDING_VERSION,
1294 intent: None,
1295 sources: vec![crate::pipeline::Source {
1296 name: "source-tree".to_string(),
1297 medium_type: crate::pipeline::MediumType::Codebase,
1298 pointer: "srcdir".to_string(),
1299 change_detection: Some("git".to_string()),
1300 scope: vec![PatternEntry {
1301 path: "**/*.rs".to_string(),
1302 mode: PatternMode::Allow,
1303 }],
1304 engagement: None,
1305 preparation: None,
1306 }],
1307 reference_mems: Vec::new(),
1308 destination_mem: "engine".to_string(),
1309 deny_paths: Vec::new(),
1310 coverage_semantics: None,
1311 rules: None,
1312 prune: None,
1313 operations: Operations {
1314 build: None,
1315 sync: None,
1316 verify: None,
1317 },
1318 };
1319 let dir = root.join(".memstead").join("projections").join("engine");
1320 std::fs::create_dir_all(&dir).unwrap();
1321 std::fs::write(
1322 dir.join("graph.json"),
1323 serde_json::to_string_pretty(&binding).unwrap(),
1324 )
1325 .unwrap();
1326
1327 let mut resolved = resolved_engine_graph();
1330 if let [ResolvedSource::Primary(p)] = resolved.sources.as_mut_slice() {
1331 p.pointer = "srcdir".to_string();
1332 } else {
1333 panic!("fixture shape");
1334 }
1335
1336 {
1337 let mut engine = engine_at(root);
1338 engine
1339 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1340 .unwrap();
1341 }
1342
1343 std::fs::write(root.join("srcdir").join("f.rs"), "fn f() {}").unwrap();
1344 git(root, &["add", "-A"]);
1345 git(root, &["commit", "-qm", "head1"]);
1346
1347 let mut sections = IndexMap::new();
1350 sections.insert("identity".to_string(), "Covers f.".to_string());
1351 sections.insert("purpose".to_string(), "Track f.rs.".to_string());
1352 {
1353 let mut engine = engine_at(root);
1354 engine.set_workspace_root(root.to_path_buf());
1355 engine
1356 .create_entity(
1357 crate::CreateEntityArgs {
1358 mem: "engine".to_string(),
1359 title: "Covers F".to_string(),
1360 entity_type: "spec".to_string(),
1361 sections,
1362 metadata: IndexMap::new(),
1363 relations: Vec::new(),
1364 anchors: vec![crate::anchor::AnchorInput {
1365 artifact: Some("f.rs".to_string()),
1366 grain: Some("file".to_string()),
1367 class: Some("anchored".to_string()),
1368 hash: Some("h".to_string()),
1369 hash_stability: Some("stable".to_string()),
1370 source: Some("source-tree".to_string()),
1371 ..Default::default()
1372 }],
1373 dry_run: false,
1374 },
1375 Actor::Agent,
1376 None,
1377 Some("source-dialect anchored write"),
1378 )
1379 .unwrap();
1380 }
1381
1382 let mut engine = engine_at(root);
1385 engine.set_workspace_root(root.to_path_buf());
1386 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1387 assert!(
1388 out.completed,
1389 "the source-dialect anchor auto-worked the joined slice artifact: {out:?}"
1390 );
1391 assert_eq!(out.disposed, 1);
1392 }
1393
1394 #[test]
1400 fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
1401 use crate::vcs::Actor;
1402 use indexmap::IndexMap;
1403
1404 let tmp = TempDir::new().unwrap();
1405 let root = tmp.path();
1406
1407 git(root, &["init", "-q"]);
1410 std::fs::write(root.join(".keep"), "x").unwrap();
1411 git(root, &["add", ".keep"]);
1412 git(root, &["commit", "-qm", "base"]);
1413 let baseline = head_sha(root);
1414
1415 let resolved = resolved_engine_graph();
1416 {
1417 let mut engine = engine_at(root);
1418 engine
1419 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1420 .unwrap();
1421 }
1422
1423 std::fs::write(root.join("a.rs"), "one").unwrap();
1425 std::fs::write(root.join("b.rs"), "bee").unwrap();
1426 git(root, &["add", "a.rs", "b.rs"]);
1427 git(root, &["commit", "-qm", "head1"]);
1428
1429 let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
1433 artifact: Some(artifact.to_string()),
1434 grain: Some("file".to_string()),
1435 class: Some("anchored".to_string()),
1436 hash: Some("h".to_string()),
1437 hash_stability: Some("stable".to_string()),
1438 ..Default::default()
1439 };
1440 let mut sections = IndexMap::new();
1441 sections.insert("identity".to_string(), "Covers a.".to_string());
1442 sections.insert("purpose".to_string(), "Track a.rs.".to_string());
1443 {
1444 let mut engine = engine_at(root);
1445 engine
1446 .create_entity(
1447 crate::CreateEntityArgs {
1448 mem: "engine".to_string(),
1449 title: "Covers A".to_string(),
1450 entity_type: "spec".to_string(),
1451 sections,
1452 metadata: IndexMap::new(),
1453 relations: Vec::new(),
1454 anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
1455 dry_run: false,
1456 },
1457 Actor::Agent,
1458 None,
1459 Some("anchored write"),
1460 )
1461 .unwrap();
1462 }
1463
1464 {
1468 let mut engine = engine_at(root);
1469 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1470 assert!(!out.completed, "b.rs still pending");
1471 assert_eq!(
1472 out.remainder,
1473 slice(&["b.rs"], &[], &[]),
1474 "a.rs auto-worked from its anchor; zzz.rs never became a slice member"
1475 );
1476 assert_eq!(out.disposed, 1, "only a.rs auto-worked");
1477 assert_eq!(out.pending, 1);
1478 }
1479
1480 std::fs::write(root.join("c.rs"), "cee").unwrap();
1484 git(root, &["add", "-A"]);
1485 git(root, &["commit", "-qm", "head2"]);
1486 {
1487 let mut engine = engine_at(root);
1488 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1489 assert_eq!(
1490 out.remainder,
1491 slice(&["b.rs", "c.rs"], &[], &[]),
1492 "auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
1493 );
1494 assert_eq!(
1495 out.disposed, 1,
1496 "still only a.rs auto-worked; c.rs unanchored"
1497 );
1498 assert!(!out.completed);
1499 }
1500 }
1501}