1use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::EntityId;
8use crate::entity::generator::generate_markdown;
9use crate::entity::parser::parse_markdown;
10use crate::entity::store_builder::push_entities_into_store;
11use crate::ops::{ModifiedMetadata, ModifiedSections, WarningHint};
12use crate::provenance::{Provenance, ProvenanceKind};
13use crate::runtime_validator::{
14 parse_metadata_value, validate_section_content, validate_section_keys,
15 validate_updatable_section, validate_writable_metadata_key,
16};
17use crate::vcs::{Actor, ClientId, CommitContext};
18use crate::workspace::MountCapability;
19
20use super::super::{Engine, EngineError, UpdateEntityArgs, UpdateEntityOutcome};
21use super::{
22 PATCH_OLD_NOT_FOUND_CONTENT_CAP, make_stub, today_iso, unknown_type_error,
23 validate_relation_target_grammar,
24};
25use crate::engine::outcomes::RelationDeclared;
26use crate::entity::{Entity, Relationship};
27
28use std::sync::Arc;
29
30enum PrepareOutcome {
34 Done(UpdateEntityOutcome),
37 Prepared(PreparedUpdate),
40}
41
42struct PreparedUpdate {
46 mount_idx: usize,
47 id: EntityId,
48 mem: String,
49 type_def: Arc<memstead_schema::TypeDefinition>,
50 file_path: String,
51 markdown: String,
52 prev_body_targets: std::collections::HashSet<EntityId>,
55 modified_date: String,
56 modified_sections: ModifiedSections,
57 modified_metadata: ModifiedMetadata,
58 warnings: Vec<WarningHint>,
59 relations_declared: Vec<RelationDeclared>,
60 anchors: Vec<crate::anchor::Anchor>,
64 anchor_only: bool,
75}
76
77struct AppliedWrite {
80 content_hash: String,
81 title: String,
82 orphan_stubs_removed: Vec<EntityId>,
83}
84
85impl Engine {
86 pub fn update_entity(
102 &mut self,
103 args: UpdateEntityArgs,
104 actor: Actor,
105 client: Option<&ClientId>,
106 note: Option<&str>,
107 ) -> Result<UpdateEntityOutcome, EngineError> {
108 let mut drift_warnings = self.reload_if_stale(Some(args.id.mem()));
115 let mut outcome = match self.prepare_update(args)? {
116 PrepareOutcome::Done(outcome) => outcome,
117 PrepareOutcome::Prepared(prepared) => {
118 self.commit_prepared_update(prepared, actor, client, note)?
119 }
120 };
121 drift_warnings.append(&mut outcome.warnings);
122 outcome.warnings = drift_warnings;
123 Ok(outcome)
124 }
125
126 fn commit_prepared_update(
131 &mut self,
132 prepared: PreparedUpdate,
133 actor: Actor,
134 client: Option<&ClientId>,
135 note: Option<&str>,
136 ) -> Result<UpdateEntityOutcome, EngineError> {
137 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
138 backend.write_entity(Path::new(&prepared.file_path), prepared.markdown.as_bytes())?;
139 if !prepared.anchors.is_empty() {
142 super::stage_anchors_sidecar(backend, &prepared.id, prepared.anchors.clone())?;
143 }
144 let commit_subject = if prepared.anchor_only {
150 format!("memstead: anchor {}", prepared.id)
151 } else {
152 format!("memstead: update {}", prepared.id)
153 };
154 let ctx = CommitContext {
155 actor,
156 client: client.cloned(),
157 tool: Some("update_entity"),
158 note: note.map(String::from),
159 logical_operation_id: None,
160 entity_ids: None,
161 };
162 let commit_sha = backend.commit(&commit_subject, &ctx)?;
163 backend.append_provenance(&Provenance::new(
164 std::time::SystemTime::now(),
165 ProvenanceKind::Update,
166 Some(prepared.id.to_string()),
167 actor,
168 client.cloned(),
169 note.map(String::from),
170 ))?;
171 self.record_self_write(prepared.mount_idx, &commit_sha);
172
173 let applied = self.apply_prepared_to_store(&prepared)?;
174
175 self.invalidate_communities();
176 self.invalidate_search_indexes();
177
178 let mut warnings = prepared.warnings;
182 if let Some(w) = self.note_missing_warning("update_entity", note) {
183 warnings.push(w);
184 }
185
186 Ok(UpdateEntityOutcome {
187 id: prepared.id.clone(),
188 title: applied.title,
189 file_path: prepared.file_path,
190 content_hash: applied.content_hash,
191 commit_sha,
192 modified_date: prepared.modified_date,
193 orphan_stubs_removed: applied.orphan_stubs_removed,
194 modified_sections: prepared.modified_sections,
195 modified_metadata: prepared.modified_metadata,
196 prospective_hash: None,
197 warnings,
198 relations_declared: prepared.relations_declared,
199 })
200 }
201
202 fn apply_prepared_to_store(
209 &mut self,
210 prepared: &PreparedUpdate,
211 ) -> Result<AppliedWrite, EngineError> {
212 let parse_result = parse_markdown(
213 &prepared.markdown,
214 &prepared.file_path,
215 prepared.type_def.as_ref(),
216 &prepared.mem,
217 )
218 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
219 let content_hash = parse_result.entity.content_hash.clone();
220 let title = parse_result.entity.title.clone();
221 let fallback = engine_fallback_type();
222 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
223 crate::entity::store_builder::remap_alias_target_edge_sources(
224 &mut self.store,
225 &self.schemas,
226 );
227 let orphan_stubs_removed =
228 super::gc_orphan_stubs_among(&mut self.store, &prepared.prev_body_targets);
229 Ok(AppliedWrite {
230 content_hash,
231 title,
232 orphan_stubs_removed,
233 })
234 }
235
236 fn prepare_update(&mut self, args: UpdateEntityArgs) -> Result<PrepareOutcome, EngineError> {
244 let id = &args.id;
245 let mem = id.mem().to_string();
246
247 let mount_idx = self
248 .mounts
249 .iter()
250 .position(|m| m.mount.mem == mem)
251 .ok_or_else(|| EngineError::UnknownMem(mem.clone()))?;
252 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
253 return Err(EngineError::ReadOnlyMount(mem));
254 }
255
256 let entity = self
257 .store
258 .get(id)
259 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
260
261 let prev_body_targets = super::collect_body_link_targets(entity);
267
268 if entity.stub {
276 return Err(EngineError::StubNotUpdatable { id: id.to_string() });
277 }
278
279 if !args.dry_run
285 && let Some(expected) = args.expected_hash.as_deref()
286 && entity.content_hash != expected
287 {
288 return Err(EngineError::HashMismatch {
289 id: id.to_string(),
290 current: entity.content_hash.clone(),
291 is_stub: entity.stub,
292 });
293 }
294
295 if args.sections.is_empty()
307 && args.append_sections.is_empty()
308 && args.patch_sections.is_empty()
309 && args.metadata.is_empty()
310 && args.metadata_unset.is_empty()
311 && args.declare_relations.is_empty()
312 && args.relations_unset.is_empty()
313 && args.anchors.is_empty()
314 {
315 return Err(EngineError::EmptyUpdate { id: id.to_string() });
316 }
317
318 let validated_anchors = self.validate_anchor_inputs(&mem, &args.anchors)?;
324
325 let schema = self
326 .schemas
327 .get(&mem)
328 .expect("schema present for every registered mount")
329 .clone();
330 let type_def = schema
331 .get_type(&entity.entity_type)
332 .ok_or_else(|| unknown_type_error(schema.as_ref(), &entity.entity_type))?;
333
334 for key in args.sections.keys() {
341 let mut modes = vec!["sections".to_string()];
342 if args.append_sections.contains_key(key) {
343 modes.push("append_sections".to_string());
344 }
345 if args.patch_sections.contains_key(key) {
346 modes.push("patch_sections".to_string());
347 }
348 if modes.len() > 1 {
349 return Err(EngineError::ConflictingSectionModes {
350 section: key.clone(),
351 modes,
352 });
353 }
354 }
355 for key in args.append_sections.keys() {
356 if args.patch_sections.contains_key(key) {
357 return Err(EngineError::ConflictingSectionModes {
358 section: key.clone(),
359 modes: vec!["append_sections".to_string(), "patch_sections".to_string()],
360 });
361 }
362 }
363
364 validate_section_keys(
365 args.sections
366 .keys()
367 .chain(args.append_sections.keys())
368 .chain(args.patch_sections.keys())
369 .map(String::as_str),
370 type_def.as_ref(),
371 )?;
372 validate_section_content(
378 args.sections
379 .iter()
380 .map(|(k, v)| (k.as_str(), v.as_str()))
381 .chain(
382 args.append_sections
383 .iter()
384 .map(|(k, v)| (k.as_str(), v.as_str())),
385 )
386 .chain(
387 args.patch_sections
388 .iter()
389 .map(|(k, p)| (k.as_str(), p.new.as_str())),
390 ),
391 )?;
392 for key in args.sections.keys() {
393 validate_updatable_section(key.as_str(), type_def.as_ref())?;
394 }
395 for key in args.append_sections.keys() {
396 validate_updatable_section(key.as_str(), type_def.as_ref())?;
397 }
398 for key in args.patch_sections.keys() {
399 validate_updatable_section(key.as_str(), type_def.as_ref())?;
400 }
401 for key in args.metadata.keys() {
402 validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
403 }
404 for key in &args.metadata_unset {
405 validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
406 }
407
408 let mut overlap: Vec<String> = args
415 .metadata
416 .keys()
417 .filter(|k| args.metadata_unset.iter().any(|u| u == k.as_str()))
418 .cloned()
419 .collect();
420 if !overlap.is_empty() {
421 overlap.sort();
422 overlap.dedup();
423 return Err(EngineError::SetAndUnsetConflict { keys: overlap });
424 }
425
426 if !args.relations_unset.is_empty() {
435 let findings = crate::ops::integrity::entity_conformance_findings(
436 &self.store,
437 entity,
438 schema.as_ref(),
439 &self.schemas,
440 );
441 if findings.is_empty() {
442 return Err(EngineError::RepairNotNeeded {
443 id: id.to_string(),
444 recovery: "use memstead_relate(remove=true) to detach an edge from a conformant entity, or the additive memstead_update params to evolve it"
445 .to_string(),
446 });
447 }
448 }
449
450 let mut next = entity.clone();
451
452 for unset in &args.relations_unset {
459 let canonical = crate::entity::id::validate_rel_type(&unset.rel_type)
460 .unwrap_or_else(|_| unset.rel_type.clone());
461 next.relationships
462 .retain(|r| !(r.rel_type == canonical && r.target == unset.target));
463 }
464
465 let relations_declared = apply_declare_relations(
475 self,
476 &mut next,
477 &args.declare_relations,
478 &mem,
479 mount_idx,
480 type_def.as_ref(),
481 schema.as_ref(),
482 )?;
483
484 let mut modified_sections: Vec<String> = Vec::new();
485 for (key, body) in args.sections {
486 modified_sections.push(key.clone());
487 next.sections.insert(key, body);
488 }
489
490 let mut modified_sections_appended: Vec<String> = Vec::new();
494 for (key, value) in args.append_sections {
495 let existing = next.sections.get(&key).cloned().unwrap_or_default();
496 let new_content = if existing.trim().is_empty() {
497 value
498 } else {
499 format!("{existing}\n{value}")
500 };
501 next.sections.insert(key.clone(), new_content);
502 modified_sections_appended.push(key);
503 }
504
505 let mut modified_sections_patched: Vec<String> = Vec::new();
513 for (key, patch) in args.patch_sections {
514 let existing = next
515 .sections
516 .get(&key)
517 .ok_or_else(|| EngineError::PatchSectionEmpty {
518 section: key.clone(),
519 })?
520 .clone();
521 if !existing.contains(&patch.old) {
522 let cap = PATCH_OLD_NOT_FOUND_CONTENT_CAP;
523 let truncated = existing.len() > cap;
524 let mut cut = cap.min(existing.len());
527 while cut > 0 && !existing.is_char_boundary(cut) {
528 cut -= 1;
529 }
530 let current_content = if truncated {
531 existing[..cut].to_string()
532 } else {
533 existing.clone()
534 };
535 return Err(EngineError::PatchOldNotFound {
536 section: key,
537 current_content,
538 truncated,
539 });
540 }
541 let patched = if patch.all {
542 existing.replace(&patch.old, &patch.new)
543 } else {
544 existing.replacen(&patch.old, &patch.new, 1)
545 };
546 next.sections.insert(key.clone(), patched);
547 modified_sections_patched.push(key);
548 }
549
550 let mut modified_metadata_set: Vec<String> = Vec::new();
551 for (key, value) in &args.metadata {
552 let parsed = parse_metadata_value(key.as_str(), value.as_str(), type_def.as_ref())?;
553 modified_metadata_set.push(key.clone());
554 next.metadata.insert(key.clone(), parsed);
555 }
556
557 let mut modified_metadata_unset: Vec<String> = Vec::new();
558 for key in args.metadata_unset {
559 let field_def = type_def.metadata_field(&key);
564 let is_required = field_def.map(|f| !f.optional).unwrap_or(false);
565 if is_required {
566 let (field_description, enum_values) = match field_def {
567 Some(f) => (
568 Some(f.description.clone()),
569 f.enum_values.clone().unwrap_or_default(),
570 ),
571 None => (None, Vec::new()),
572 };
573 return Err(EngineError::RequiredFieldUnset {
574 field: key,
575 entity_type: type_def.name.clone(),
576 field_description,
577 enum_values,
578 type_write_rules: type_def.write_rules.clone(),
579 on_create: false,
585 missing: Vec::new(),
590 });
591 }
592 if next.metadata.shift_remove(&key).is_some() {
593 modified_metadata_unset.push(key);
594 }
595 }
596
597 let today = today_iso();
606
607 let (synthesised_relations, self_link_ignored) =
618 super::synthesise_alias_relations(self, &prev_body_targets, &mut next)?;
619
620 let missing = super::scan_wikilinks_without_relation(&next)?;
626 if !missing.is_empty() {
627 return Err(EngineError::WikiLinkWithoutRelation {
628 from_id: id.to_string(),
629 missing: missing
630 .into_iter()
631 .map(|(section_key, target)| crate::engine::MissingWikiLink {
632 section_key,
633 target_id: target.to_string(),
634 })
635 .collect(),
636 });
637 }
638
639 let file_path = next.file_path.clone();
640
641 let markdown_pre_stamp = generate_markdown(&next, type_def.as_ref());
650
651 let content_unchanged =
662 crate::entity::parser::compute_hash(&markdown_pre_stamp) == next.content_hash;
663
664 if !args.dry_run {
679 if content_unchanged && validated_anchors.is_empty() {
683 let modified_date = next
688 .metadata
689 .get("last_modified")
690 .and_then(|v| v.as_str().map(str::to_string))
691 .unwrap_or_default();
692 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
693 id: id.clone(),
694 title: next.title.clone(),
695 file_path,
696 content_hash: next.content_hash.clone(),
697 commit_sha: String::new(),
698 modified_date,
699 modified_sections: ModifiedSections::default(),
708 modified_metadata: ModifiedMetadata::default(),
709 prospective_hash: None,
710 orphan_stubs_removed: Vec::new(),
713 warnings: vec![WarningHint::UpdateNoop { id: id.clone() }],
714 relations_declared,
715 }));
716 }
717 }
718
719 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
722 let markdown = generate_markdown(&next, type_def.as_ref());
723
724 let mut warnings: Vec<WarningHint> = Vec::new();
725
726 let auto_stubbed: Vec<EntityId> = synthesised_relations
734 .iter()
735 .filter_map(|rel| {
736 if !self.store.contains(&rel.target) {
737 Some(rel.target.clone())
738 } else {
739 None
740 }
741 })
742 .collect();
743 if !auto_stubbed.is_empty() {
744 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
745 from: id.clone(),
746 stubs: auto_stubbed,
747 });
748 }
749 if self_link_ignored {
752 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
753 }
754
755 if args.dry_run {
762 let prospective = crate::entity::parser::compute_hash(&markdown);
763 let current_hash = next.content_hash.clone();
767 let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
768 today.clone()
769 } else {
770 String::new()
771 };
772 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
773 id: id.clone(),
774 title: next.title.clone(),
775 file_path,
776 content_hash: current_hash,
777 commit_sha: String::new(),
778 modified_date,
779 modified_sections: ModifiedSections {
780 replaced: modified_sections,
781 appended: modified_sections_appended,
782 patched: modified_sections_patched,
783 },
784 modified_metadata: ModifiedMetadata {
785 set: modified_metadata_set,
786 unset: modified_metadata_unset,
787 },
788 prospective_hash: Some(prospective),
789 orphan_stubs_removed: Vec::new(),
792 warnings,
793 relations_declared: relations_declared.clone(),
794 }));
795 }
796
797 let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
804 today.clone()
805 } else {
806 String::new()
807 };
808
809 Ok(PrepareOutcome::Prepared(PreparedUpdate {
810 mount_idx,
811 id: id.clone(),
812 mem,
813 type_def,
814 file_path,
815 markdown,
816 prev_body_targets,
817 modified_date,
818 modified_sections: ModifiedSections {
819 replaced: modified_sections,
820 appended: modified_sections_appended,
821 patched: modified_sections_patched,
822 },
823 modified_metadata: ModifiedMetadata {
824 set: modified_metadata_set,
825 unset: modified_metadata_unset,
826 },
827 warnings,
830 relations_declared,
831 anchor_only: content_unchanged && !validated_anchors.is_empty(),
838 anchors: validated_anchors,
839 }))
840 }
841
842 pub fn batch_update(
875 &mut self,
876 updates: Vec<(UpdateEntityArgs, Option<String>)>,
877 actor: Actor,
878 client: Option<&ClientId>,
879 ) -> Result<crate::ops::BatchResult, EngineError> {
880 if updates.is_empty() {
881 return Ok(crate::ops::BatchResult {
882 applied: true,
883 results: Vec::new(),
884 succeeded: 0,
885 failed: 0,
886 commit_sha: String::new(),
887 });
888 }
889
890 let mut touched_mems: Vec<String> = updates
897 .iter()
898 .map(|(a, _)| a.id.mem().to_string())
899 .collect();
900 touched_mems.sort();
901 touched_mems.dedup();
902 for v in &touched_mems {
903 self.reload_if_stale(Some(v));
904 }
905
906 let store_snapshot = self.store.clone();
912
913 enum Item {
918 Prepared,
919 Noop,
920 }
921 let mut items: Vec<(EntityId, Item)> = Vec::with_capacity(updates.len());
922 let mut prepared: Vec<PreparedUpdate> = Vec::new();
923 let mut notes: Vec<Option<String>> = Vec::new();
924
925 let mut iter = updates.into_iter();
927 while let Some((args, note)) = iter.next() {
928 let id = args.id.clone();
929 match self.prepare_update(args) {
930 Ok(PrepareOutcome::Done(_)) => {
931 items.push((id, Item::Noop));
933 }
934 Ok(PrepareOutcome::Prepared(p)) => {
935 prepared.push(p);
936 notes.push(note);
937 items.push((id, Item::Prepared));
938 }
939 Err(e) => {
940 self.store = store_snapshot;
942 self.discard_all_pending();
943 let mut results: Vec<crate::ops::BatchEntry> = items
944 .into_iter()
945 .map(|(prev_id, _)| crate::ops::BatchEntry {
946 id: prev_id,
947 action: "not_applied".to_string(),
948 error: None,
949 })
950 .collect();
951 results.push(crate::ops::BatchEntry {
952 id,
953 action: "error".to_string(),
954 error: Some(batch_error_envelope(&e)),
955 });
956 for (rem_args, _) in iter {
958 results.push(crate::ops::BatchEntry {
959 id: rem_args.id,
960 action: "not_applied".to_string(),
961 error: None,
962 });
963 }
964 return Ok(crate::ops::BatchResult {
965 applied: false,
966 results,
967 succeeded: 0,
968 failed: 1,
969 commit_sha: String::new(),
970 });
971 }
972 }
973 }
974
975 for p in &prepared {
978 if let Err(e) = self.mounts[p.mount_idx]
979 .backend
980 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
981 {
982 self.store = store_snapshot;
983 self.discard_all_pending();
984 return Err(e.into());
985 }
986 if !p.anchors.is_empty()
989 && let Err(e) = super::stage_anchors_sidecar(
990 self.mounts[p.mount_idx].backend.as_ref(),
991 &p.id,
992 p.anchors.clone(),
993 )
994 {
995 self.store = store_snapshot;
996 self.discard_all_pending();
997 return Err(e);
998 }
999 }
1000
1001 let mut distinct_mounts: Vec<usize> = Vec::new();
1003 for p in &prepared {
1004 if !distinct_mounts.contains(&p.mount_idx) {
1005 distinct_mounts.push(p.mount_idx);
1006 }
1007 }
1008 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1009 for &m in &distinct_mounts {
1010 let entity_ids: Vec<String> = prepared
1011 .iter()
1012 .filter(|p| p.mount_idx == m)
1013 .map(|p| p.id.to_string())
1014 .collect();
1015 let count = entity_ids.len();
1016 let subject = format!("memstead: batch-update ({count} entities)");
1017 let ctx = CommitContext {
1018 actor,
1019 client: client.cloned(),
1020 tool: Some("batch_update"),
1021 note: None,
1022 logical_operation_id: None,
1023 entity_ids: Some(entity_ids),
1027 };
1028 match self.mounts[m].backend.commit(&subject, &ctx) {
1029 Ok(sha) => mount_commits.push((m, sha)),
1030 Err(e) => {
1031 self.store = store_snapshot;
1035 self.discard_all_pending();
1036 return Err(e.into());
1037 }
1038 }
1039 }
1040
1041 for (p, note) in prepared.iter().zip(notes.iter()) {
1045 let commit_sha = mount_commits
1046 .iter()
1047 .find(|(m, _)| *m == p.mount_idx)
1048 .map(|(_, s)| s.clone())
1049 .unwrap_or_default();
1050 self.mounts[p.mount_idx]
1051 .backend
1052 .append_provenance(&Provenance::new(
1053 std::time::SystemTime::now(),
1054 ProvenanceKind::Update,
1055 Some(p.id.to_string()),
1056 actor,
1057 client.cloned(),
1058 note.clone(),
1059 ))?;
1060 self.record_self_write(p.mount_idx, &commit_sha);
1061 self.apply_prepared_to_store(p)?;
1062 }
1063
1064 self.invalidate_communities();
1065 self.invalidate_search_indexes();
1066
1067 let commit_sha = mount_commits
1070 .last()
1071 .map(|(_, s)| s.clone())
1072 .unwrap_or_default();
1073 let succeeded = items.len();
1074 let results: Vec<crate::ops::BatchEntry> = items
1075 .into_iter()
1076 .map(|(id, item)| crate::ops::BatchEntry {
1077 id,
1078 action: match item {
1079 Item::Prepared => "updated".to_string(),
1080 Item::Noop => "noop".to_string(),
1081 },
1082 error: None,
1083 })
1084 .collect();
1085
1086 Ok(crate::ops::BatchResult {
1087 applied: true,
1088 results,
1089 succeeded,
1090 failed: 0,
1091 commit_sha,
1092 })
1093 }
1094
1095 fn discard_all_pending(&self) {
1100 for mount in &self.mounts {
1101 let _ = mount.backend.discard_pending();
1102 }
1103 }
1104
1105 pub fn update_entity_with_ctx(
1108 &mut self,
1109 args: UpdateEntityArgs,
1110 ctx: &CommitContext<'_>,
1111 ) -> Result<UpdateEntityOutcome, EngineError> {
1112 self.update_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1113 }
1114}
1115
1116fn batch_error_envelope(err: &EngineError) -> crate::ops::BatchError {
1123 let code = err.code().to_string();
1129 let message = err.to_string();
1130 let details = err.details();
1131 crate::ops::BatchError {
1132 code,
1133 message,
1134 details,
1135 }
1136}
1137
1138fn apply_declare_relations(
1153 engine: &mut Engine,
1154 next: &mut Entity,
1155 declarations: &[crate::ops::RelateArg],
1156 source_mem: &str,
1157 source_mount_idx: usize,
1158 type_def: &memstead_schema::TypeDefinition,
1159 schema: &memstead_schema::Schema,
1160) -> Result<Vec<RelationDeclared>, EngineError> {
1161 let _ = type_def; let _ = source_mount_idx; let mut declared: Vec<RelationDeclared> = Vec::with_capacity(declarations.len());
1164 for rel in declarations {
1165 let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
1168 .unwrap_or_else(|_| rel.rel_type.clone());
1169
1170 validate_relation_target_grammar(&rel.to)?;
1171
1172 let target_mem = rel.to.mem().to_string();
1173 super::validate_cross_mem_add_policy(engine, source_mem, &rel.to)?;
1176
1177 let target_type = engine
1186 .store
1187 .get(&rel.to)
1188 .map(|e| e.entity_type.clone())
1189 .filter(|t| !t.is_empty());
1190 let _ = schema; let _ = super::route_edge_validation(
1192 engine,
1193 &canonical,
1194 next.entity_type.as_str(),
1195 target_type.as_deref(),
1196 source_mem,
1197 &target_mem,
1198 &next.id,
1199 &rel.to,
1200 true,
1201 )?;
1202
1203 let normalised_description =
1208 crate::entity::normalise_description(rel.description.as_deref());
1209 super::validate_description_posture(
1210 engine,
1211 &canonical,
1212 normalised_description.as_deref(),
1213 source_mem,
1214 &target_mem,
1215 &next.id,
1216 &rel.to,
1217 )?;
1218 super::validate_manual_authoring_posture(
1221 engine, &canonical, source_mem, &next.id, &rel.to,
1222 )?;
1223
1224 let exists = next
1229 .relationships
1230 .iter()
1231 .any(|r| r.rel_type == canonical && r.target == rel.to);
1232 if !exists {
1233 next.relationships.push(Relationship {
1234 rel_type: canonical.clone(),
1235 target: rel.to.clone(),
1236 description: normalised_description,
1237 });
1238 }
1239
1240 let target_was_stubbed = !engine.store.contains(&rel.to);
1245 if target_was_stubbed && !exists {
1246 engine.store.upsert(
1247 rel.to.clone(),
1248 make_stub(&rel.to, crate::entity::StubKind::ForwardReference),
1249 );
1250 }
1251
1252 declared.push(RelationDeclared {
1253 rel_type: canonical,
1254 target: rel.to.clone(),
1255 target_was_stubbed,
1256 });
1257 }
1258 Ok(declared)
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263
1264 use indexmap::IndexMap;
1265 use tempfile::TempDir;
1266
1267 use crate::backend::MemBackend;
1268 use crate::engine::test_helpers::*;
1269 use crate::engine::{
1270 CreateEntityArgs, Engine, EngineError, RelateEntityArgs, UpdateEntityArgs,
1271 };
1272 use crate::entity::EntityId;
1273
1274 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1275 use crate::vcs::Actor;
1276
1277 #[test]
1278 fn batch_update_empty_batch_returns_zero_counts() {
1279 let tmp = TempDir::new().unwrap();
1282 let mem_dir = tmp.path().to_path_buf();
1283 let writer = FilesystemMemWriter::new(mem_dir.clone());
1284 let mut engine = Engine::from_mounts(vec![(
1285 folder_mount("specs", mem_dir),
1286 Box::new(writer) as Box<dyn MemBackend>,
1287 )])
1288 .unwrap();
1289
1290 let result = engine.batch_update(Vec::new(), Actor::Cli, None).unwrap();
1291 assert!(result.applied, "empty batch is a vacuous success");
1292 assert_eq!(result.results.len(), 0);
1293 assert_eq!(result.succeeded, 0);
1294 assert_eq!(result.failed, 0);
1295 assert_eq!(result.commit_sha, "");
1296 }
1297
1298 #[test]
1299 fn batch_update_refuses_whole_batch_when_one_item_fails() {
1300 let tmp = TempDir::new().unwrap();
1307 let mem_dir = tmp.path().to_path_buf();
1308 let writer = FilesystemMemWriter::new(mem_dir.clone());
1309 let mut engine = Engine::from_mounts(vec![(
1310 folder_mount("specs", mem_dir),
1311 Box::new(writer) as Box<dyn MemBackend>,
1312 )])
1313 .unwrap();
1314
1315 let create_args = CreateEntityArgs {
1317 anchors: Vec::new(),
1318 mem: "specs".to_string(),
1319 title: "Seed".to_string(),
1320 entity_type: "spec".to_string(),
1321 sections: IndexMap::from_iter([
1322 ("identity".to_string(), "seed identity".to_string()),
1323 ("purpose".to_string(), "seed purpose".to_string()),
1324 ]),
1325 metadata: IndexMap::new(),
1326 relations: Vec::new(),
1327 dry_run: false,
1328 };
1329 let created = engine
1330 .create_entity(create_args, Actor::Cli, None, None)
1331 .unwrap();
1332
1333 let valid_update = UpdateEntityArgs {
1335 anchors: Vec::new(),
1336 id: created.id.clone(),
1337 expected_hash: Some(created.content_hash.clone()),
1338 sections: IndexMap::from_iter([("identity".to_string(), "updated body".to_string())]),
1339 append_sections: IndexMap::new(),
1340 patch_sections: IndexMap::new(),
1341 metadata: IndexMap::new(),
1342 metadata_unset: Vec::new(),
1343 declare_relations: Vec::new(),
1344 dry_run: false,
1345 relations_unset: Vec::new(),
1346 };
1347 let missing_update = UpdateEntityArgs {
1348 anchors: Vec::new(),
1349 id: EntityId("specs--nonexistent".to_string()),
1350 expected_hash: None,
1351 sections: IndexMap::new(),
1352 append_sections: IndexMap::new(),
1353 patch_sections: IndexMap::new(),
1354 metadata: IndexMap::new(),
1355 metadata_unset: Vec::new(),
1356 declare_relations: Vec::new(),
1357 dry_run: false,
1358 relations_unset: Vec::new(),
1359 };
1360
1361 let result = engine
1362 .batch_update(
1363 vec![(valid_update, None), (missing_update, None)],
1364 Actor::Cli,
1365 None,
1366 )
1367 .unwrap();
1368 assert!(!result.applied, "a failing item must refuse the batch");
1370 assert_eq!(result.results.len(), 2);
1371 assert_eq!(result.succeeded, 0);
1372 assert_eq!(result.failed, 1);
1373 assert_eq!(result.commit_sha, "", "refused batch must not commit");
1374 assert_eq!(result.results[0].action, "not_applied");
1377 assert!(result.results[0].error.is_none());
1378 assert_eq!(result.results[1].action, "error");
1380 let err = result.results[1]
1381 .error
1382 .as_ref()
1383 .expect("failed entry must carry a structured error envelope");
1384 assert_eq!(err.code, "ENTITY_NOT_FOUND");
1385 assert!(err.message.contains("not found"), "got: {}", err.message);
1386
1387 let seed = engine.get_entity(&created.id).unwrap();
1390 assert_eq!(
1391 seed.sections.get("identity").map(String::as_str),
1392 Some("seed identity"),
1393 "refused batch must leave the in-memory store untouched",
1394 );
1395 assert_eq!(
1396 seed.content_hash, created.content_hash,
1397 "refused batch must not change the entity's content hash",
1398 );
1399 }
1400
1401 #[test]
1402 fn batch_update_applies_all_valid_items_as_one_commit() {
1403 let tmp = TempDir::new().unwrap();
1407 let mem_dir = tmp.path().to_path_buf();
1408 let writer = FilesystemMemWriter::new(mem_dir.clone());
1409 let mut engine = Engine::from_mounts(vec![(
1410 folder_mount("specs", mem_dir),
1411 Box::new(writer) as Box<dyn MemBackend>,
1412 )])
1413 .unwrap();
1414
1415 let mk = |title: &str| CreateEntityArgs {
1416 anchors: Vec::new(),
1417 mem: "specs".to_string(),
1418 title: title.to_string(),
1419 entity_type: "spec".to_string(),
1420 sections: IndexMap::from_iter([
1421 ("identity".to_string(), "id".to_string()),
1422 ("purpose".to_string(), "purp".to_string()),
1423 ]),
1424 metadata: IndexMap::new(),
1425 relations: Vec::new(),
1426 dry_run: false,
1427 };
1428 let a = engine
1429 .create_entity(mk("A"), Actor::Cli, None, None)
1430 .unwrap();
1431 let b = engine
1432 .create_entity(mk("B"), Actor::Cli, None, None)
1433 .unwrap();
1434
1435 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
1436 anchors: Vec::new(),
1437 id,
1438 expected_hash: Some(hash),
1439 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
1440 append_sections: IndexMap::new(),
1441 patch_sections: IndexMap::new(),
1442 metadata: IndexMap::new(),
1443 metadata_unset: Vec::new(),
1444 declare_relations: Vec::new(),
1445 dry_run: false,
1446 relations_unset: Vec::new(),
1447 };
1448
1449 let result = engine
1450 .batch_update(
1451 vec![
1452 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
1453 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
1454 ],
1455 Actor::Cli,
1456 None,
1457 )
1458 .unwrap();
1459 assert!(result.applied);
1460 assert_eq!(result.succeeded, 2);
1461 assert_eq!(result.failed, 0);
1462 assert!(
1463 !result.commit_sha.is_empty(),
1464 "applied batch carries the commit"
1465 );
1466 assert!(result.results.iter().all(|e| e.action == "updated"));
1467 assert_eq!(
1469 engine
1470 .get_entity(&a.id)
1471 .unwrap()
1472 .sections
1473 .get("identity")
1474 .map(String::as_str),
1475 Some("A body"),
1476 );
1477 assert_eq!(
1478 engine
1479 .get_entity(&b.id)
1480 .unwrap()
1481 .sections
1482 .get("identity")
1483 .map(String::as_str),
1484 Some("B body"),
1485 );
1486 }
1487
1488 #[test]
1489 fn batch_update_rolls_back_in_memory_store_auto_stub_on_refusal() {
1490 let tmp = TempDir::new().unwrap();
1498 let mem_dir = tmp.path().to_path_buf();
1499 let writer = FilesystemMemWriter::new(mem_dir.clone());
1500 let mut engine = Engine::from_mounts(vec![(
1501 folder_mount("specs", mem_dir.clone()),
1502 Box::new(writer) as Box<dyn MemBackend>,
1503 )])
1504 .unwrap();
1505 engine.set_workspace_root(mem_dir);
1506 let (actor, client) = cli_actor();
1507
1508 let a = engine
1509 .create_entity(
1510 empty_create_args("specs", "Anchor"),
1511 actor,
1512 Some(&client),
1513 None,
1514 )
1515 .unwrap();
1516
1517 let stub_target = EntityId::new("specs", "would-be-stub");
1518 let item1 = UpdateEntityArgs {
1519 anchors: Vec::new(),
1520 relations_unset: Vec::new(),
1521 id: a.id.clone(),
1522 expected_hash: Some(a.content_hash.clone()),
1523 sections: IndexMap::new(),
1524 append_sections: IndexMap::new(),
1525 patch_sections: IndexMap::new(),
1526 metadata: IndexMap::new(),
1527 metadata_unset: Vec::new(),
1528 declare_relations: vec![crate::ops::RelateArg {
1529 rel_type: "USES".to_string(),
1530 to: stub_target.clone(),
1531 description: None,
1532 }],
1533 dry_run: false,
1534 };
1535 let item2 = UpdateEntityArgs {
1536 anchors: Vec::new(),
1537 id: EntityId::new("specs", "nonexistent"),
1538 expected_hash: None,
1539 sections: IndexMap::from_iter([("identity".to_string(), "x".to_string())]),
1540 append_sections: IndexMap::new(),
1541 patch_sections: IndexMap::new(),
1542 metadata: IndexMap::new(),
1543 metadata_unset: Vec::new(),
1544 declare_relations: Vec::new(),
1545 dry_run: false,
1546 relations_unset: Vec::new(),
1547 };
1548
1549 assert!(engine.get_entity(&stub_target).is_none());
1551
1552 let result = engine
1553 .batch_update(vec![(item1, None), (item2, None)], actor, Some(&client))
1554 .unwrap();
1555 assert!(!result.applied, "missing item 2 must refuse the batch");
1556
1557 assert!(
1560 engine.get_entity(&stub_target).is_none(),
1561 "refused batch must roll the in-memory auto-stub back out of the store",
1562 );
1563 let anchor = engine.get_entity(&a.id).unwrap();
1565 assert!(
1566 !anchor.relationships.iter().any(|r| r.target == stub_target),
1567 "refused batch must not leave the declared relation on the anchor",
1568 );
1569 }
1570
1571 #[test]
1572 fn update_entity_replaces_a_section_and_logs_provenance() {
1573 let tmp = TempDir::new().unwrap();
1574 let (mut engine, seeded) = engine_with_seed(&tmp, "Updatable");
1575 let (actor, client) = cli_actor();
1576
1577 let mut sections = IndexMap::new();
1578 sections.insert("identity".to_string(), "Updated body.".to_string());
1579
1580 let outcome = engine
1581 .update_entity(
1582 UpdateEntityArgs {
1583 anchors: Vec::new(),
1584 id: seeded.id.clone(),
1585 expected_hash: Some(seeded.content_hash.clone()),
1586 sections,
1587 append_sections: IndexMap::new(),
1588 patch_sections: IndexMap::new(),
1589 metadata: IndexMap::new(),
1590 metadata_unset: Vec::new(),
1591 declare_relations: Vec::new(),
1592 dry_run: false,
1593 relations_unset: Vec::new(),
1594 },
1595 actor,
1596 Some(&client),
1597 Some("section update"),
1598 )
1599 .unwrap();
1600
1601 assert_eq!(
1602 outcome.modified_sections.replaced,
1603 vec!["identity".to_string()]
1604 );
1605 assert_ne!(
1606 outcome.content_hash, seeded.content_hash,
1607 "hash must change"
1608 );
1609 let entity = engine.get_entity(&seeded.id).unwrap();
1611 assert!(
1612 entity
1613 .sections
1614 .get("identity")
1615 .unwrap()
1616 .contains("Updated body.")
1617 );
1618 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
1620 assert!(log.contains("\"kind\":\"update\""));
1621 assert!(log.contains("\"note\":\"section update\""));
1622 }
1623
1624 #[test]
1625 fn update_entity_rejects_hash_mismatch() {
1626 let tmp = TempDir::new().unwrap();
1627 let (mut engine, seeded) = engine_with_seed(&tmp, "Hash Guarded");
1628 let (actor, client) = cli_actor();
1629 let err = engine
1630 .update_entity(
1631 UpdateEntityArgs {
1632 anchors: Vec::new(),
1633 id: seeded.id.clone(),
1634 expected_hash: Some("wrong-hash".to_string()),
1635 sections: IndexMap::new(),
1636 append_sections: IndexMap::new(),
1637 patch_sections: IndexMap::new(),
1638 metadata: IndexMap::new(),
1639 metadata_unset: Vec::new(),
1640 declare_relations: Vec::new(),
1641 dry_run: false,
1642 relations_unset: Vec::new(),
1643 },
1644 actor,
1645 Some(&client),
1646 None,
1647 )
1648 .unwrap_err();
1649 match err {
1650 EngineError::HashMismatch {
1651 id,
1652 current,
1653 is_stub,
1654 } => {
1655 assert_eq!(id, seeded.id.to_string());
1656 assert_eq!(current, seeded.content_hash);
1657 assert!(!is_stub, "real entity must not flag as stub");
1658 }
1659 other => panic!("expected HashMismatch, got {other:?}"),
1660 }
1661 }
1662
1663 #[test]
1664 fn update_entity_rejects_unknown_id() {
1665 let tmp = TempDir::new().unwrap();
1666 let (mut engine, _) = engine_with_seed(&tmp, "Anchor");
1667 let (actor, client) = cli_actor();
1668 let err = engine
1669 .update_entity(
1670 UpdateEntityArgs {
1671 anchors: Vec::new(),
1672 id: crate::EntityId::new("specs", "ghost"),
1673 expected_hash: None,
1674 sections: IndexMap::new(),
1675 append_sections: IndexMap::new(),
1676 patch_sections: IndexMap::new(),
1677 metadata: IndexMap::new(),
1678 metadata_unset: Vec::new(),
1679 declare_relations: Vec::new(),
1680 dry_run: false,
1681 relations_unset: Vec::new(),
1682 },
1683 actor,
1684 Some(&client),
1685 None,
1686 )
1687 .unwrap_err();
1688 assert!(matches!(err, EngineError::NotFound { .. }));
1689 }
1690
1691 #[test]
1692 fn update_entity_rejects_read_only_mount() {
1693 let tmp = TempDir::new().unwrap();
1694 let archive_path = build_archive(
1695 tmp.path(),
1696 "ext",
1697 &[(
1698 "a.md",
1699 b"---\ntype: spec\n---\n# A\n\n## Identity\n\nbody.\n",
1700 )],
1701 );
1702 let mut engine = Engine::from_mounts(vec![(
1703 archive_mount("external", archive_path.clone()),
1704 Box::new(ArchiveBackend::new(archive_path)),
1705 )])
1706 .unwrap();
1707 let (actor, client) = cli_actor();
1708 let id = crate::EntityId::new("external", "a");
1709 let err = engine
1710 .update_entity(
1711 UpdateEntityArgs {
1712 anchors: Vec::new(),
1713 id,
1714 expected_hash: None,
1715 sections: IndexMap::new(),
1716 append_sections: IndexMap::new(),
1717 patch_sections: IndexMap::new(),
1718 metadata: IndexMap::new(),
1719 metadata_unset: Vec::new(),
1720 declare_relations: Vec::new(),
1721 dry_run: false,
1722 relations_unset: Vec::new(),
1723 },
1724 actor,
1725 Some(&client),
1726 None,
1727 )
1728 .unwrap_err();
1729 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "external"));
1730 }
1731
1732 #[test]
1733 fn update_entity_patches_section_with_find_and_replace() {
1734 let tmp = TempDir::new().unwrap();
1735 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Subject");
1736 let (actor, client) = cli_actor();
1737
1738 let mut replace = IndexMap::new();
1741 replace.insert("identity".to_string(), "hello world hello".to_string());
1742 let replaced = engine
1743 .update_entity(
1744 UpdateEntityArgs {
1745 anchors: Vec::new(),
1746 id: seeded.id.clone(),
1747 expected_hash: Some(seeded.content_hash.clone()),
1748 sections: replace,
1749 append_sections: IndexMap::new(),
1750 patch_sections: IndexMap::new(),
1751 metadata: IndexMap::new(),
1752 metadata_unset: Vec::new(),
1753 declare_relations: Vec::new(),
1754 dry_run: false,
1755 relations_unset: Vec::new(),
1756 },
1757 actor,
1758 Some(&client),
1759 None,
1760 )
1761 .unwrap();
1762
1763 let mut patches = IndexMap::new();
1765 patches.insert(
1766 "identity".to_string(),
1767 crate::ops::PatchArg {
1768 old: "hello".to_string(),
1769 new: "HI".to_string(),
1770 all: false,
1771 },
1772 );
1773 let outcome = engine
1774 .update_entity(
1775 UpdateEntityArgs {
1776 anchors: Vec::new(),
1777 id: seeded.id.clone(),
1778 expected_hash: Some(replaced.content_hash.clone()),
1779 sections: IndexMap::new(),
1780 append_sections: IndexMap::new(),
1781 patch_sections: patches,
1782 metadata: IndexMap::new(),
1783 metadata_unset: Vec::new(),
1784 declare_relations: Vec::new(),
1785 dry_run: false,
1786 relations_unset: Vec::new(),
1787 },
1788 actor,
1789 Some(&client),
1790 None,
1791 )
1792 .unwrap();
1793 assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
1794 let body = engine
1795 .get_entity(&seeded.id)
1796 .unwrap()
1797 .sections
1798 .get("identity")
1799 .unwrap()
1800 .clone();
1801 assert!(body.contains("HI world hello"), "first-only: {body:?}");
1802 }
1803
1804 #[test]
1805 fn update_entity_patch_rejects_missing_old_substring() {
1806 let tmp = TempDir::new().unwrap();
1807 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Miss");
1808 let (actor, client) = cli_actor();
1809 let mut patches = IndexMap::new();
1810 patches.insert(
1811 "identity".to_string(),
1812 crate::ops::PatchArg {
1813 old: "this-substring-does-not-exist".to_string(),
1814 new: "nope".to_string(),
1815 all: false,
1816 },
1817 );
1818 let err = engine
1819 .update_entity(
1820 UpdateEntityArgs {
1821 anchors: Vec::new(),
1822 id: seeded.id.clone(),
1823 expected_hash: Some(seeded.content_hash.clone()),
1824 sections: IndexMap::new(),
1825 append_sections: IndexMap::new(),
1826 patch_sections: patches,
1827 metadata: IndexMap::new(),
1828 metadata_unset: Vec::new(),
1829 declare_relations: Vec::new(),
1830 dry_run: false,
1831 relations_unset: Vec::new(),
1832 },
1833 actor,
1834 Some(&client),
1835 None,
1836 )
1837 .unwrap_err();
1838 match err {
1839 EngineError::PatchOldNotFound { section, .. } => {
1840 assert_eq!(section, "identity");
1841 }
1842 other => panic!("expected PatchOldNotFound, got {other:?}"),
1843 }
1844 }
1845
1846 #[test]
1847 fn update_entity_appends_to_existing_section_with_newline_separator() {
1848 let tmp = TempDir::new().unwrap();
1849 let (mut engine, seeded) = engine_with_seed(&tmp, "Append Subject");
1850 let (actor, client) = cli_actor();
1851
1852 let mut appends = IndexMap::new();
1853 appends.insert("identity".to_string(), "appended tail.".to_string());
1854
1855 let outcome = engine
1856 .update_entity(
1857 UpdateEntityArgs {
1858 anchors: Vec::new(),
1859 id: seeded.id.clone(),
1860 expected_hash: Some(seeded.content_hash.clone()),
1861 sections: IndexMap::new(),
1862 append_sections: appends,
1863 patch_sections: IndexMap::new(),
1864 metadata: IndexMap::new(),
1865 metadata_unset: Vec::new(),
1866 declare_relations: Vec::new(),
1867 dry_run: false,
1868 relations_unset: Vec::new(),
1869 },
1870 actor,
1871 Some(&client),
1872 None,
1873 )
1874 .unwrap();
1875
1876 assert_eq!(outcome.modified_sections.appended, vec!["identity"]);
1879 assert!(outcome.modified_sections.replaced.is_empty());
1880
1881 let updated = engine.get_entity(&seeded.id).unwrap();
1883 let body = updated.sections.get("identity").expect("identity section");
1884 assert!(
1885 body.contains("appended tail."),
1886 "appended body missing: {body:?}"
1887 );
1888 }
1889
1890 #[test]
1897 fn update_entity_against_stub_surfaces_typed_stub_not_updatable() {
1898 let tmp = TempDir::new().unwrap();
1899 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1900 let (actor, client) = cli_actor();
1901 let stub_id = crate::EntityId::new("specs", "stub-update-target");
1904 engine
1905 .relate_entity(
1906 RelateEntityArgs {
1907 source: source.id.clone(),
1908 expected_hash: Some(source.content_hash.clone()),
1909 rel_type: "USES".to_string(),
1910 target: stub_id.clone(),
1911 remove: false,
1912 description: None,
1913 },
1914 actor,
1915 Some(&client),
1916 None,
1917 )
1918 .unwrap();
1919
1920 let err = engine
1921 .update_entity(
1922 UpdateEntityArgs {
1923 anchors: Vec::new(),
1924 id: stub_id.clone(),
1925 expected_hash: Some(String::new()),
1926 sections: IndexMap::from_iter([("identity".to_string(), "body".to_string())]),
1927 append_sections: IndexMap::new(),
1928 patch_sections: IndexMap::new(),
1929 metadata: IndexMap::new(),
1930 metadata_unset: Vec::new(),
1931 declare_relations: Vec::new(),
1932 dry_run: false,
1933 relations_unset: Vec::new(),
1934 },
1935 actor,
1936 Some(&client),
1937 None,
1938 )
1939 .unwrap_err();
1940 match err {
1941 EngineError::StubNotUpdatable { id } => assert_eq!(id, stub_id.to_string()),
1942 other => panic!("expected StubNotUpdatable, got {other:?}"),
1943 }
1944 }
1945
1946 #[test]
1947 fn update_entity_rejects_conflicting_section_modes() {
1948 let tmp = TempDir::new().unwrap();
1949 let (mut engine, seeded) = engine_with_seed(&tmp, "Conflict");
1950 let (actor, client) = cli_actor();
1951
1952 let mut sections = IndexMap::new();
1953 sections.insert("identity".to_string(), "replace".to_string());
1954 let mut appends = IndexMap::new();
1955 appends.insert("identity".to_string(), "append".to_string());
1956
1957 let err = engine
1958 .update_entity(
1959 UpdateEntityArgs {
1960 anchors: Vec::new(),
1961 id: seeded.id.clone(),
1962 expected_hash: Some(seeded.content_hash.clone()),
1963 sections,
1964 append_sections: appends,
1965 patch_sections: IndexMap::new(),
1966 metadata: IndexMap::new(),
1967 metadata_unset: Vec::new(),
1968 declare_relations: Vec::new(),
1969 dry_run: false,
1970 relations_unset: Vec::new(),
1971 },
1972 actor,
1973 Some(&client),
1974 None,
1975 )
1976 .unwrap_err();
1977
1978 match err {
1979 EngineError::ConflictingSectionModes { section, modes } => {
1980 assert_eq!(section, "identity");
1981 assert_eq!(modes, vec!["sections", "append_sections"]);
1982 }
1983 other => panic!("expected ConflictingSectionModes, got {other:?}"),
1984 }
1985 }
1986
1987 #[test]
1988 fn update_entity_rejects_overlapping_metadata_and_metadata_unset_keys() {
1989 let tmp = TempDir::new().unwrap();
1994 let (mut engine, seeded) = engine_with_seed(&tmp, "Overlap Subject");
1995 let (actor, client) = cli_actor();
1996
1997 let mut metadata = IndexMap::new();
1998 metadata.insert("tags".to_string(), "foo".to_string());
2002
2003 let err = engine
2004 .update_entity(
2005 UpdateEntityArgs {
2006 anchors: Vec::new(),
2007 id: seeded.id.clone(),
2008 expected_hash: Some(seeded.content_hash.clone()),
2009 sections: IndexMap::new(),
2010 append_sections: IndexMap::new(),
2011 patch_sections: IndexMap::new(),
2012 metadata,
2013 metadata_unset: vec!["tags".to_string()],
2014 declare_relations: Vec::new(),
2015 dry_run: false,
2016 relations_unset: Vec::new(),
2017 },
2018 actor,
2019 Some(&client),
2020 None,
2021 )
2022 .unwrap_err();
2023 match err {
2024 EngineError::SetAndUnsetConflict { keys } => {
2025 assert_eq!(keys, vec!["tags".to_string()]);
2026 }
2027 other => panic!("expected SetAndUnsetConflict, got {other:?}"),
2028 }
2029 }
2030
2031 #[test]
2032 fn update_entity_pointer_schema_auto_synthesises_references_from_body_link() {
2033 use crate::EntityId;
2041 use crate::engine::UpdateEntityArgs;
2042 use indexmap::IndexMap;
2043 use tempfile::TempDir;
2044
2045 let tmp = TempDir::new().unwrap();
2046 let mem_dir = tmp.path().to_path_buf();
2047 let writer = FilesystemMemWriter::new(mem_dir.clone());
2048 let mut engine = Engine::from_mounts(vec![(
2049 folder_mount("specs", mem_dir.clone()),
2050 Box::new(writer) as Box<dyn MemBackend>,
2051 )])
2052 .unwrap();
2053 engine.set_workspace_root(mem_dir.clone());
2054 let (actor, client) = cli_actor();
2055
2056 let target = engine
2057 .create_entity(
2058 empty_create_args("specs", "Target"),
2059 actor,
2060 Some(&client),
2061 None,
2062 )
2063 .unwrap();
2064 let source = engine
2065 .create_entity(
2066 empty_create_args("specs", "Source"),
2067 actor,
2068 Some(&client),
2069 None,
2070 )
2071 .unwrap();
2072
2073 let mut sections: IndexMap<String, String> = IndexMap::new();
2074 sections.insert(
2075 "purpose".to_string(),
2076 "see [[target]] for context".to_string(),
2077 );
2078 let outcome = engine
2079 .update_entity(
2080 UpdateEntityArgs {
2081 anchors: Vec::new(),
2082 id: source.id.clone(),
2083 expected_hash: Some(source.content_hash.clone()),
2084 sections,
2085 append_sections: IndexMap::new(),
2086 patch_sections: IndexMap::new(),
2087 metadata: IndexMap::new(),
2088 metadata_unset: Vec::new(),
2089 declare_relations: Vec::new(),
2090 dry_run: false,
2091 relations_unset: Vec::new(),
2092 },
2093 actor,
2094 Some(&client),
2095 None,
2096 )
2097 .expect("auto-synthesis must satisfy the alias-existence invariant");
2098 assert!(
2100 outcome
2101 .modified_sections
2102 .replaced
2103 .iter()
2104 .any(|s| s == "purpose"),
2105 );
2106 let in_mem = engine.get_entity(&source.id).unwrap();
2107 assert_eq!(
2108 in_mem
2109 .sections
2110 .get("purpose")
2111 .map(String::as_str)
2112 .unwrap_or(""),
2113 "see [[target]] for context",
2114 );
2115 assert!(
2117 in_mem
2118 .relationships
2119 .iter()
2120 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2121 "synthesis must emit REFERENCES → target; relationships: {:?}",
2122 in_mem.relationships,
2123 );
2124 let _ = EntityId::new("specs", "x");
2126 }
2127
2128 #[test]
2129 fn update_entity_declare_relations_passes_strict_validator_in_one_call() {
2130 use crate::engine::UpdateEntityArgs;
2137 use crate::ops::RelateArg;
2138 use indexmap::IndexMap;
2139 use tempfile::TempDir;
2140
2141 let tmp = TempDir::new().unwrap();
2142 let mem_dir = tmp.path().to_path_buf();
2143 let writer = FilesystemMemWriter::new(mem_dir.clone());
2144 let mut engine = Engine::from_mounts(vec![(
2145 folder_mount("specs", mem_dir.clone()),
2146 Box::new(writer) as Box<dyn MemBackend>,
2147 )])
2148 .unwrap();
2149 engine.set_workspace_root(mem_dir.clone());
2150 let (actor, client) = cli_actor();
2151
2152 let target = engine
2153 .create_entity(
2154 empty_create_args("specs", "Target"),
2155 actor,
2156 Some(&client),
2157 None,
2158 )
2159 .unwrap();
2160 let source = engine
2161 .create_entity(
2162 empty_create_args("specs", "Source"),
2163 actor,
2164 Some(&client),
2165 None,
2166 )
2167 .unwrap();
2168
2169 let mut sections: IndexMap<String, String> = IndexMap::new();
2177 sections.insert(
2178 "purpose".to_string(),
2179 "see [[target]] for context".to_string(),
2180 );
2181 let outcome = engine
2182 .update_entity(
2183 UpdateEntityArgs {
2184 anchors: Vec::new(),
2185 relations_unset: Vec::new(),
2186 id: source.id.clone(),
2187 expected_hash: Some(source.content_hash.clone()),
2188 sections,
2189 append_sections: IndexMap::new(),
2190 patch_sections: IndexMap::new(),
2191 metadata: IndexMap::new(),
2192 metadata_unset: Vec::new(),
2193 dry_run: false,
2194 declare_relations: vec![RelateArg {
2195 rel_type: "USES".to_string(),
2196 to: target.id.clone(),
2197 description: None,
2198 }],
2199 },
2200 actor,
2201 Some(&client),
2202 None,
2203 )
2204 .expect("declare_relations + body update must succeed in one call");
2205
2206 assert_eq!(outcome.relations_declared.len(), 1);
2207 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
2208 assert_eq!(outcome.relations_declared[0].target, target.id);
2209 assert!(
2210 !outcome.relations_declared[0].target_was_stubbed,
2211 "target was already present in store; target_was_stubbed must be false"
2212 );
2213
2214 let in_mem = engine.get_entity(&source.id).unwrap();
2215 assert!(
2216 in_mem.relationships.iter().any(|r| r.target == target.id),
2217 "declared relation must land in entity.relationships; got {:?}",
2218 in_mem.relationships
2219 );
2220 }
2221
2222 #[test]
2223 fn update_entity_declare_relations_auto_stubs_absent_target() {
2224 use crate::EntityId;
2228 use crate::engine::UpdateEntityArgs;
2229 use crate::ops::RelateArg;
2230 use indexmap::IndexMap;
2231
2232 let tmp = TempDir::new().unwrap();
2233 let (mut engine, source) = engine_with_seed(&tmp, "Source");
2234 let (actor, client) = cli_actor();
2235 let absent_target = EntityId::new("specs", "not-yet-existing");
2236 assert!(!engine.store().contains(&absent_target));
2237
2238 let outcome = engine
2239 .update_entity(
2240 UpdateEntityArgs {
2241 anchors: Vec::new(),
2242 relations_unset: Vec::new(),
2243 id: source.id.clone(),
2244 expected_hash: Some(source.content_hash.clone()),
2245 sections: IndexMap::new(),
2246 append_sections: IndexMap::new(),
2247 patch_sections: IndexMap::new(),
2248 metadata: IndexMap::new(),
2249 metadata_unset: Vec::new(),
2250 dry_run: false,
2251 declare_relations: vec![RelateArg {
2252 rel_type: "USES".to_string(),
2253 to: absent_target.clone(),
2254 description: None,
2255 }],
2256 },
2257 actor,
2258 Some(&client),
2259 None,
2260 )
2261 .unwrap();
2262
2263 assert_eq!(outcome.relations_declared.len(), 1);
2264 assert!(
2265 outcome.relations_declared[0].target_was_stubbed,
2266 "absent target must be auto-stubbed; got target_was_stubbed=false"
2267 );
2268 assert!(engine.store().contains(&absent_target));
2270 let stub = engine.get_entity(&absent_target).unwrap();
2271 assert!(stub.stub);
2272 }
2273
2274 #[test]
2275 fn update_entity_alias_synthesis_runs_unconditionally_for_pointer_schemas() {
2276 use crate::engine::UpdateEntityArgs;
2282 use indexmap::IndexMap;
2283 use tempfile::TempDir;
2284
2285 let tmp = TempDir::new().unwrap();
2286 let mem_dir = tmp.path().to_path_buf();
2287 let writer = FilesystemMemWriter::new(mem_dir.clone());
2288 let mut engine = Engine::from_mounts(vec![(
2289 folder_mount("specs", mem_dir.clone()),
2290 Box::new(writer) as Box<dyn MemBackend>,
2291 )])
2292 .unwrap();
2293 engine.set_workspace_root(mem_dir.clone());
2294 let (actor, client) = cli_actor();
2295 let target = engine
2296 .create_entity(
2297 empty_create_args("specs", "Target"),
2298 actor,
2299 Some(&client),
2300 None,
2301 )
2302 .unwrap();
2303 let source = engine
2304 .create_entity(
2305 empty_create_args("specs", "Source"),
2306 actor,
2307 Some(&client),
2308 None,
2309 )
2310 .unwrap();
2311
2312 let mut sections: IndexMap<String, String> = IndexMap::new();
2313 sections.insert(
2314 "purpose".to_string(),
2315 "see [[target]] for context".to_string(),
2316 );
2317 engine
2318 .update_entity(
2319 UpdateEntityArgs {
2320 anchors: Vec::new(),
2321 id: source.id.clone(),
2322 expected_hash: Some(source.content_hash.clone()),
2323 sections,
2324 append_sections: IndexMap::new(),
2325 patch_sections: IndexMap::new(),
2326 metadata: IndexMap::new(),
2327 metadata_unset: Vec::new(),
2328 declare_relations: Vec::new(),
2329 dry_run: false,
2330 relations_unset: Vec::new(),
2331 },
2332 actor,
2333 Some(&client),
2334 None,
2335 )
2336 .expect("synthesis must back the wiki-link and let the body land");
2337 let in_mem = engine.get_entity(&source.id).unwrap();
2338 assert!(
2339 in_mem
2340 .relationships
2341 .iter()
2342 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2343 "synthesis must emit REFERENCES → target; relationships: {:?}",
2344 in_mem.relationships,
2345 );
2346 }
2347
2348 #[test]
2349 fn update_entity_dry_run_returns_prospective_hash_without_writing() {
2350 let tmp = TempDir::new().unwrap();
2351 let (mut engine, seeded) = engine_with_seed(&tmp, "Preview Subject");
2352 let (actor, client) = cli_actor();
2353 let original_hash = seeded.content_hash.clone();
2354
2355 let mut sections = IndexMap::new();
2356 sections.insert("identity".to_string(), "preview body".to_string());
2357
2358 let outcome = engine
2359 .update_entity(
2360 UpdateEntityArgs {
2361 anchors: Vec::new(),
2362 id: seeded.id.clone(),
2363 expected_hash: Some("wrong-hash".to_string()),
2366 sections,
2367 append_sections: IndexMap::new(),
2368 patch_sections: IndexMap::new(),
2369 metadata: IndexMap::new(),
2370 metadata_unset: Vec::new(),
2371 declare_relations: Vec::new(),
2372 dry_run: true,
2373 relations_unset: Vec::new(),
2374 },
2375 actor,
2376 Some(&client),
2377 None,
2378 )
2379 .unwrap();
2380
2381 assert_eq!(outcome.content_hash, original_hash);
2384 let prospective = outcome
2385 .prospective_hash
2386 .expect("prospective_hash populated on dry_run");
2387 assert_ne!(prospective, original_hash);
2388 assert!(outcome.commit_sha.is_empty());
2389 let store_entity = engine.get_entity(&seeded.id).unwrap();
2391 assert_eq!(store_entity.content_hash, original_hash);
2392 }
2393
2394 #[test]
2413 fn references_edges_round_trip_across_full_crud_cycle() {
2414 let tmp = TempDir::new().unwrap();
2415 let mem_dir = tmp.path().to_path_buf();
2416 let writer = FilesystemMemWriter::new(mem_dir.clone());
2417 let mut engine = Engine::from_mounts(vec![(
2418 folder_mount("specs", mem_dir),
2419 Box::new(writer) as Box<dyn MemBackend>,
2420 )])
2421 .unwrap();
2422 let (actor, client) = cli_actor();
2423
2424 let foo = engine
2428 .create_entity(
2429 empty_create_args("specs", "Foo"),
2430 actor,
2431 Some(&client),
2432 None,
2433 )
2434 .unwrap();
2435 let bar = engine
2436 .create_entity(
2437 empty_create_args("specs", "Bar"),
2438 actor,
2439 Some(&client),
2440 None,
2441 )
2442 .unwrap();
2443
2444 let count_references = |engine: &Engine| -> usize {
2445 engine
2446 .store()
2447 .all_ids()
2448 .flat_map(|id| engine.store().outgoing(id))
2449 .filter(|e| e.rel_type == "REFERENCES")
2450 .count()
2451 };
2452
2453 let baseline_edges = engine.store().edge_count();
2454 let baseline_refs = count_references(&engine);
2455
2456 let mut sections = IndexMap::new();
2462 sections.insert(
2463 "identity".to_string(),
2464 "See [[foo]] and [[bar]] inline.".to_string(),
2465 );
2466 sections.insert("purpose".to_string(), "probe purpose".to_string());
2467 let probe = engine
2468 .create_entity(
2469 CreateEntityArgs {
2470 anchors: Vec::new(),
2471 mem: "specs".to_string(),
2472 title: "Probe".to_string(),
2473 entity_type: "spec".to_string(),
2474 sections,
2475 metadata: IndexMap::new(),
2476 relations: Vec::new(),
2477 dry_run: false,
2478 },
2479 actor,
2480 Some(&client),
2481 None,
2482 )
2483 .unwrap();
2484 assert_eq!(count_references(&engine), baseline_refs + 2);
2485
2486 let relate1 = engine
2491 .relate_entity(
2492 RelateEntityArgs {
2493 source: probe.id.clone(),
2494 expected_hash: Some(probe.content_hash.clone()),
2495 rel_type: "INFORMED_BY".to_string(),
2496 target: foo.id.clone(),
2497 remove: false,
2498 description: None,
2499 },
2500 actor,
2501 Some(&client),
2502 None,
2503 )
2504 .unwrap();
2505 assert_eq!(
2506 count_references(&engine),
2507 baseline_refs + 2,
2508 "set-membership aliasing — adding INFORMED_BY does not \
2509 absorb the REFERENCES relation"
2510 );
2511
2512 let mut sections = IndexMap::new();
2516 sections.insert("identity".to_string(), "See [[foo]] inline.".to_string());
2517 let updated = engine
2518 .update_entity(
2519 UpdateEntityArgs {
2520 anchors: Vec::new(),
2521 id: probe.id.clone(),
2522 expected_hash: Some(relate1.content_hash.clone()),
2523 sections,
2524 append_sections: IndexMap::new(),
2525 patch_sections: IndexMap::new(),
2526 metadata: IndexMap::new(),
2527 metadata_unset: Vec::new(),
2528 declare_relations: Vec::new(),
2529 dry_run: false,
2530 relations_unset: Vec::new(),
2531 },
2532 actor,
2533 Some(&client),
2534 None,
2535 )
2536 .unwrap();
2537 assert_eq!(
2538 count_references(&engine),
2539 baseline_refs + 1,
2540 "REFERENCES → bar must be auto-GC'd when its body link drops"
2541 );
2542
2543 let renamed = engine
2545 .rename_entity(
2546 crate::engine::RenameEntityArgs {
2547 id: probe.id.clone(),
2548 expected_hash: Some(updated.content_hash.clone()),
2549 new_title: "Probe Renamed".to_string(),
2550 },
2551 actor,
2552 Some(&client),
2553 None,
2554 )
2555 .unwrap();
2556 assert_eq!(count_references(&engine), baseline_refs + 1);
2557
2558 engine
2561 .delete_entity(
2562 crate::engine::DeleteEntityArgs {
2563 id: renamed.new_id.clone(),
2564 expected_hash: Some(renamed.content_hash.clone()),
2565 },
2566 actor,
2567 Some(&client),
2568 None,
2569 )
2570 .unwrap();
2571
2572 assert_eq!(
2574 engine.store().edge_count(),
2575 baseline_edges,
2576 "total edges must round-trip to baseline"
2577 );
2578 assert_eq!(
2579 count_references(&engine),
2580 baseline_refs,
2581 "REFERENCES counter must round-trip to baseline"
2582 );
2583
2584 engine.reload_one_mem("specs").unwrap();
2588 assert_eq!(
2589 engine.store().edge_count(),
2590 baseline_edges,
2591 "total edges must match disk after reload"
2592 );
2593 assert_eq!(
2594 count_references(&engine),
2595 baseline_refs,
2596 "REFERENCES must match disk after reload"
2597 );
2598 assert!(engine.store().contains(&foo.id));
2600 assert!(engine.store().contains(&bar.id));
2601 }
2602
2603 #[test]
2604 fn update_entity_returns_commit_sha_title_modified_date_warnings_shape() {
2605 let tmp = TempDir::new().unwrap();
2606 let (mut engine, seeded) = engine_with_seed(&tmp, "Subject");
2607 let (actor, client) = cli_actor();
2608
2609 let mut sections = IndexMap::new();
2610 sections.insert("identity".to_string(), "edited body".to_string());
2611
2612 let outcome = engine
2613 .update_entity(
2614 UpdateEntityArgs {
2615 anchors: Vec::new(),
2616 id: seeded.id.clone(),
2617 expected_hash: Some(seeded.content_hash.clone()),
2618 sections,
2619 append_sections: IndexMap::new(),
2620 patch_sections: IndexMap::new(),
2621 metadata: IndexMap::new(),
2622 metadata_unset: Vec::new(),
2623 declare_relations: Vec::new(),
2624 dry_run: false,
2625 relations_unset: Vec::new(),
2626 },
2627 actor,
2628 Some(&client),
2629 None,
2630 )
2631 .unwrap();
2632
2633 assert!(
2635 !outcome.commit_sha.is_empty(),
2636 "commit_sha must be populated on a real update"
2637 );
2638 assert_eq!(outcome.title, "Subject");
2640 assert!(
2645 !outcome.modified_date.is_empty(),
2646 "modified_date must be auto-stamped on update for the default spec schema",
2647 );
2648 assert!(outcome.warnings.is_empty());
2652 assert_eq!(
2654 outcome.modified_sections.replaced,
2655 vec!["identity".to_string()]
2656 );
2657 }
2658
2659 #[test]
2668 fn update_entity_noop_resetting_section_to_current_value_preserves_last_modified() {
2669 let tmp = TempDir::new().unwrap();
2670 let (mut engine, seeded) = engine_with_seed(&tmp, "Section Resetter");
2671 let (actor, client) = cli_actor();
2672
2673 let pre_last_modified = engine
2676 .get_entity(&seeded.id)
2677 .and_then(|e| e.metadata.get("last_modified"))
2678 .map(|v| v.to_frontmatter_string())
2679 .expect("seeded entity has last_modified");
2680
2681 let mut sections = IndexMap::new();
2685 sections.insert("identity".to_string(), "fixture identity body".to_string());
2686 let outcome = engine
2687 .update_entity(
2688 UpdateEntityArgs {
2689 anchors: Vec::new(),
2690 id: seeded.id.clone(),
2691 expected_hash: Some(seeded.content_hash.clone()),
2692 sections,
2693 append_sections: IndexMap::new(),
2694 patch_sections: IndexMap::new(),
2695 metadata: IndexMap::new(),
2696 metadata_unset: Vec::new(),
2697 declare_relations: Vec::new(),
2698 dry_run: false,
2699 relations_unset: Vec::new(),
2700 },
2701 actor,
2702 Some(&client),
2703 None,
2704 )
2705 .unwrap();
2706
2707 assert_eq!(outcome.commit_sha, "", "no-op must not commit");
2708 assert_eq!(
2709 outcome.content_hash, seeded.content_hash,
2710 "no-op must not advance content_hash",
2711 );
2712 assert!(
2713 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2714 "UPDATE_NOOP must fire on bytes-identical re-set",
2715 );
2716 assert_eq!(
2717 outcome.modified_date, pre_last_modified,
2718 "no-op must preserve last_modified at the pre-call value",
2719 );
2720 assert!(
2725 outcome.modified_sections.replaced.is_empty()
2726 && outcome.modified_sections.appended.is_empty()
2727 && outcome.modified_sections.patched.is_empty(),
2728 "no-op must report an empty section delta, got {:?}",
2729 outcome.modified_sections,
2730 );
2731
2732 let post_last_modified = engine
2736 .get_entity(&seeded.id)
2737 .and_then(|e| e.metadata.get("last_modified"))
2738 .map(|v| v.to_frontmatter_string())
2739 .expect("entity still in store");
2740 assert_eq!(post_last_modified, pre_last_modified);
2741 }
2742
2743 #[test]
2755 fn update_entity_empty_payload_refuses_with_typed_code() {
2756 let tmp = TempDir::new().unwrap();
2757 let (mut engine, seeded) = engine_with_seed(&tmp, "Empty Payload");
2758 let (actor, client) = cli_actor();
2759
2760 let err = engine
2761 .update_entity(
2762 UpdateEntityArgs {
2763 anchors: Vec::new(),
2764 id: seeded.id.clone(),
2765 expected_hash: Some(seeded.content_hash.clone()),
2766 sections: IndexMap::new(),
2767 append_sections: IndexMap::new(),
2768 patch_sections: IndexMap::new(),
2769 metadata: IndexMap::new(),
2770 metadata_unset: Vec::new(),
2771 declare_relations: Vec::new(),
2772 dry_run: false,
2773 relations_unset: Vec::new(),
2774 },
2775 actor,
2776 Some(&client),
2777 None,
2778 )
2779 .unwrap_err();
2780 match err {
2781 EngineError::EmptyUpdate { id } => {
2782 assert_eq!(id, seeded.id.to_string());
2783 }
2784 other => panic!("expected EMPTY_UPDATE, got {other:?}"),
2785 }
2786 let log_path = tmp.path().join(".memstead/changes.jsonl");
2788 if let Ok(log) = std::fs::read_to_string(&log_path) {
2789 let updates = log.matches("\"kind\":\"update\"").count();
2790 assert_eq!(updates, 0, "EMPTY_UPDATE refusal must not log an update");
2791 }
2792 }
2793
2794 #[test]
2800 fn update_entity_noop_same_content_surfaces_warning() {
2801 let tmp = TempDir::new().unwrap();
2802 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Content Noop");
2803 let (actor, client) = cli_actor();
2804
2805 let mut sections = IndexMap::new();
2807 sections.insert("identity".to_string(), "fixture identity body".to_string());
2808
2809 let outcome = engine
2810 .update_entity(
2811 UpdateEntityArgs {
2812 anchors: Vec::new(),
2813 id: seeded.id.clone(),
2814 expected_hash: Some(seeded.content_hash.clone()),
2815 sections,
2816 append_sections: IndexMap::new(),
2817 patch_sections: IndexMap::new(),
2818 metadata: IndexMap::new(),
2819 metadata_unset: Vec::new(),
2820 declare_relations: Vec::new(),
2821 dry_run: false,
2822 relations_unset: Vec::new(),
2823 },
2824 actor,
2825 Some(&client),
2826 None,
2827 )
2828 .unwrap();
2829
2830 assert_eq!(outcome.commit_sha, "");
2831 assert_eq!(outcome.content_hash, seeded.content_hash);
2832 let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code()).collect();
2833 assert!(
2834 codes.contains(&"UPDATE_NOOP"),
2835 "same-content update must surface UPDATE_NOOP; got {codes:?}",
2836 );
2837 }
2838
2839 #[test]
2840 fn update_entity_noop_metadata_unset_on_absent_key() {
2841 let tmp = TempDir::new().unwrap();
2846 let (mut engine, seeded) = engine_with_seed(&tmp, "Absent Key Noop");
2847 let (actor, client) = cli_actor();
2848
2849 let outcome = engine
2850 .update_entity(
2851 UpdateEntityArgs {
2852 anchors: Vec::new(),
2853 id: seeded.id.clone(),
2854 expected_hash: Some(seeded.content_hash.clone()),
2855 sections: IndexMap::new(),
2856 append_sections: IndexMap::new(),
2857 patch_sections: IndexMap::new(),
2858 metadata: IndexMap::new(),
2859 metadata_unset: vec!["tags".to_string()],
2863 declare_relations: Vec::new(),
2864 dry_run: false,
2865 relations_unset: Vec::new(),
2866 },
2867 actor,
2868 Some(&client),
2869 None,
2870 )
2871 .unwrap();
2872
2873 assert_eq!(outcome.commit_sha, "");
2874 assert_eq!(outcome.content_hash, seeded.content_hash);
2875 assert!(
2876 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2877 "absent-key metadata_unset must surface UPDATE_NOOP",
2878 );
2879 assert!(
2882 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
2883 "no-op must report an empty metadata delta, got {:?}",
2884 outcome.modified_metadata,
2885 );
2886
2887 let mut sections = IndexMap::new();
2890 sections.insert("identity".to_string(), "real change".to_string());
2891 let real = engine
2892 .update_entity(
2893 UpdateEntityArgs {
2894 anchors: Vec::new(),
2895 id: seeded.id.clone(),
2896 expected_hash: Some(seeded.content_hash.clone()),
2897 sections,
2898 append_sections: IndexMap::new(),
2899 patch_sections: IndexMap::new(),
2900 metadata: IndexMap::new(),
2901 metadata_unset: Vec::new(),
2902 declare_relations: Vec::new(),
2903 dry_run: false,
2904 relations_unset: Vec::new(),
2905 },
2906 actor,
2907 Some(&client),
2908 None,
2909 )
2910 .unwrap();
2911 assert!(!real.commit_sha.is_empty());
2912 assert_ne!(real.content_hash, seeded.content_hash);
2913 }
2914
2915 #[test]
2922 fn update_entity_noop_setting_metadata_to_current_value_reports_empty_delta() {
2923 let tmp = TempDir::new().unwrap();
2924 let (mut engine, seeded) = engine_with_seed(&tmp, "Stability Resetter");
2925 let (actor, client) = cli_actor();
2926
2927 let mut metadata = IndexMap::new();
2930 metadata.insert("level".to_string(), "M0".to_string());
2931 let outcome = engine
2932 .update_entity(
2933 UpdateEntityArgs {
2934 anchors: Vec::new(),
2935 id: seeded.id.clone(),
2936 expected_hash: Some(seeded.content_hash.clone()),
2937 sections: IndexMap::new(),
2938 append_sections: IndexMap::new(),
2939 patch_sections: IndexMap::new(),
2940 metadata,
2941 metadata_unset: Vec::new(),
2942 declare_relations: Vec::new(),
2943 dry_run: false,
2944 relations_unset: Vec::new(),
2945 },
2946 actor,
2947 Some(&client),
2948 None,
2949 )
2950 .unwrap();
2951
2952 assert_eq!(outcome.commit_sha, "", "no-op must not commit");
2953 assert_eq!(
2954 outcome.content_hash, seeded.content_hash,
2955 "no-op must not advance hash"
2956 );
2957 assert!(
2958 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2959 "re-set to current value must surface UPDATE_NOOP",
2960 );
2961 assert!(
2962 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
2963 "no-op must not claim `level` was set — applied delta is empty, got {:?}",
2964 outcome.modified_metadata,
2965 );
2966 }
2967
2968 #[test]
2969 fn update_entity_noop_declare_already_related_edge() {
2970 use crate::ops::RelateArg;
2975 let tmp = TempDir::new().unwrap();
2976 let mem_dir = tmp.path().to_path_buf();
2977 let writer = FilesystemMemWriter::new(mem_dir.clone());
2978 let mut engine = Engine::from_mounts(vec![(
2979 folder_mount("specs", mem_dir),
2980 Box::new(writer) as Box<dyn MemBackend>,
2981 )])
2982 .unwrap();
2983 let (actor, client) = cli_actor();
2984 let target = engine
2985 .create_entity(
2986 empty_create_args("specs", "Target Already Related"),
2987 actor,
2988 Some(&client),
2989 None,
2990 )
2991 .unwrap();
2992 let source = engine
2993 .create_entity(
2994 empty_create_args("specs", "Source Already Related"),
2995 actor,
2996 Some(&client),
2997 None,
2998 )
2999 .unwrap();
3000 let after_relate = engine
3001 .relate_entity(
3002 RelateEntityArgs {
3003 source: source.id.clone(),
3004 expected_hash: Some(source.content_hash.clone()),
3005 rel_type: "USES".to_string(),
3006 target: target.id.clone(),
3007 remove: false,
3008 description: None,
3009 },
3010 actor,
3011 Some(&client),
3012 None,
3013 )
3014 .unwrap();
3015 let outcome = engine
3017 .update_entity(
3018 UpdateEntityArgs {
3019 anchors: Vec::new(),
3020 relations_unset: Vec::new(),
3021 id: source.id.clone(),
3022 expected_hash: Some(after_relate.content_hash.clone()),
3023 sections: IndexMap::new(),
3024 append_sections: IndexMap::new(),
3025 patch_sections: IndexMap::new(),
3026 metadata: IndexMap::new(),
3027 metadata_unset: Vec::new(),
3028 declare_relations: vec![RelateArg {
3029 rel_type: "USES".to_string(),
3030 to: target.id.clone(),
3031 description: None,
3032 }],
3033 dry_run: false,
3034 },
3035 actor,
3036 Some(&client),
3037 None,
3038 )
3039 .unwrap();
3040
3041 assert_eq!(outcome.commit_sha, "");
3042 assert_eq!(outcome.content_hash, after_relate.content_hash);
3043 assert!(
3044 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3045 "duplicate declare must surface UPDATE_NOOP",
3046 );
3047 assert_eq!(outcome.relations_declared.len(), 1);
3050 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
3051 assert_eq!(outcome.relations_declared[0].target, target.id);
3052 assert!(!outcome.relations_declared[0].target_was_stubbed);
3053 }
3054
3055 #[test]
3056 fn update_entity_real_change_still_commits_and_advances_hash() {
3057 let tmp = TempDir::new().unwrap();
3062 let (mut engine, seeded) = engine_with_seed(&tmp, "Real Change Subject");
3063 let (actor, client) = cli_actor();
3064
3065 let mut sections = IndexMap::new();
3066 sections.insert("identity".to_string(), "definitely new body".to_string());
3067
3068 let outcome = engine
3069 .update_entity(
3070 UpdateEntityArgs {
3071 anchors: Vec::new(),
3072 id: seeded.id.clone(),
3073 expected_hash: Some(seeded.content_hash.clone()),
3074 sections,
3075 append_sections: IndexMap::new(),
3076 patch_sections: IndexMap::new(),
3077 metadata: IndexMap::new(),
3078 metadata_unset: Vec::new(),
3079 declare_relations: Vec::new(),
3080 dry_run: false,
3081 relations_unset: Vec::new(),
3082 },
3083 actor,
3084 Some(&client),
3085 None,
3086 )
3087 .unwrap();
3088
3089 assert!(!outcome.commit_sha.is_empty(), "real change must commit");
3090 assert_ne!(
3091 outcome.content_hash, seeded.content_hash,
3092 "real change must advance content_hash",
3093 );
3094 assert!(
3095 !outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3096 "real change must not surface UPDATE_NOOP",
3097 );
3098 }
3099
3100 #[test]
3101 fn update_entity_noop_preserves_expected_hash_across_chain() {
3102 let tmp = TempDir::new().unwrap();
3107 let (mut engine, seeded) = engine_with_seed(&tmp, "Chained Noops Subject");
3108 let (actor, client) = cli_actor();
3109
3110 let mut noop_sections = IndexMap::new();
3115 noop_sections.insert("identity".to_string(), "fixture identity body".to_string());
3116 for _ in 0..2 {
3117 let outcome = engine
3118 .update_entity(
3119 UpdateEntityArgs {
3120 anchors: Vec::new(),
3121 id: seeded.id.clone(),
3122 expected_hash: Some(seeded.content_hash.clone()),
3123 sections: noop_sections.clone(),
3124 append_sections: IndexMap::new(),
3125 patch_sections: IndexMap::new(),
3126 metadata: IndexMap::new(),
3127 metadata_unset: Vec::new(),
3128 declare_relations: Vec::new(),
3129 dry_run: false,
3130 relations_unset: Vec::new(),
3131 },
3132 actor,
3133 Some(&client),
3134 None,
3135 )
3136 .unwrap();
3137 assert_eq!(outcome.commit_sha, "");
3138 assert_eq!(outcome.content_hash, seeded.content_hash);
3139 }
3140
3141 let mut sections = IndexMap::new();
3144 sections.insert(
3145 "identity".to_string(),
3146 "third call: real change".to_string(),
3147 );
3148 let real = engine
3149 .update_entity(
3150 UpdateEntityArgs {
3151 anchors: Vec::new(),
3152 id: seeded.id.clone(),
3153 expected_hash: Some(seeded.content_hash.clone()),
3154 sections,
3155 append_sections: IndexMap::new(),
3156 patch_sections: IndexMap::new(),
3157 metadata: IndexMap::new(),
3158 metadata_unset: Vec::new(),
3159 declare_relations: Vec::new(),
3160 dry_run: false,
3161 relations_unset: Vec::new(),
3162 },
3163 actor,
3164 Some(&client),
3165 None,
3166 )
3167 .unwrap();
3168 assert!(!real.commit_sha.is_empty());
3169 assert_ne!(real.content_hash, seeded.content_hash);
3170 }
3171
3172 #[test]
3181 fn synthesis_gc_drops_auto_emitted_reference_when_body_link_removed() {
3182 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3186 use indexmap::IndexMap;
3187 use tempfile::TempDir;
3188
3189 let tmp = TempDir::new().unwrap();
3190 let mem_dir = tmp.path().to_path_buf();
3191 let writer = FilesystemMemWriter::new(mem_dir.clone());
3192 let mut engine = Engine::from_mounts(vec![(
3193 folder_mount("specs", mem_dir.clone()),
3194 Box::new(writer) as Box<dyn MemBackend>,
3195 )])
3196 .unwrap();
3197 engine.set_workspace_root(mem_dir.clone());
3198 let (actor, client) = cli_actor();
3199
3200 let target = engine
3201 .create_entity(
3202 empty_create_args("specs", "Target"),
3203 actor,
3204 Some(&client),
3205 None,
3206 )
3207 .unwrap();
3208 let mut sections: IndexMap<String, String> = IndexMap::new();
3211 sections.insert("identity".to_string(), "source identity".to_string());
3212 sections.insert(
3213 "purpose".to_string(),
3214 "see [[target]] for context".to_string(),
3215 );
3216 let source = engine
3217 .create_entity(
3218 CreateEntityArgs {
3219 anchors: Vec::new(),
3220 mem: "specs".to_string(),
3221 title: "Source".to_string(),
3222 entity_type: "spec".to_string(),
3223 sections,
3224 metadata: IndexMap::new(),
3225 relations: Vec::new(),
3226 dry_run: false,
3227 },
3228 actor,
3229 Some(&client),
3230 None,
3231 )
3232 .unwrap();
3233 assert!(
3234 engine
3235 .get_entity(&source.id)
3236 .unwrap()
3237 .relationships
3238 .iter()
3239 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3240 "create-time synthesis must emit REFERENCES → target",
3241 );
3242
3243 let mut new_sections: IndexMap<String, String> = IndexMap::new();
3246 new_sections.insert("purpose".to_string(), "no link any more".to_string());
3247 engine
3248 .update_entity(
3249 UpdateEntityArgs {
3250 anchors: Vec::new(),
3251 id: source.id.clone(),
3252 expected_hash: Some(source.content_hash.clone()),
3253 sections: new_sections,
3254 append_sections: IndexMap::new(),
3255 patch_sections: IndexMap::new(),
3256 metadata: IndexMap::new(),
3257 metadata_unset: Vec::new(),
3258 declare_relations: Vec::new(),
3259 dry_run: false,
3260 relations_unset: Vec::new(),
3261 },
3262 actor,
3263 Some(&client),
3264 None,
3265 )
3266 .expect("update must succeed; GC drops the now-orphan REFERENCES");
3267 let in_mem = engine.get_entity(&source.id).unwrap();
3268 assert!(
3269 !in_mem
3270 .relationships
3271 .iter()
3272 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3273 "GC must drop the auto-emitted REFERENCES after body link removal; got {:?}",
3274 in_mem.relationships,
3275 );
3276 }
3277
3278 #[test]
3279 fn update_gc_removes_orphan_stub_when_last_body_link_dropped() {
3280 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3288 use indexmap::IndexMap;
3289 use tempfile::TempDir;
3290
3291 let tmp = TempDir::new().unwrap();
3292 let mem_dir = tmp.path().to_path_buf();
3293 let writer = FilesystemMemWriter::new(mem_dir.clone());
3294 let mut engine = Engine::from_mounts(vec![(
3295 folder_mount("specs", mem_dir.clone()),
3296 Box::new(writer) as Box<dyn MemBackend>,
3297 )])
3298 .unwrap();
3299 engine.set_workspace_root(mem_dir.clone());
3300 let (actor, client) = cli_actor();
3301
3302 let ghost = crate::EntityId::new("specs", "ghost");
3303 let mut sections: IndexMap<String, String> = IndexMap::new();
3304 sections.insert("identity".to_string(), "source identity".to_string());
3305 sections.insert(
3306 "purpose".to_string(),
3307 "see [[ghost]] for context".to_string(),
3308 );
3309 let source = engine
3310 .create_entity(
3311 CreateEntityArgs {
3312 anchors: Vec::new(),
3313 mem: "specs".to_string(),
3314 title: "Source".to_string(),
3315 entity_type: "spec".to_string(),
3316 sections,
3317 metadata: IndexMap::new(),
3318 relations: Vec::new(),
3319 dry_run: false,
3320 },
3321 actor,
3322 Some(&client),
3323 None,
3324 )
3325 .unwrap();
3326 assert!(
3327 engine.store().contains(&ghost) && engine.get_entity(&ghost).unwrap().stub,
3328 "body wiki-link to an absent target must auto-stub it",
3329 );
3330 assert_eq!(
3331 engine.health().stub_count,
3332 1,
3333 "one stub before the link drop"
3334 );
3335
3336 let mut new_sections: IndexMap<String, String> = IndexMap::new();
3337 new_sections.insert("purpose".to_string(), "no link any more".to_string());
3338 let outcome = engine
3339 .update_entity(
3340 UpdateEntityArgs {
3341 anchors: Vec::new(),
3342 id: source.id.clone(),
3343 expected_hash: Some(source.content_hash.clone()),
3344 sections: new_sections,
3345 append_sections: IndexMap::new(),
3346 patch_sections: IndexMap::new(),
3347 metadata: IndexMap::new(),
3348 metadata_unset: Vec::new(),
3349 declare_relations: Vec::new(),
3350 dry_run: false,
3351 relations_unset: Vec::new(),
3352 },
3353 actor,
3354 Some(&client),
3355 None,
3356 )
3357 .expect("update must succeed and GC the now-orphan stub");
3358
3359 assert_eq!(
3360 outcome.orphan_stubs_removed,
3361 vec![ghost.clone()],
3362 "the update that dropped the last body link must report the GC'd stub",
3363 );
3364 assert!(
3365 !engine.store().contains(&ghost),
3366 "orphan stub must be gone from the in-memory store",
3367 );
3368 assert_eq!(
3369 engine.health().stub_count,
3370 0,
3371 "stub count decremented in-session"
3372 );
3373
3374 engine.reload_each_writable_mem().unwrap();
3378 assert!(
3379 !engine.store().contains(&ghost),
3380 "stub stays gone after reload-from-disk",
3381 );
3382 assert_eq!(
3383 engine.health().stub_count,
3384 0,
3385 "reloaded-from-disk store carries the same stub count as the in-session post-update state",
3386 );
3387 }
3388
3389 #[test]
3390 fn update_gc_noop_when_section_edit_changes_no_body_link() {
3391 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3396 use indexmap::IndexMap;
3397 use tempfile::TempDir;
3398
3399 let tmp = TempDir::new().unwrap();
3400 let mem_dir = tmp.path().to_path_buf();
3401 let writer = FilesystemMemWriter::new(mem_dir.clone());
3402 let mut engine = Engine::from_mounts(vec![(
3403 folder_mount("specs", mem_dir.clone()),
3404 Box::new(writer) as Box<dyn MemBackend>,
3405 )])
3406 .unwrap();
3407 engine.set_workspace_root(mem_dir.clone());
3408 let (actor, client) = cli_actor();
3409
3410 let ghost = crate::EntityId::new("specs", "ghost");
3411 let mut sections: IndexMap<String, String> = IndexMap::new();
3412 sections.insert("identity".to_string(), "original identity".to_string());
3413 sections.insert(
3414 "purpose".to_string(),
3415 "see [[ghost]] for context".to_string(),
3416 );
3417 let source = engine
3418 .create_entity(
3419 CreateEntityArgs {
3420 anchors: Vec::new(),
3421 mem: "specs".to_string(),
3422 title: "Source".to_string(),
3423 entity_type: "spec".to_string(),
3424 sections,
3425 metadata: IndexMap::new(),
3426 relations: Vec::new(),
3427 dry_run: false,
3428 },
3429 actor,
3430 Some(&client),
3431 None,
3432 )
3433 .unwrap();
3434 assert!(engine.store().contains(&ghost), "ghost stub materialised");
3435
3436 let mut edit: IndexMap<String, String> = IndexMap::new();
3439 edit.insert("identity".to_string(), "edited identity".to_string());
3440 let outcome = engine
3441 .update_entity(
3442 UpdateEntityArgs {
3443 anchors: Vec::new(),
3444 id: source.id.clone(),
3445 expected_hash: Some(source.content_hash.clone()),
3446 sections: edit,
3447 append_sections: IndexMap::new(),
3448 patch_sections: IndexMap::new(),
3449 metadata: IndexMap::new(),
3450 metadata_unset: Vec::new(),
3451 declare_relations: Vec::new(),
3452 dry_run: false,
3453 relations_unset: Vec::new(),
3454 },
3455 actor,
3456 Some(&client),
3457 None,
3458 )
3459 .expect("update must succeed");
3460 assert!(
3461 outcome.orphan_stubs_removed.is_empty(),
3462 "an edit that keeps every body wiki-link orphans nothing; got {:?}",
3463 outcome.orphan_stubs_removed,
3464 );
3465 assert!(
3466 engine.store().contains(&ghost),
3467 "the still-referenced stub survives the unrelated section edit",
3468 );
3469 }
3470
3471 #[test]
3472 fn update_gc_preserves_stub_with_surviving_referrer() {
3473 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3477 use indexmap::IndexMap;
3478 use tempfile::TempDir;
3479
3480 let tmp = TempDir::new().unwrap();
3481 let mem_dir = tmp.path().to_path_buf();
3482 let writer = FilesystemMemWriter::new(mem_dir.clone());
3483 let mut engine = Engine::from_mounts(vec![(
3484 folder_mount("specs", mem_dir.clone()),
3485 Box::new(writer) as Box<dyn MemBackend>,
3486 )])
3487 .unwrap();
3488 engine.set_workspace_root(mem_dir.clone());
3489 let (actor, client) = cli_actor();
3490
3491 let ghost = crate::EntityId::new("specs", "ghost");
3492 let make_with_link = |title: &str| {
3493 let mut sections: IndexMap<String, String> = IndexMap::new();
3494 sections.insert("identity".to_string(), format!("{title} identity"));
3495 sections.insert("purpose".to_string(), "see [[ghost]]".to_string());
3496 CreateEntityArgs {
3497 anchors: Vec::new(),
3498 mem: "specs".to_string(),
3499 title: title.to_string(),
3500 entity_type: "spec".to_string(),
3501 sections,
3502 metadata: IndexMap::new(),
3503 relations: Vec::new(),
3504 dry_run: false,
3505 }
3506 };
3507 let source_a = engine
3508 .create_entity(make_with_link("Source A"), actor, Some(&client), None)
3509 .unwrap();
3510 engine
3511 .create_entity(make_with_link("Source B"), actor, Some(&client), None)
3512 .unwrap();
3513 assert!(engine.store().contains(&ghost), "ghost stub materialised");
3514
3515 let mut drop_link: IndexMap<String, String> = IndexMap::new();
3517 drop_link.insert("purpose".to_string(), "no link here".to_string());
3518 let outcome = engine
3519 .update_entity(
3520 UpdateEntityArgs {
3521 anchors: Vec::new(),
3522 id: source_a.id.clone(),
3523 expected_hash: Some(source_a.content_hash.clone()),
3524 sections: drop_link,
3525 append_sections: IndexMap::new(),
3526 patch_sections: IndexMap::new(),
3527 metadata: IndexMap::new(),
3528 metadata_unset: Vec::new(),
3529 declare_relations: Vec::new(),
3530 dry_run: false,
3531 relations_unset: Vec::new(),
3532 },
3533 actor,
3534 Some(&client),
3535 None,
3536 )
3537 .expect("update must succeed");
3538 assert!(
3539 outcome.orphan_stubs_removed.is_empty(),
3540 "the stub keeps a referrer (source B), so nothing is GC'd; got {:?}",
3541 outcome.orphan_stubs_removed,
3542 );
3543 assert!(
3544 engine.store().contains(&ghost),
3545 "stub survives via the surviving referrer",
3546 );
3547 }
3548
3549 #[test]
3550 fn synthesis_gc_preserves_non_pointer_explicit_relation_across_body_update() {
3551 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
3560 use indexmap::IndexMap;
3561 use tempfile::TempDir;
3562
3563 let tmp = TempDir::new().unwrap();
3564 let mem_dir = tmp.path().to_path_buf();
3565 let writer = FilesystemMemWriter::new(mem_dir.clone());
3566 let mut engine = Engine::from_mounts(vec![(
3567 folder_mount("specs", mem_dir.clone()),
3568 Box::new(writer) as Box<dyn MemBackend>,
3569 )])
3570 .unwrap();
3571 engine.set_workspace_root(mem_dir.clone());
3572 let (actor, client) = cli_actor();
3573
3574 let target = engine
3575 .create_entity(
3576 empty_create_args("specs", "Target"),
3577 actor,
3578 Some(&client),
3579 None,
3580 )
3581 .unwrap();
3582 let source = engine
3583 .create_entity(
3584 empty_create_args("specs", "Source"),
3585 actor,
3586 Some(&client),
3587 None,
3588 )
3589 .unwrap();
3590
3591 let relate = engine
3593 .relate_entity(
3594 RelateEntityArgs {
3595 source: source.id.clone(),
3596 expected_hash: Some(source.content_hash.clone()),
3597 rel_type: "USES".to_string(),
3598 target: target.id.clone(),
3599 remove: false,
3600 description: None,
3601 },
3602 actor,
3603 Some(&client),
3604 None,
3605 )
3606 .unwrap();
3607
3608 let mut sections: IndexMap<String, String> = IndexMap::new();
3611 sections.insert("purpose".to_string(), "unrelated edit".to_string());
3612 engine
3613 .update_entity(
3614 UpdateEntityArgs {
3615 anchors: Vec::new(),
3616 id: source.id.clone(),
3617 expected_hash: Some(relate.content_hash.clone()),
3618 sections,
3619 append_sections: IndexMap::new(),
3620 patch_sections: IndexMap::new(),
3621 metadata: IndexMap::new(),
3622 metadata_unset: Vec::new(),
3623 declare_relations: Vec::new(),
3624 dry_run: false,
3625 relations_unset: Vec::new(),
3626 },
3627 actor,
3628 Some(&client),
3629 None,
3630 )
3631 .expect("update must succeed");
3632 let in_mem = engine.get_entity(&source.id).unwrap();
3633 assert!(
3634 in_mem
3635 .relationships
3636 .iter()
3637 .any(|r| r.rel_type == "USES" && r.target == target.id),
3638 "explicit USES must survive an unrelated body update; got {:?}",
3639 in_mem.relationships,
3640 );
3641 }
3642
3643 #[test]
3644 fn synthesis_dedupes_repeated_body_links_to_same_target() {
3645 use crate::engine::UpdateEntityArgs;
3648 use indexmap::IndexMap;
3649 use tempfile::TempDir;
3650
3651 let tmp = TempDir::new().unwrap();
3652 let mem_dir = tmp.path().to_path_buf();
3653 let writer = FilesystemMemWriter::new(mem_dir.clone());
3654 let mut engine = Engine::from_mounts(vec![(
3655 folder_mount("specs", mem_dir.clone()),
3656 Box::new(writer) as Box<dyn MemBackend>,
3657 )])
3658 .unwrap();
3659 engine.set_workspace_root(mem_dir.clone());
3660 let (actor, client) = cli_actor();
3661
3662 let target = engine
3663 .create_entity(
3664 empty_create_args("specs", "Target"),
3665 actor,
3666 Some(&client),
3667 None,
3668 )
3669 .unwrap();
3670 let source = engine
3671 .create_entity(
3672 empty_create_args("specs", "Source"),
3673 actor,
3674 Some(&client),
3675 None,
3676 )
3677 .unwrap();
3678
3679 let mut sections: IndexMap<String, String> = IndexMap::new();
3680 sections.insert(
3681 "purpose".to_string(),
3682 "see [[target]] and again [[target]]".to_string(),
3683 );
3684 engine
3685 .update_entity(
3686 UpdateEntityArgs {
3687 anchors: Vec::new(),
3688 id: source.id.clone(),
3689 expected_hash: Some(source.content_hash.clone()),
3690 sections,
3691 append_sections: IndexMap::new(),
3692 patch_sections: IndexMap::new(),
3693 metadata: IndexMap::new(),
3694 metadata_unset: Vec::new(),
3695 declare_relations: Vec::new(),
3696 dry_run: false,
3697 relations_unset: Vec::new(),
3698 },
3699 actor,
3700 Some(&client),
3701 None,
3702 )
3703 .unwrap();
3704 let in_mem = engine.get_entity(&source.id).unwrap();
3705 let count = in_mem
3706 .relationships
3707 .iter()
3708 .filter(|r| r.rel_type == "REFERENCES" && r.target == target.id)
3709 .count();
3710 assert_eq!(
3711 count, 1,
3712 "dedupe must leave exactly one REFERENCES → target; got {:?}",
3713 in_mem.relationships,
3714 );
3715 }
3716
3717 #[test]
3718 fn synthesis_coexists_with_explicit_uses_to_same_target() {
3719 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
3724 use indexmap::IndexMap;
3725 use tempfile::TempDir;
3726
3727 let tmp = TempDir::new().unwrap();
3728 let mem_dir = tmp.path().to_path_buf();
3729 let writer = FilesystemMemWriter::new(mem_dir.clone());
3730 let mut engine = Engine::from_mounts(vec![(
3731 folder_mount("specs", mem_dir.clone()),
3732 Box::new(writer) as Box<dyn MemBackend>,
3733 )])
3734 .unwrap();
3735 engine.set_workspace_root(mem_dir.clone());
3736 let (actor, client) = cli_actor();
3737
3738 let target = engine
3739 .create_entity(
3740 empty_create_args("specs", "Target"),
3741 actor,
3742 Some(&client),
3743 None,
3744 )
3745 .unwrap();
3746 let source = engine
3747 .create_entity(
3748 empty_create_args("specs", "Source"),
3749 actor,
3750 Some(&client),
3751 None,
3752 )
3753 .unwrap();
3754 let relate = engine
3756 .relate_entity(
3757 RelateEntityArgs {
3758 source: source.id.clone(),
3759 expected_hash: Some(source.content_hash.clone()),
3760 rel_type: "USES".to_string(),
3761 target: target.id.clone(),
3762 remove: false,
3763 description: None,
3764 },
3765 actor,
3766 Some(&client),
3767 None,
3768 )
3769 .unwrap();
3770 let mut sections: IndexMap<String, String> = IndexMap::new();
3772 sections.insert(
3773 "purpose".to_string(),
3774 "we also reference [[target]]".to_string(),
3775 );
3776 engine
3777 .update_entity(
3778 UpdateEntityArgs {
3779 anchors: Vec::new(),
3780 id: source.id.clone(),
3781 expected_hash: Some(relate.content_hash.clone()),
3782 sections,
3783 append_sections: IndexMap::new(),
3784 patch_sections: IndexMap::new(),
3785 metadata: IndexMap::new(),
3786 metadata_unset: Vec::new(),
3787 declare_relations: Vec::new(),
3788 dry_run: false,
3789 relations_unset: Vec::new(),
3790 },
3791 actor,
3792 Some(&client),
3793 None,
3794 )
3795 .unwrap();
3796 let in_mem = engine.get_entity(&source.id).unwrap();
3797 assert!(
3798 in_mem
3799 .relationships
3800 .iter()
3801 .any(|r| r.rel_type == "USES" && r.target == target.id),
3802 "USES must survive — synthesis dedupes on (rel_type, target)",
3803 );
3804 assert!(
3805 in_mem
3806 .relationships
3807 .iter()
3808 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3809 "REFERENCES must be synthesised even though USES already targets the same entity",
3810 );
3811 }
3812
3813 mod alias_synthesis_custom_schema {
3825 use std::path::Path;
3826
3827 use indexmap::IndexMap;
3828 use memstead_schema::SchemaRef;
3829 use tempfile::TempDir;
3830
3831 use crate::backend::MemBackend;
3832 use crate::engine::test_helpers::*;
3833 use crate::engine::{CreateEntityArgs, Engine, EngineError, UpdateEntityArgs};
3834 use crate::storage::FilesystemMemWriter;
3835 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
3836
3837 const TYPE_BODY: &str = r#"description: t
3838when_to_use: tests
3839sections:
3840 - key: body
3841 heading: Body
3842 required: true
3843 search_weight: 10.0
3844 catch_all: true
3845 write_rules: []
3846metadata_fields: []
3847title_weight: 100.0
3848text_fields:
3849 - body
3850hierarchy_relationship: _default
3851propagating_relationships: []
3852updatable_fields:
3853 - title
3854 - body
3855health_required_fields:
3856 - body
3857staleness_threshold_days: 90
3858write_rules: []
3859"#;
3860
3861 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
3862 let dir = root.join(name);
3863 std::fs::create_dir_all(dir.join("types")).unwrap();
3864 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
3865 for (type_name, body) in types {
3866 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
3867 }
3868 }
3869
3870 fn make_type_yaml(name: &str) -> String {
3871 format!("name: {name}\n{TYPE_BODY}")
3872 }
3873
3874 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
3875 Mount {
3876 mem: mem.to_string(),
3877 schema: Some(pin),
3878 storage: MountStorage::Folder { path },
3879 capability: MountCapability::Write,
3880 lifecycle: MountLifecycle::Eager,
3881 cross_linkable: true,
3882 migration_target: None,
3883 }
3884 }
3885
3886 fn engine_with_schema(
3887 manifest: &str,
3888 type_yaml_name: &str,
3889 schema_name: &str,
3890 schema_version: semver::Version,
3891 ) -> (Engine, TempDir) {
3892 let tmp = TempDir::new().unwrap();
3893 let schemas_dir = tmp.path().join("schemas");
3894 std::fs::create_dir_all(&schemas_dir).unwrap();
3895 write_schema_files(
3896 &schemas_dir,
3897 schema_name,
3898 manifest,
3899 &[(type_yaml_name, &make_type_yaml(type_yaml_name))],
3900 );
3901 let mem_dir = tmp.path().join("mem");
3902 std::fs::create_dir_all(&mem_dir).unwrap();
3903 let writer = FilesystemMemWriter::new(mem_dir.clone());
3904 let pin = SchemaRef::new(schema_name, schema_version);
3905 let mount = folder_mount_with_pin("v", mem_dir, pin);
3906 let mut engine = Engine::from_mounts_with_schemas_dir(
3907 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3908 Some(&schemas_dir),
3909 )
3910 .expect("engine with custom schema constructs");
3911 engine.set_workspace_root(tmp.path().to_path_buf());
3912 (engine, tmp)
3913 }
3914
3915 #[test]
3916 fn non_references_alias_pointer_emits_named_rel_type_from_body_link() {
3917 let manifest = r#"name: aliased
3922version: 0.1.0
3923description: alias-synthesis fixture using a non-REFERENCES pointer
3924when_to_use: tests prove the engine does not hard-code REFERENCES
3925types:
3926 - doc
3927relationships:
3928 mode: strict
3929 definitions:
3930 - name: CITES
3931 description: Citation — auto-emitted from body wiki-links
3932 default_weight: 0.5
3933 - name: PART_OF
3934 description: Hierarchy
3935 default_weight: 3.0
3936 acyclic: true
3937 - name: _default
3938 description: Fallback
3939 default_weight: 1.0
3940alias_target_rel_type: CITES
3941community:
3942 resolution: 1.0
3943 seed: 42
3944"#;
3945 let (mut engine, _tmp) =
3946 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
3947 let (actor, client) = cli_actor();
3948
3949 let target = engine
3950 .create_entity(
3951 CreateEntityArgs {
3952 anchors: Vec::new(),
3953 mem: "v".to_string(),
3954 title: "Target".to_string(),
3955 entity_type: "doc".to_string(),
3956 sections: IndexMap::from_iter([(
3957 "body".to_string(),
3958 "target body".to_string(),
3959 )]),
3960 metadata: IndexMap::new(),
3961 relations: Vec::new(),
3962 dry_run: false,
3963 },
3964 actor,
3965 Some(&client),
3966 None,
3967 )
3968 .unwrap();
3969
3970 let mut sections: IndexMap<String, String> = IndexMap::new();
3971 sections.insert("body".to_string(), "see [[target]]".to_string());
3972 let source = engine
3973 .create_entity(
3974 CreateEntityArgs {
3975 anchors: Vec::new(),
3976 mem: "v".to_string(),
3977 title: "Source".to_string(),
3978 entity_type: "doc".to_string(),
3979 sections,
3980 metadata: IndexMap::new(),
3981 relations: Vec::new(),
3982 dry_run: false,
3983 },
3984 actor,
3985 Some(&client),
3986 None,
3987 )
3988 .expect("create must succeed; CITES is auto-emitted by synthesis");
3989
3990 let in_mem = engine.get_entity(&source.id).unwrap();
3991 assert!(
3992 in_mem
3993 .relationships
3994 .iter()
3995 .any(|r| r.rel_type == "CITES" && r.target == target.id),
3996 "synthesis must emit CITES (the pointer rel-type), not REFERENCES; got {:?}",
3997 in_mem.relationships,
3998 );
3999 assert!(
4000 !in_mem
4001 .relationships
4002 .iter()
4003 .any(|r| r.rel_type == "REFERENCES"),
4004 "engine must not hard-code REFERENCES — pointer rel-type is CITES; got {:?}",
4005 in_mem.relationships,
4006 );
4007 }
4008
4009 #[test]
4010 fn no_pointer_schema_refuses_unbacked_body_wiki_link() {
4011 let manifest = r#"name: no-alias
4016version: 0.1.0
4017description: schema without alias_target_rel_type pointer
4018when_to_use: tests prove strict validator still fires for opt-out schemas
4019types:
4020 - doc
4021relationships:
4022 mode: strict
4023 definitions:
4024 - name: USES
4025 description: Use
4026 default_weight: 1.0
4027 - name: PART_OF
4028 description: Hierarchy
4029 default_weight: 3.0
4030 acyclic: true
4031 - name: _default
4032 description: Fallback
4033 default_weight: 1.0
4034community:
4035 resolution: 1.0
4036 seed: 42
4037"#;
4038 let (mut engine, _tmp) =
4039 engine_with_schema(manifest, "doc", "no-alias", semver::Version::new(0, 1, 0));
4040 let (actor, client) = cli_actor();
4041
4042 let target = engine
4043 .create_entity(
4044 CreateEntityArgs {
4045 anchors: Vec::new(),
4046 mem: "v".to_string(),
4047 title: "Target".to_string(),
4048 entity_type: "doc".to_string(),
4049 sections: IndexMap::from_iter([(
4050 "body".to_string(),
4051 "target body".to_string(),
4052 )]),
4053 metadata: IndexMap::new(),
4054 relations: Vec::new(),
4055 dry_run: false,
4056 },
4057 actor,
4058 Some(&client),
4059 None,
4060 )
4061 .unwrap();
4062 let source = engine
4063 .create_entity(
4064 CreateEntityArgs {
4065 anchors: Vec::new(),
4066 mem: "v".to_string(),
4067 title: "Source".to_string(),
4068 entity_type: "doc".to_string(),
4069 sections: IndexMap::from_iter([(
4070 "body".to_string(),
4071 "source body".to_string(),
4072 )]),
4073 metadata: IndexMap::new(),
4074 relations: Vec::new(),
4075 dry_run: false,
4076 },
4077 actor,
4078 Some(&client),
4079 None,
4080 )
4081 .unwrap();
4082
4083 let mut sections: IndexMap<String, String> = IndexMap::new();
4087 sections.insert("body".to_string(), "see [[target]]".to_string());
4088 let err = engine
4089 .update_entity(
4090 UpdateEntityArgs {
4091 anchors: Vec::new(),
4092 id: source.id.clone(),
4093 expected_hash: Some(source.content_hash.clone()),
4094 sections,
4095 append_sections: IndexMap::new(),
4096 patch_sections: IndexMap::new(),
4097 metadata: IndexMap::new(),
4098 metadata_unset: Vec::new(),
4099 declare_relations: Vec::new(),
4100 dry_run: false,
4101 relations_unset: Vec::new(),
4102 },
4103 actor,
4104 Some(&client),
4105 None,
4106 )
4107 .unwrap_err();
4108 match err {
4109 EngineError::WikiLinkWithoutRelation { from_id, missing } => {
4110 assert_eq!(from_id, source.id.to_string());
4111 assert_eq!(missing.len(), 1);
4112 assert_eq!(missing[0].section_key, "body");
4113 assert_eq!(missing[0].target_id, target.id.to_string());
4114 }
4115 other => panic!(
4116 "no-pointer schema must refuse with WikiLinkWithoutRelation; got {other:?}"
4117 ),
4118 }
4119 }
4120
4121 #[test]
4130 fn natural_form_body_wiki_link_refuses_with_typed_envelope() {
4131 let manifest = r#"name: aliased
4132version: 0.1.0
4133description: alias-synthesis fixture
4134when_to_use: tests prove strict wiki-link grammar at mutation entry
4135types:
4136 - doc
4137relationships:
4138 mode: strict
4139 definitions:
4140 - name: REFERENCES
4141 description: Reference — auto-emitted from body wiki-links
4142 default_weight: 0.5
4143 - name: PART_OF
4144 description: Hierarchy
4145 default_weight: 3.0
4146 acyclic: true
4147 - name: _default
4148 description: Fallback
4149 default_weight: 1.0
4150alias_target_rel_type: REFERENCES
4151community:
4152 resolution: 1.0
4153 seed: 42
4154"#;
4155 let (mut engine, _tmp) =
4156 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4157 let (actor, client) = cli_actor();
4158
4159 let mut sections: IndexMap<String, String> = IndexMap::new();
4160 sections.insert("body".to_string(), "see [[Knowledge Graph]]".to_string());
4161 let err = engine
4162 .create_entity(
4163 CreateEntityArgs {
4164 anchors: Vec::new(),
4165 mem: "v".to_string(),
4166 title: "Source".to_string(),
4167 entity_type: "doc".to_string(),
4168 sections,
4169 metadata: IndexMap::new(),
4170 relations: Vec::new(),
4171 dry_run: false,
4172 },
4173 actor,
4174 Some(&client),
4175 None,
4176 )
4177 .unwrap_err();
4178 match err {
4179 EngineError::InvalidWikiLinkTarget {
4180 raw,
4181 suggested,
4182 section,
4183 link_source,
4184 ..
4185 } => {
4186 assert_eq!(raw, "Knowledge Graph");
4187 assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
4188 assert_eq!(section, "body");
4189 assert_eq!(link_source, "body_link");
4190 }
4191 other => panic!(
4192 "natural-form body wiki-link must refuse with InvalidWikiLinkTarget; got {other:?}"
4193 ),
4194 }
4195 }
4196
4197 #[test]
4203 fn tier_two_bad_mem_prefix_refuses_with_distinct_envelope() {
4204 let manifest = r#"name: aliased
4205version: 0.1.0
4206description: alias-synthesis fixture
4207when_to_use: tests prove strict mem-prefix grammar at mutation entry
4208types:
4209 - doc
4210relationships:
4211 mode: strict
4212 definitions:
4213 - name: REFERENCES
4214 description: Reference
4215 default_weight: 0.5
4216 - name: PART_OF
4217 description: Hierarchy
4218 default_weight: 3.0
4219 acyclic: true
4220 - name: _default
4221 description: Fallback
4222 default_weight: 1.0
4223alias_target_rel_type: REFERENCES
4224community:
4225 resolution: 1.0
4226 seed: 42
4227"#;
4228 let (mut engine, _tmp) =
4229 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4230 let (actor, client) = cli_actor();
4231
4232 let mut sections: IndexMap<String, String> = IndexMap::new();
4233 sections.insert("body".to_string(), "see [[Other Mem:foo]]".to_string());
4234 let err = engine
4235 .create_entity(
4236 CreateEntityArgs {
4237 anchors: Vec::new(),
4238 mem: "v".to_string(),
4239 title: "Source".to_string(),
4240 entity_type: "doc".to_string(),
4241 sections,
4242 metadata: IndexMap::new(),
4243 relations: Vec::new(),
4244 dry_run: false,
4245 },
4246 actor,
4247 Some(&client),
4248 None,
4249 )
4250 .unwrap_err();
4251 match err {
4252 EngineError::InvalidWikiLinkMem { raw, section, .. } => {
4253 assert_eq!(raw, "Other Mem");
4254 assert_eq!(section, "body");
4255 }
4256 other => panic!(
4257 "Tier-2 bad mem prefix must refuse with InvalidWikiLinkMem; got {other:?}"
4258 ),
4259 }
4260 }
4261
4262 #[test]
4269 fn hierarchical_dash_form_body_link_refuses_with_colon_suggestion() {
4270 let manifest = r#"name: aliased
4271version: 0.1.0
4272description: alias-synthesis fixture
4273when_to_use: tests prove hierarchical dash-form refusal at mutation entry
4274types:
4275 - doc
4276relationships:
4277 mode: strict
4278 definitions:
4279 - name: REFERENCES
4280 description: Reference — auto-emitted from body wiki-links
4281 default_weight: 0.5
4282 - name: PART_OF
4283 description: Hierarchy
4284 default_weight: 3.0
4285 acyclic: true
4286 - name: _default
4287 description: Fallback
4288 default_weight: 1.0
4289alias_target_rel_type: REFERENCES
4290community:
4291 resolution: 1.0
4292 seed: 42
4293"#;
4294 let (mut engine, _tmp) =
4295 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4296 let (actor, client) = cli_actor();
4297
4298 let mut sections: IndexMap<String, String> = IndexMap::new();
4299 sections.insert(
4300 "body".to_string(),
4301 "see [[team/sub-mem--target]]".to_string(),
4302 );
4303 let err = engine
4304 .create_entity(
4305 CreateEntityArgs {
4306 anchors: Vec::new(),
4307 mem: "v".to_string(),
4308 title: "Source".to_string(),
4309 entity_type: "doc".to_string(),
4310 sections,
4311 metadata: IndexMap::new(),
4312 relations: Vec::new(),
4313 dry_run: false,
4314 },
4315 actor,
4316 Some(&client),
4317 None,
4318 )
4319 .unwrap_err();
4320 match err {
4321 EngineError::InvalidWikiLinkTarget {
4322 raw,
4323 suggested,
4324 section,
4325 link_source,
4326 ..
4327 } => {
4328 assert_eq!(raw, "team/sub-mem--target");
4329 assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
4330 assert_eq!(section, "body");
4331 assert_eq!(link_source, "body_link");
4332 }
4333 other => panic!(
4334 "hierarchical dash-form body link must refuse with InvalidWikiLinkTarget; got {other:?}"
4335 ),
4336 }
4337
4338 let listed = engine.store().all_entities().collect::<Vec<_>>();
4341 assert!(
4342 listed.is_empty(),
4343 "refused create must not leave any entity behind, got: {listed:?}"
4344 );
4345 }
4346 }
4347
4348 const DRIFTED_MD: &str = "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nzzz_bogus_field: x\n---\n# Drifted\n\n## Identity\n\nNon-conformant fixture.\n\n## Purpose\n\nRepair-gate tests.\n\n## Relationships\n\n- **USES**: [[anchor]]\n";
4357
4358 fn repair_engine() -> (TempDir, Engine) {
4359 let tmp = TempDir::new().unwrap();
4360 let mem_dir = tmp.path().to_path_buf();
4361 std::fs::write(
4362 mem_dir.join("anchor.md"),
4363 "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\n---\n# Anchor\n\n## Identity\n\nTarget.\n\n## Purpose\n\nRelation target.\n",
4364 )
4365 .unwrap();
4366 std::fs::write(mem_dir.join("drifted.md"), DRIFTED_MD).unwrap();
4367 let writer = FilesystemMemWriter::new(mem_dir.clone());
4368 let engine = Engine::from_mounts(vec![(
4369 folder_mount("specs", mem_dir),
4370 Box::new(writer) as Box<dyn MemBackend>,
4371 )])
4372 .unwrap();
4373 (tmp, engine)
4374 }
4375
4376 fn repair_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
4377 UpdateEntityArgs {
4378 anchors: Vec::new(),
4379 id,
4380 expected_hash: hash,
4381 sections: IndexMap::new(),
4382 append_sections: IndexMap::new(),
4383 patch_sections: IndexMap::new(),
4384 metadata: IndexMap::new(),
4385 metadata_unset: Vec::new(),
4386 declare_relations: Vec::new(),
4387 dry_run: false,
4388 relations_unset: vec![crate::ops::RelationUnsetArg {
4389 rel_type: "USES".to_string(),
4390 target: EntityId::new("specs", "anchor"),
4391 }],
4392 }
4393 }
4394
4395 #[test]
4400 fn relations_unset_on_conformant_entity_refuses_repair_not_needed() {
4401 let (_tmp, mut engine) = repair_engine();
4402 let anchor = EntityId::new("specs", "anchor");
4405 let drifted = EntityId::new("specs", "drifted");
4406 engine
4407 .relate_entity(
4408 RelateEntityArgs {
4409 source: anchor.clone(),
4410 expected_hash: None,
4411 rel_type: "USES".to_string(),
4412 target: drifted.clone(),
4413 remove: false,
4414 description: None,
4415 },
4416 Actor::Cli,
4417 None,
4418 None,
4419 )
4420 .expect("relate on conformant entity works");
4421 let mut args = repair_args(anchor.clone(), None);
4422 args.relations_unset[0].target = drifted.clone();
4423 let err = engine
4424 .update_entity(args, Actor::Cli, None, None)
4425 .unwrap_err();
4426 match err {
4427 EngineError::RepairNotNeeded { id, recovery } => {
4428 assert_eq!(id, anchor.to_string());
4429 assert!(
4430 recovery.contains("memstead_relate"),
4431 "recovery must point at the focused tool; got {recovery}"
4432 );
4433 }
4434 other => panic!("expected RepairNotNeeded, got {other:?}"),
4435 }
4436 let entity = engine.store().get(&anchor).unwrap();
4438 assert!(
4439 entity.relationships.iter().any(|r| r.target == drifted),
4440 "gate must not modify the entity"
4441 );
4442 }
4443
4444 #[test]
4449 fn relations_unset_repairs_non_conformant_entity_atomically() {
4450 let (_tmp, mut engine) = repair_engine();
4451 let drifted = EntityId::new("specs", "drifted");
4452 let pre = engine.conformance_findings("specs", None).unwrap();
4454 assert!(
4455 pre.iter().any(|f| f.id == drifted.to_string()),
4456 "fixture must lint non-conformant; got {pre:?}"
4457 );
4458 let mut args = repair_args(drifted.clone(), None);
4459 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
4460 engine
4461 .update_entity(args, Actor::Cli, None, None)
4462 .expect("repair update lands");
4463 let entity = engine.store().get(&drifted).unwrap();
4464 assert!(
4465 entity.relationships.is_empty(),
4466 "relation must be removed; got {:?}",
4467 entity.relationships
4468 );
4469 assert!(
4470 !entity.metadata.contains_key("zzz_bogus_field"),
4471 "conformance break must be repaired in the same update"
4472 );
4473 let post = engine.conformance_findings("specs", None).unwrap();
4474 assert!(
4475 post.iter().all(|f| f.id != drifted.to_string()),
4476 "post-repair entity must be conformant; got {post:?}"
4477 );
4478 }
4479
4480 #[test]
4484 fn relations_unset_post_state_must_still_validate() {
4485 let (_tmp, mut engine) = repair_engine();
4486 let drifted = EntityId::new("specs", "drifted");
4487 let mut args = repair_args(drifted.clone(), None);
4488 args.sections = IndexMap::from_iter([("nonexistent_section".to_string(), "x".to_string())]);
4491 let err = engine
4492 .update_entity(args, Actor::Cli, None, None)
4493 .unwrap_err();
4494 assert_eq!(
4495 err.code(),
4496 "UNKNOWN_SECTION",
4497 "strict-write post-condition must hold during repair; got {err:?}"
4498 );
4499 let entity = engine.store().get(&drifted).unwrap();
4501 assert!(
4502 !entity.relationships.is_empty(),
4503 "refused repair must not partially apply"
4504 );
4505 }
4506
4507 #[test]
4510 fn relations_unset_absent_pair_is_silent_noop() {
4511 let (_tmp, mut engine) = repair_engine();
4512 let drifted = EntityId::new("specs", "drifted");
4513 let mut args = repair_args(drifted.clone(), None);
4514 args.relations_unset[0].rel_type = "NEVER_DECLARED".to_string();
4515 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
4517 engine
4518 .update_entity(args, Actor::Cli, None, None)
4519 .expect("absent pair no-ops, update lands");
4520 let entity = engine.store().get(&drifted).unwrap();
4521 assert_eq!(
4522 entity.relationships.len(),
4523 1,
4524 "the USES relation must survive an unmatched unset"
4525 );
4526 }
4527}