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 engine
514 .anchors_referencing_artifact(art)
515 .iter()
516 .any(|(eid, _)| eid.mem() == resolved.destination_mem.as_str())
517 })
518 .cloned()
519 .collect();
520 for art in auto_worked {
521 state.dispositions.insert(art, "worked".to_string());
522 }
523
524 let remainder = subtract_disposed(&state.frozen_slice, &state.dispositions);
526 let pending = remainder.added.len() + remainder.modified.len() + remainder.deleted.len();
527 let completed = pending == 0;
528
529 let mut warnings: Vec<String> = Vec::new();
530 let mut tokens_written: Vec<String> = Vec::new();
531 if completed {
532 let note = format!(
536 "projection advance {binding_id}: {} artifact(s) disposed, baseline advanced",
537 state.dispositions.len()
538 );
539 for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
540 let outcome = engine
541 .set_mem_sync_state(&resolved.destination_mem, &c.key, &c.token, Some(¬e))
542 .map_err(|e| AdvanceError::Engine(e.to_string()))?;
543 warnings.extend(outcome.warnings.iter().map(ToString::to_string));
544 tokens_written.push(c.key.clone());
545 }
546 if state.exclusions.is_empty() {
552 delete_advance_store(workspace_root, &mem, &name).map_err(AdvanceError::Store)?;
553 } else {
554 let durable = AdvanceState {
555 binding: binding_id.clone(),
556 frozen_slice: Slice::default(),
557 dispositions: BTreeMap::new(),
558 exclusions: state.exclusions.clone(),
559 };
560 write_advance_store(workspace_root, &mem, &name, &durable)
561 .map_err(AdvanceError::Store)?;
562 }
563 } else {
564 write_advance_store(workspace_root, &mem, &name, &state).map_err(AdvanceError::Store)?;
566 }
567
568 Ok(AdvanceOutcome {
569 binding: binding_id,
570 remainder,
571 disposed: state.dispositions.len(),
572 pending,
573 completed,
574 tokens_written,
575 warnings,
576 })
577}
578
579#[derive(Debug, Clone, PartialEq, Eq)]
581pub struct ExcludeOutcome {
582 pub binding: String,
584 pub excluded: usize,
586 pub added: usize,
588}
589
590#[derive(Debug, thiserror::Error)]
592pub enum ExcludeError {
593 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
595 MalformedId(String),
596 #[error(
599 "exclusion names {} artifact id(s) not in the binding's enumerable source S(D): {}; \
600 only an in-scope source member can be declared excluded ({printed} enumerated)",
601 artifacts.len(),
602 fmt_list(artifacts)
603 )]
604 NotSourceMember {
605 artifacts: Vec<String>,
607 printed: usize,
609 },
610 #[error("advance store error: {0}")]
612 Store(#[source] StoreError),
613}
614
615pub fn record_exclusions(
630 engine: &Engine,
631 workspace_root: &Path,
632 resolved: &ResolvedIngest,
633 exclusions: &BTreeMap<String, String>,
634) -> Result<ExcludeOutcome, ExcludeError> {
635 let binding_id = resolved.name.clone();
636 let (mem, name) =
637 split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
638
639 let mut s_d: BTreeSet<String> = BTreeSet::new();
642 for source in &resolved.sources {
643 if let ResolvedSource::Primary(p) = source {
644 for f in enumerate_source_artifacts(engine, p, &resolved.deny_paths, workspace_root) {
645 s_d.insert(f);
646 }
647 }
648 }
649
650 let mut not_member: Vec<String> = exclusions
653 .keys()
654 .filter(|a| !s_d.contains(a.as_str()))
655 .cloned()
656 .collect();
657 if !not_member.is_empty() {
658 not_member.sort();
659 not_member.dedup();
660 return Err(ExcludeError::NotSourceMember {
661 artifacts: not_member,
662 printed: s_d.len(),
663 });
664 }
665
666 let mut state = read_advance_store(workspace_root, &mem, &name)
669 .map_err(ExcludeError::Store)?
670 .unwrap_or_else(|| AdvanceState {
671 binding: binding_id.clone(),
672 ..Default::default()
673 });
674 let mut added = 0usize;
675 for (artifact, rationale) in exclusions {
676 if state
677 .exclusions
678 .insert(artifact.clone(), rationale.clone())
679 .is_none()
680 {
681 added += 1;
682 }
683 }
684 write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
685
686 Ok(ExcludeOutcome {
687 binding: binding_id,
688 excluded: state.exclusions.len(),
689 added,
690 })
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use crate::binding::BuildMode;
697 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
698 use crate::storage::FilesystemMemWriter;
699 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
700 use tempfile::TempDir;
701
702 fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
705 Slice {
706 added: added.iter().map(|s| s.to_string()).collect(),
707 modified: modified.iter().map(|s| s.to_string()).collect(),
708 deleted: deleted.iter().map(|s| s.to_string()).collect(),
709 }
710 }
711
712 fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
713 pairs
714 .iter()
715 .map(|(a, d)| (a.to_string(), d.to_string()))
716 .collect()
717 }
718
719 fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
722 pairs
723 .iter()
724 .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
725 .collect()
726 }
727
728 #[test]
730 fn advance_store_round_trips_and_delete_is_idempotent() {
731 let tmp = TempDir::new().unwrap();
732 let root = tmp.path();
733 assert!(
734 read_advance_store(root, "engine", "graph")
735 .unwrap()
736 .is_none()
737 );
738
739 let state = AdvanceState {
740 binding: "engine/graph".to_string(),
741 frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
742 dispositions: disp(&[("a.rs", "worked")]),
743 exclusions: BTreeMap::new(),
744 };
745 write_advance_store(root, "engine", "graph", &state).unwrap();
746 assert!(
747 advance_store_path(root, "engine", "graph")
748 .ends_with("state/advance/engine/graph.json")
749 );
750 let back = read_advance_store(root, "engine", "graph")
751 .unwrap()
752 .unwrap();
753 assert_eq!(back, state);
754
755 delete_advance_store(root, "engine", "graph").unwrap();
756 assert!(
757 read_advance_store(root, "engine", "graph")
758 .unwrap()
759 .is_none()
760 );
761 delete_advance_store(root, "engine", "graph").unwrap();
763 }
764
765 #[test]
767 fn subtract_disposed_removes_disposed_from_every_class() {
768 let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
769 let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
770 assert_eq!(out, slice(&[], &[], &["b.rs"]));
771 }
772
773 fn git(repo: &Path, args: &[&str]) {
776 let out = std::process::Command::new("git")
777 .args(args)
778 .current_dir(repo)
779 .env("GIT_AUTHOR_NAME", "t")
780 .env("GIT_AUTHOR_EMAIL", "t@t")
781 .env("GIT_COMMITTER_NAME", "t")
782 .env("GIT_COMMITTER_EMAIL", "t@t")
783 .output()
784 .unwrap();
785 assert!(
786 out.status.success(),
787 "git {args:?}: {}",
788 String::from_utf8_lossy(&out.stderr)
789 );
790 }
791
792 fn head_sha(repo: &Path) -> String {
793 String::from_utf8(
794 std::process::Command::new("git")
795 .args(["rev-parse", "HEAD"])
796 .current_dir(repo)
797 .output()
798 .unwrap()
799 .stdout,
800 )
801 .unwrap()
802 .trim()
803 .to_string()
804 }
805
806 fn resolved_engine_graph() -> ResolvedIngest {
810 use super::super::resolve::{ResolvedSource, Source};
811 ResolvedIngest {
812 name: "engine/graph".to_string(),
813 mode: BuildMode::Discovery,
814 trigger: IngestTrigger::Loop,
815 batch_size: 20,
816 deny_paths: vec![],
817 projection_ref: "engine/graph".to_string(),
818 projection_mem: "engine".to_string(),
819 projection_name: "graph".to_string(),
820 intent: None,
821 sources: vec![ResolvedSource::Primary(Source {
822 name: "source-tree".to_string(),
823 medium_type: MediumType::Codebase,
824 pointer: String::new(),
825 change_detection: Some("git".to_string()),
826 scope: vec![PatternEntry {
827 path: "**/*.rs".to_string(),
828 mode: PatternMode::Allow,
829 }],
830 engagement: None,
831 preparation: None,
832 })],
833 destination_mem: "engine".to_string(),
834 rules: None,
835 post_actions: None,
836 }
837 }
838
839 fn engine_at(root: &Path) -> Engine {
843 let config_path = root.join(".memstead").join("config.json");
847 if !config_path.exists() {
848 std::fs::create_dir_all(root.join(".memstead")).unwrap();
849 std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
850 }
851 let mount = Mount {
852 mem: "engine".to_string(),
853 schema: Some("default@1.0.0".parse().unwrap()),
854 storage: MountStorage::Folder {
855 path: root.to_path_buf(),
856 },
857 capability: MountCapability::Write,
858 lifecycle: MountLifecycle::Eager,
859 cross_linkable: false,
860 migration_target: None,
861 };
862 Engine::from_mounts(vec![(
863 mount,
864 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
865 as Box<dyn crate::backend::MemBackend>,
866 )])
867 .unwrap()
868 }
869
870 fn synced_key() -> &'static str {
871 "engine/graph/source-tree#synced"
872 }
873
874 #[test]
887 fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
888 let tmp = TempDir::new().unwrap();
889 let root = tmp.path();
890
891 git(root, &["init", "-q"]);
893 std::fs::write(root.join("a.rs"), "one").unwrap();
894 std::fs::write(root.join("b.rs"), "bee").unwrap();
895 git(root, &["add", "a.rs", "b.rs"]);
896 git(root, &["commit", "-qm", "base"]);
897 let baseline = head_sha(root);
898
899 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
901 std::fs::remove_file(root.join("b.rs")).unwrap();
902 git(root, &["add", "-A"]);
903 git(root, &["commit", "-qm", "head1"]);
904
905 let resolved = resolved_engine_graph();
906
907 {
909 let mut engine = engine_at(root);
910 engine
911 .set_mem_sync_state("engine", synced_key(), &baseline, None)
912 .unwrap();
913 }
914
915 {
917 let mut engine = engine_at(root);
918 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
919 .unwrap();
920 assert!(!out.completed, "one artifact still pending");
921 assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
922 assert_eq!(out.pending, 1);
923 assert_eq!(out.disposed, 1);
924 }
925 let on_disk = read_advance_store(root, "engine", "graph")
927 .unwrap()
928 .unwrap();
929 assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
930
931 let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
934 {
935 let mut engine = engine_at(root);
936 let err = advance_baseline(
937 &mut engine,
938 root,
939 &resolved,
940 &input(&[("never-presented.rs", "worked")]),
941 )
942 .unwrap_err();
943 assert!(
944 matches!(err, AdvanceError::UnknownArtifact { .. }),
945 "expected UnknownArtifact, got {err:?}"
946 );
947 }
948 let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
949 assert_eq!(before, after, "refused call must not touch the store");
950
951 std::fs::write(root.join("c.rs"), "cee").unwrap();
953 git(root, &["add", "-A"]);
954 git(root, &["commit", "-qm", "head2"]);
955
956 {
960 let mut engine = engine_at(root);
961 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
962 assert!(!out.completed);
963 assert_eq!(
964 out.remainder,
965 slice(&["c.rs"], &[], &["b.rs"]),
966 "re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
967 );
968 assert_eq!(out.disposed, 1, "no new disposition this call");
969 }
970
971 let head2 = head_sha(root);
973 {
974 let mut engine = engine_at(root);
975 let out = advance_baseline(
976 &mut engine,
977 root,
978 &resolved,
979 &input(&[("b.rs", "worked"), ("c.rs", "worked")]),
980 )
981 .unwrap();
982 assert!(out.completed, "every artifact disposed → complete");
983 assert_eq!(out.pending, 0);
984 assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
985
986 let token = engine
988 .mem_config_for("engine")
989 .and_then(|c| c.sync_state.get(synced_key()).cloned());
990 assert_eq!(token.as_deref(), Some(head2.as_str()));
991 }
992 assert!(
994 read_advance_store(root, "engine", "graph")
995 .unwrap()
996 .is_none()
997 );
998 }
999
1000 #[test]
1006 fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
1007 let tmp = TempDir::new().unwrap();
1008 let root = tmp.path();
1009
1010 git(root, &["init", "-q"]);
1012 std::fs::write(root.join("a.rs"), "one").unwrap();
1013 git(root, &["add", "a.rs"]);
1014 git(root, &["commit", "-qm", "base"]);
1015 let baseline = head_sha(root);
1016 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1017 git(root, &["add", "-A"]);
1018 git(root, &["commit", "-qm", "head1"]);
1019
1020 let resolved = resolved_engine_graph();
1021 {
1022 let mut engine = engine_at(root);
1023 engine
1024 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1025 .unwrap();
1026 }
1027
1028 let excluded = {
1032 let mut m = BTreeMap::new();
1033 m.insert(
1034 "a.rs".to_string(),
1035 DispositionInput::Reasoned {
1036 disposition: EXCLUDED_VERDICT.to_string(),
1037 rationale: "mined; warrants no destination entity".to_string(),
1038 },
1039 );
1040 m
1041 };
1042 {
1043 let mut engine = engine_at(root);
1044 let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
1045 assert!(out.completed, "the sole slice artifact was disposed");
1046 }
1047 let retained = read_advance_store(root, "engine", "graph")
1048 .unwrap()
1049 .expect("an authored exclusion keeps the store alive past completion");
1050 assert!(
1051 retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
1052 "transient progress is dropped on completion"
1053 );
1054 assert_eq!(
1055 retained.exclusions.get("a.rs").map(String::as_str),
1056 Some("mined; warrants no destination entity"),
1057 "the durable exclusion + its rationale persist"
1058 );
1059
1060 std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
1064 git(root, &["add", "-A"]);
1065 git(root, &["commit", "-qm", "head2"]);
1066 {
1067 let mut engine = engine_at(root);
1068 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1069 .unwrap();
1070 assert!(out.completed);
1071 }
1072 assert!(
1073 read_advance_store(root, "engine", "graph")
1074 .unwrap()
1075 .is_none(),
1076 "re-judging the artifact cleared the exclusion; nothing durable remains"
1077 );
1078 }
1079
1080 #[test]
1088 fn advance_unknown_artifact_names_dialect_and_suggests_corrected_id() {
1089 let tmp = TempDir::new().unwrap();
1090 let root = tmp.path();
1091
1092 git(root, &["init", "-q"]);
1095 std::fs::create_dir_all(root.join("sub")).unwrap();
1096 std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1097 git(root, &["add", "-A"]);
1098 git(root, &["commit", "-qm", "base"]);
1099 let baseline = head_sha(root);
1100 std::fs::write(root.join("sub").join("a.rs"), "one-longer").unwrap();
1101 git(root, &["add", "-A"]);
1102 git(root, &["commit", "-qm", "head1"]);
1103
1104 let mut resolved = resolved_engine_graph();
1105 if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1106 p.pointer = "sub".to_string();
1107 }
1108 {
1109 let mut engine = engine_at(root);
1110 engine
1111 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1112 .unwrap();
1113 }
1114
1115 {
1119 let mut engine = engine_at(root);
1120 let err = advance_baseline(
1121 &mut engine,
1122 root,
1123 &resolved,
1124 &input(&[("a.rs", "worked"), ("zzz.rs", "worked")]),
1125 )
1126 .unwrap_err();
1127 let AdvanceError::UnknownArtifact {
1128 artifacts,
1129 suggestions,
1130 ..
1131 } = &err
1132 else {
1133 panic!("expected UnknownArtifact, got {err:?}");
1134 };
1135 assert_eq!(artifacts, &vec!["a.rs".to_string(), "zzz.rs".to_string()]);
1136 assert_eq!(
1137 suggestions,
1138 &vec![("a.rs".to_string(), "sub/a.rs".to_string())],
1139 "only the medium-relative id gets a corrected form; zzz.rs has none"
1140 );
1141 let msg = err.to_string();
1142 assert!(
1143 msg.contains("workspace-relative"),
1144 "names the dialect: {msg}"
1145 );
1146 assert!(
1147 msg.contains("`a.rs` → `sub/a.rs`"),
1148 "carries the concrete corrected id: {msg}"
1149 );
1150 assert!(
1151 msg.contains("never accepted"),
1152 "states the dialect does not widen: {msg}"
1153 );
1154 }
1155 assert!(
1157 read_advance_store(root, "engine", "graph")
1158 .unwrap()
1159 .is_none(),
1160 "a refused call must not create the advance store"
1161 );
1162
1163 {
1165 let mut engine = engine_at(root);
1166 let out = advance_baseline(
1167 &mut engine,
1168 root,
1169 &resolved,
1170 &input(&[("sub/a.rs", "worked")]),
1171 )
1172 .unwrap();
1173 assert!(out.completed, "the sole slice artifact was disposed");
1174 }
1175 }
1176
1177 #[test]
1182 fn record_exclusions_gates_on_source_membership_and_merges() {
1183 let tmp = TempDir::new().unwrap();
1184 let root = tmp.path();
1185
1186 git(root, &["init", "-q"]);
1189 std::fs::write(root.join("a.rs"), "one").unwrap();
1190 std::fs::write(root.join("b.rs"), "two").unwrap();
1191 git(root, &["add", "-A"]);
1192 git(root, &["commit", "-qm", "base"]);
1193
1194 let resolved = resolved_engine_graph();
1195
1196 let out = record_exclusions(
1198 &Engine::from_mounts(Vec::new()).unwrap(),
1199 root,
1200 &resolved,
1201 &BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
1202 )
1203 .unwrap();
1204 assert_eq!((out.added, out.excluded), (1, 1));
1205 let state = read_advance_store(root, "engine", "graph")
1206 .unwrap()
1207 .unwrap();
1208 assert_eq!(
1209 state.exclusions.get("a.rs").map(String::as_str),
1210 Some("mined; no entity")
1211 );
1212
1213 let err = record_exclusions(
1215 &Engine::from_mounts(Vec::new()).unwrap(),
1216 root,
1217 &resolved,
1218 &BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
1219 )
1220 .unwrap_err();
1221 assert!(
1222 matches!(err, ExcludeError::NotSourceMember { .. }),
1223 "got {err:?}"
1224 );
1225 assert_eq!(
1226 read_advance_store(root, "engine", "graph")
1227 .unwrap()
1228 .unwrap()
1229 .exclusions
1230 .len(),
1231 1,
1232 "refused call left the ledger unchanged"
1233 );
1234
1235 let out2 = record_exclusions(
1237 &Engine::from_mounts(Vec::new()).unwrap(),
1238 root,
1239 &resolved,
1240 &BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
1241 )
1242 .unwrap();
1243 assert_eq!((out2.added, out2.excluded), (1, 2));
1244 }
1245
1246 #[test]
1249 fn disposition_input_parses_bare_and_reasoned_forms() {
1250 let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
1251 r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
1252 )
1253 .unwrap();
1254 assert_eq!(map["a.rs"].verdict(), "worked");
1255 assert_eq!(map["a.rs"].rationale(), None);
1256 assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
1257 assert_eq!(map["b.rs"].rationale(), Some("generated"));
1258 }
1259
1260 #[test]
1267 fn advance_auto_worked_matches_source_dialect_anchors() {
1268 use crate::binding::{BINDING_VERSION, Binding, Operations};
1269 use crate::vcs::Actor;
1270 use indexmap::IndexMap;
1271
1272 let tmp = TempDir::new().unwrap();
1273 let root = tmp.path();
1274
1275 git(root, &["init", "-q"]);
1276 std::fs::create_dir_all(root.join("srcdir")).unwrap();
1277 std::fs::write(root.join(".keep"), "x").unwrap();
1278 git(root, &["add", ".keep"]);
1279 git(root, &["commit", "-qm", "base"]);
1280 let baseline = head_sha(root);
1281
1282 let binding = Binding {
1285 version: BINDING_VERSION,
1286 intent: None,
1287 sources: vec![crate::pipeline::Source {
1288 name: "source-tree".to_string(),
1289 medium_type: crate::pipeline::MediumType::Codebase,
1290 pointer: "srcdir".to_string(),
1291 change_detection: Some("git".to_string()),
1292 scope: vec![PatternEntry {
1293 path: "**/*.rs".to_string(),
1294 mode: PatternMode::Allow,
1295 }],
1296 engagement: None,
1297 preparation: None,
1298 }],
1299 reference_mems: Vec::new(),
1300 destination_mem: "engine".to_string(),
1301 deny_paths: Vec::new(),
1302 coverage_semantics: None,
1303 rules: None,
1304 prune: None,
1305 operations: Operations {
1306 build: None,
1307 sync: None,
1308 verify: None,
1309 },
1310 };
1311 let dir = root.join(".memstead").join("projections").join("engine");
1312 std::fs::create_dir_all(&dir).unwrap();
1313 std::fs::write(
1314 dir.join("graph.json"),
1315 serde_json::to_string_pretty(&binding).unwrap(),
1316 )
1317 .unwrap();
1318
1319 let mut resolved = resolved_engine_graph();
1322 if let [ResolvedSource::Primary(p)] = resolved.sources.as_mut_slice() {
1323 p.pointer = "srcdir".to_string();
1324 } else {
1325 panic!("fixture shape");
1326 }
1327
1328 {
1329 let mut engine = engine_at(root);
1330 engine
1331 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1332 .unwrap();
1333 }
1334
1335 std::fs::write(root.join("srcdir").join("f.rs"), "fn f() {}").unwrap();
1336 git(root, &["add", "-A"]);
1337 git(root, &["commit", "-qm", "head1"]);
1338
1339 let mut sections = IndexMap::new();
1342 sections.insert("identity".to_string(), "Covers f.".to_string());
1343 sections.insert("purpose".to_string(), "Track f.rs.".to_string());
1344 {
1345 let mut engine = engine_at(root);
1346 engine.set_workspace_root(root.to_path_buf());
1347 engine
1348 .create_entity(
1349 crate::CreateEntityArgs {
1350 mem: "engine".to_string(),
1351 title: "Covers F".to_string(),
1352 entity_type: "spec".to_string(),
1353 sections,
1354 metadata: IndexMap::new(),
1355 relations: Vec::new(),
1356 anchors: vec![crate::anchor::AnchorInput {
1357 artifact: Some("f.rs".to_string()),
1358 grain: Some("file".to_string()),
1359 class: Some("anchored".to_string()),
1360 hash: Some("h".to_string()),
1361 hash_stability: Some("stable".to_string()),
1362 source: Some("source-tree".to_string()),
1363 ..Default::default()
1364 }],
1365 dry_run: false,
1366 },
1367 Actor::Agent,
1368 None,
1369 Some("source-dialect anchored write"),
1370 )
1371 .unwrap();
1372 }
1373
1374 let mut engine = engine_at(root);
1377 engine.set_workspace_root(root.to_path_buf());
1378 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1379 assert!(
1380 out.completed,
1381 "the source-dialect anchor auto-worked the joined slice artifact: {out:?}"
1382 );
1383 assert_eq!(out.disposed, 1);
1384 }
1385
1386 #[test]
1392 fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
1393 use crate::vcs::Actor;
1394 use indexmap::IndexMap;
1395
1396 let tmp = TempDir::new().unwrap();
1397 let root = tmp.path();
1398
1399 git(root, &["init", "-q"]);
1402 std::fs::write(root.join(".keep"), "x").unwrap();
1403 git(root, &["add", ".keep"]);
1404 git(root, &["commit", "-qm", "base"]);
1405 let baseline = head_sha(root);
1406
1407 let resolved = resolved_engine_graph();
1408 {
1409 let mut engine = engine_at(root);
1410 engine
1411 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1412 .unwrap();
1413 }
1414
1415 std::fs::write(root.join("a.rs"), "one").unwrap();
1417 std::fs::write(root.join("b.rs"), "bee").unwrap();
1418 git(root, &["add", "a.rs", "b.rs"]);
1419 git(root, &["commit", "-qm", "head1"]);
1420
1421 let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
1425 artifact: Some(artifact.to_string()),
1426 grain: Some("file".to_string()),
1427 class: Some("anchored".to_string()),
1428 hash: Some("h".to_string()),
1429 hash_stability: Some("stable".to_string()),
1430 ..Default::default()
1431 };
1432 let mut sections = IndexMap::new();
1433 sections.insert("identity".to_string(), "Covers a.".to_string());
1434 sections.insert("purpose".to_string(), "Track a.rs.".to_string());
1435 {
1436 let mut engine = engine_at(root);
1437 engine
1438 .create_entity(
1439 crate::CreateEntityArgs {
1440 mem: "engine".to_string(),
1441 title: "Covers A".to_string(),
1442 entity_type: "spec".to_string(),
1443 sections,
1444 metadata: IndexMap::new(),
1445 relations: Vec::new(),
1446 anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
1447 dry_run: false,
1448 },
1449 Actor::Agent,
1450 None,
1451 Some("anchored write"),
1452 )
1453 .unwrap();
1454 }
1455
1456 {
1460 let mut engine = engine_at(root);
1461 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1462 assert!(!out.completed, "b.rs still pending");
1463 assert_eq!(
1464 out.remainder,
1465 slice(&["b.rs"], &[], &[]),
1466 "a.rs auto-worked from its anchor; zzz.rs never became a slice member"
1467 );
1468 assert_eq!(out.disposed, 1, "only a.rs auto-worked");
1469 assert_eq!(out.pending, 1);
1470 }
1471
1472 std::fs::write(root.join("c.rs"), "cee").unwrap();
1476 git(root, &["add", "-A"]);
1477 git(root, &["commit", "-qm", "head2"]);
1478 {
1479 let mut engine = engine_at(root);
1480 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1481 assert_eq!(
1482 out.remainder,
1483 slice(&["b.rs", "c.rs"], &[], &[]),
1484 "auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
1485 );
1486 assert_eq!(
1487 out.disposed, 1,
1488 "still only a.rs auto-worked; c.rs unanchored"
1489 );
1490 assert!(!out.completed);
1491 }
1492 }
1493}