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}
61
62struct AppliedWrite {
65 content_hash: String,
66 title: String,
67 orphan_stubs_removed: Vec<EntityId>,
68}
69
70impl Engine {
71 pub fn update_entity(
87 &mut self,
88 args: UpdateEntityArgs,
89 actor: Actor,
90 client: Option<&ClientId>,
91 note: Option<&str>,
92 ) -> Result<UpdateEntityOutcome, EngineError> {
93 let mut drift_warnings = self.reload_if_stale(Some(args.id.mem()));
100 let mut outcome = match self.prepare_update(args)? {
101 PrepareOutcome::Done(outcome) => outcome,
102 PrepareOutcome::Prepared(prepared) => {
103 self.commit_prepared_update(prepared, actor, client, note)?
104 }
105 };
106 drift_warnings.append(&mut outcome.warnings);
107 outcome.warnings = drift_warnings;
108 Ok(outcome)
109 }
110
111 fn commit_prepared_update(
116 &mut self,
117 prepared: PreparedUpdate,
118 actor: Actor,
119 client: Option<&ClientId>,
120 note: Option<&str>,
121 ) -> Result<UpdateEntityOutcome, EngineError> {
122 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
123 backend.write_entity(Path::new(&prepared.file_path), prepared.markdown.as_bytes())?;
124 let commit_subject = format!("memstead: update {}", prepared.id);
125 let ctx = CommitContext {
126 actor,
127 client: client.cloned(),
128 tool: Some("update_entity"),
129 note: note.map(String::from),
130 logical_operation_id: None,
131 entity_ids: None,
132 };
133 let commit_sha = backend.commit(&commit_subject, &ctx)?;
134 backend.append_provenance(&Provenance::new(
135 std::time::SystemTime::now(),
136 ProvenanceKind::Update,
137 Some(prepared.id.to_string()),
138 actor,
139 client.cloned(),
140 note.map(String::from),
141 ))?;
142 self.record_self_write(prepared.mount_idx, &commit_sha);
143
144 let applied = self.apply_prepared_to_store(&prepared)?;
145
146 self.invalidate_communities();
147 self.invalidate_search_indexes();
148
149 let mut warnings = prepared.warnings;
153 if let Some(w) = self.note_missing_warning("update_entity", note) {
154 warnings.push(w);
155 }
156
157 Ok(UpdateEntityOutcome {
158 id: prepared.id.clone(),
159 title: applied.title,
160 file_path: prepared.file_path,
161 content_hash: applied.content_hash,
162 commit_sha,
163 modified_date: prepared.modified_date,
164 orphan_stubs_removed: applied.orphan_stubs_removed,
165 modified_sections: prepared.modified_sections,
166 modified_metadata: prepared.modified_metadata,
167 prospective_hash: None,
168 warnings,
169 relations_declared: prepared.relations_declared,
170 })
171 }
172
173 fn apply_prepared_to_store(
180 &mut self,
181 prepared: &PreparedUpdate,
182 ) -> Result<AppliedWrite, EngineError> {
183 let parse_result = parse_markdown(
184 &prepared.markdown,
185 &prepared.file_path,
186 prepared.type_def.as_ref(),
187 &prepared.mem,
188 )
189 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
190 let content_hash = parse_result.entity.content_hash.clone();
191 let title = parse_result.entity.title.clone();
192 let fallback = engine_fallback_type();
193 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
194 crate::entity::store_builder::remap_alias_target_edge_sources(
195 &mut self.store,
196 &self.schemas,
197 );
198 let orphan_stubs_removed =
199 super::gc_orphan_stubs_among(&mut self.store, &prepared.prev_body_targets);
200 Ok(AppliedWrite {
201 content_hash,
202 title,
203 orphan_stubs_removed,
204 })
205 }
206
207 fn prepare_update(&mut self, args: UpdateEntityArgs) -> Result<PrepareOutcome, EngineError> {
215 let id = &args.id;
216 let mem = id.mem().to_string();
217
218 let mount_idx = self
219 .mounts
220 .iter()
221 .position(|m| m.mount.mem == mem)
222 .ok_or_else(|| EngineError::UnknownMem(mem.clone()))?;
223 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
224 return Err(EngineError::ReadOnlyMount(mem));
225 }
226
227 let entity = self
228 .store
229 .get(id)
230 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
231
232 let prev_body_targets = super::collect_body_link_targets(entity);
238
239 if entity.stub {
247 return Err(EngineError::StubNotUpdatable { id: id.to_string() });
248 }
249
250 if !args.dry_run
256 && let Some(expected) = args.expected_hash.as_deref()
257 && entity.content_hash != expected
258 {
259 return Err(EngineError::HashMismatch {
260 id: id.to_string(),
261 current: entity.content_hash.clone(),
262 is_stub: entity.stub,
263 });
264 }
265
266 if args.sections.is_empty()
278 && args.append_sections.is_empty()
279 && args.patch_sections.is_empty()
280 && args.metadata.is_empty()
281 && args.metadata_unset.is_empty()
282 && args.declare_relations.is_empty()
283 && args.relations_unset.is_empty()
284 {
285 return Err(EngineError::EmptyUpdate { id: id.to_string() });
286 }
287
288 let schema = self
289 .schemas
290 .get(&mem)
291 .expect("schema present for every registered mount")
292 .clone();
293 let type_def = schema
294 .get_type(&entity.entity_type)
295 .ok_or_else(|| unknown_type_error(schema.as_ref(), &entity.entity_type))?;
296
297 for key in args.sections.keys() {
304 let mut modes = vec!["sections".to_string()];
305 if args.append_sections.contains_key(key) {
306 modes.push("append_sections".to_string());
307 }
308 if args.patch_sections.contains_key(key) {
309 modes.push("patch_sections".to_string());
310 }
311 if modes.len() > 1 {
312 return Err(EngineError::ConflictingSectionModes {
313 section: key.clone(),
314 modes,
315 });
316 }
317 }
318 for key in args.append_sections.keys() {
319 if args.patch_sections.contains_key(key) {
320 return Err(EngineError::ConflictingSectionModes {
321 section: key.clone(),
322 modes: vec!["append_sections".to_string(), "patch_sections".to_string()],
323 });
324 }
325 }
326
327 validate_section_keys(
328 args.sections
329 .keys()
330 .chain(args.append_sections.keys())
331 .chain(args.patch_sections.keys())
332 .map(String::as_str),
333 type_def.as_ref(),
334 )?;
335 validate_section_content(
341 args.sections
342 .iter()
343 .map(|(k, v)| (k.as_str(), v.as_str()))
344 .chain(
345 args.append_sections
346 .iter()
347 .map(|(k, v)| (k.as_str(), v.as_str())),
348 )
349 .chain(
350 args.patch_sections
351 .iter()
352 .map(|(k, p)| (k.as_str(), p.new.as_str())),
353 ),
354 )?;
355 for key in args.sections.keys() {
356 validate_updatable_section(key.as_str(), type_def.as_ref())?;
357 }
358 for key in args.append_sections.keys() {
359 validate_updatable_section(key.as_str(), type_def.as_ref())?;
360 }
361 for key in args.patch_sections.keys() {
362 validate_updatable_section(key.as_str(), type_def.as_ref())?;
363 }
364 for key in args.metadata.keys() {
365 validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
366 }
367 for key in &args.metadata_unset {
368 validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
369 }
370
371 let mut overlap: Vec<String> = args
378 .metadata
379 .keys()
380 .filter(|k| args.metadata_unset.iter().any(|u| u == k.as_str()))
381 .cloned()
382 .collect();
383 if !overlap.is_empty() {
384 overlap.sort();
385 overlap.dedup();
386 return Err(EngineError::SetAndUnsetConflict { keys: overlap });
387 }
388
389 if !args.relations_unset.is_empty() {
398 let findings = crate::ops::integrity::entity_conformance_findings(
399 &self.store,
400 entity,
401 schema.as_ref(),
402 &self.schemas,
403 );
404 if findings.is_empty() {
405 return Err(EngineError::RepairNotNeeded {
406 id: id.to_string(),
407 recovery: "use memstead_relate(remove=true) to detach an edge from a conformant entity, or the additive memstead_update params to evolve it"
408 .to_string(),
409 });
410 }
411 }
412
413 let mut next = entity.clone();
414
415 for unset in &args.relations_unset {
422 let canonical = crate::entity::id::validate_rel_type(&unset.rel_type)
423 .unwrap_or_else(|_| unset.rel_type.clone());
424 next.relationships
425 .retain(|r| !(r.rel_type == canonical && r.target == unset.target));
426 }
427
428 let relations_declared = apply_declare_relations(
438 self,
439 &mut next,
440 &args.declare_relations,
441 &mem,
442 mount_idx,
443 type_def.as_ref(),
444 schema.as_ref(),
445 )?;
446
447 let mut modified_sections: Vec<String> = Vec::new();
448 for (key, body) in args.sections {
449 modified_sections.push(key.clone());
450 next.sections.insert(key, body);
451 }
452
453 let mut modified_sections_appended: Vec<String> = Vec::new();
457 for (key, value) in args.append_sections {
458 let existing = next.sections.get(&key).cloned().unwrap_or_default();
459 let new_content = if existing.trim().is_empty() {
460 value
461 } else {
462 format!("{existing}\n{value}")
463 };
464 next.sections.insert(key.clone(), new_content);
465 modified_sections_appended.push(key);
466 }
467
468 let mut modified_sections_patched: Vec<String> = Vec::new();
476 for (key, patch) in args.patch_sections {
477 let existing = next
478 .sections
479 .get(&key)
480 .ok_or_else(|| EngineError::PatchSectionEmpty {
481 section: key.clone(),
482 })?
483 .clone();
484 if !existing.contains(&patch.old) {
485 let cap = PATCH_OLD_NOT_FOUND_CONTENT_CAP;
486 let truncated = existing.len() > cap;
487 let mut cut = cap.min(existing.len());
490 while cut > 0 && !existing.is_char_boundary(cut) {
491 cut -= 1;
492 }
493 let current_content = if truncated {
494 existing[..cut].to_string()
495 } else {
496 existing.clone()
497 };
498 return Err(EngineError::PatchOldNotFound {
499 section: key,
500 current_content,
501 truncated,
502 });
503 }
504 let patched = if patch.all {
505 existing.replace(&patch.old, &patch.new)
506 } else {
507 existing.replacen(&patch.old, &patch.new, 1)
508 };
509 next.sections.insert(key.clone(), patched);
510 modified_sections_patched.push(key);
511 }
512
513 let mut modified_metadata_set: Vec<String> = Vec::new();
514 for (key, value) in &args.metadata {
515 let parsed = parse_metadata_value(key.as_str(), value.as_str(), type_def.as_ref())?;
516 modified_metadata_set.push(key.clone());
517 next.metadata.insert(key.clone(), parsed);
518 }
519
520 let mut modified_metadata_unset: Vec<String> = Vec::new();
521 for key in args.metadata_unset {
522 let field_def = type_def.metadata_field(&key);
527 let is_required = field_def.map(|f| !f.optional).unwrap_or(false);
528 if is_required {
529 let (field_description, enum_values) = match field_def {
530 Some(f) => (
531 Some(f.description.clone()),
532 f.enum_values.clone().unwrap_or_default(),
533 ),
534 None => (None, Vec::new()),
535 };
536 return Err(EngineError::RequiredFieldUnset {
537 field: key,
538 entity_type: type_def.name.clone(),
539 field_description,
540 enum_values,
541 type_write_rules: type_def.write_rules.clone(),
542 on_create: false,
548 missing: Vec::new(),
553 });
554 }
555 if next.metadata.shift_remove(&key).is_some() {
556 modified_metadata_unset.push(key);
557 }
558 }
559
560 let today = today_iso();
569
570 let (synthesised_relations, self_link_ignored) =
581 super::synthesise_alias_relations(self, &prev_body_targets, &mut next)?;
582
583 let missing = super::scan_wikilinks_without_relation(&next)?;
589 if !missing.is_empty() {
590 return Err(EngineError::WikiLinkWithoutRelation {
591 from_id: id.to_string(),
592 missing: missing
593 .into_iter()
594 .map(|(section_key, target)| crate::engine::MissingWikiLink {
595 section_key,
596 target_id: target.to_string(),
597 })
598 .collect(),
599 });
600 }
601
602 let file_path = next.file_path.clone();
603
604 let markdown_pre_stamp = generate_markdown(&next, type_def.as_ref());
613
614 if !args.dry_run {
629 let prospective_hash = crate::entity::parser::compute_hash(&markdown_pre_stamp);
630 if prospective_hash == next.content_hash {
637 let modified_date = next
642 .metadata
643 .get("last_modified")
644 .and_then(|v| v.as_str().map(str::to_string))
645 .unwrap_or_default();
646 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
647 id: id.clone(),
648 title: next.title.clone(),
649 file_path,
650 content_hash: next.content_hash.clone(),
651 commit_sha: String::new(),
652 modified_date,
653 modified_sections: ModifiedSections::default(),
662 modified_metadata: ModifiedMetadata::default(),
663 prospective_hash: None,
664 orphan_stubs_removed: Vec::new(),
667 warnings: vec![WarningHint::UpdateNoop { id: id.clone() }],
668 relations_declared,
669 }));
670 }
671 }
672
673 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
676 let markdown = generate_markdown(&next, type_def.as_ref());
677
678 let mut warnings: Vec<WarningHint> = Vec::new();
679
680 let auto_stubbed: Vec<EntityId> = synthesised_relations
688 .iter()
689 .filter_map(|rel| {
690 if !self.store.contains(&rel.target) {
691 Some(rel.target.clone())
692 } else {
693 None
694 }
695 })
696 .collect();
697 if !auto_stubbed.is_empty() {
698 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
699 from: id.clone(),
700 stubs: auto_stubbed,
701 });
702 }
703 if self_link_ignored {
706 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
707 }
708
709 if args.dry_run {
716 let prospective = crate::entity::parser::compute_hash(&markdown);
717 let current_hash = next.content_hash.clone();
721 let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
722 today.clone()
723 } else {
724 String::new()
725 };
726 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
727 id: id.clone(),
728 title: next.title.clone(),
729 file_path,
730 content_hash: current_hash,
731 commit_sha: String::new(),
732 modified_date,
733 modified_sections: ModifiedSections {
734 replaced: modified_sections,
735 appended: modified_sections_appended,
736 patched: modified_sections_patched,
737 },
738 modified_metadata: ModifiedMetadata {
739 set: modified_metadata_set,
740 unset: modified_metadata_unset,
741 },
742 prospective_hash: Some(prospective),
743 orphan_stubs_removed: Vec::new(),
746 warnings,
747 relations_declared: relations_declared.clone(),
748 }));
749 }
750
751 let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
758 today.clone()
759 } else {
760 String::new()
761 };
762
763 Ok(PrepareOutcome::Prepared(PreparedUpdate {
764 mount_idx,
765 id: id.clone(),
766 mem,
767 type_def,
768 file_path,
769 markdown,
770 prev_body_targets,
771 modified_date,
772 modified_sections: ModifiedSections {
773 replaced: modified_sections,
774 appended: modified_sections_appended,
775 patched: modified_sections_patched,
776 },
777 modified_metadata: ModifiedMetadata {
778 set: modified_metadata_set,
779 unset: modified_metadata_unset,
780 },
781 warnings,
784 relations_declared,
785 }))
786 }
787
788 pub fn batch_update(
821 &mut self,
822 updates: Vec<(UpdateEntityArgs, Option<String>)>,
823 actor: Actor,
824 client: Option<&ClientId>,
825 ) -> Result<crate::ops::BatchResult, EngineError> {
826 if updates.is_empty() {
827 return Ok(crate::ops::BatchResult {
828 applied: true,
829 results: Vec::new(),
830 succeeded: 0,
831 failed: 0,
832 commit_sha: String::new(),
833 });
834 }
835
836 let mut touched_mems: Vec<String> = updates
843 .iter()
844 .map(|(a, _)| a.id.mem().to_string())
845 .collect();
846 touched_mems.sort();
847 touched_mems.dedup();
848 for v in &touched_mems {
849 self.reload_if_stale(Some(v));
850 }
851
852 let store_snapshot = self.store.clone();
858
859 enum Item {
864 Prepared,
865 Noop,
866 }
867 let mut items: Vec<(EntityId, Item)> = Vec::with_capacity(updates.len());
868 let mut prepared: Vec<PreparedUpdate> = Vec::new();
869 let mut notes: Vec<Option<String>> = Vec::new();
870
871 let mut iter = updates.into_iter();
873 while let Some((args, note)) = iter.next() {
874 let id = args.id.clone();
875 match self.prepare_update(args) {
876 Ok(PrepareOutcome::Done(_)) => {
877 items.push((id, Item::Noop));
879 }
880 Ok(PrepareOutcome::Prepared(p)) => {
881 prepared.push(p);
882 notes.push(note);
883 items.push((id, Item::Prepared));
884 }
885 Err(e) => {
886 self.store = store_snapshot;
888 self.discard_all_pending();
889 let mut results: Vec<crate::ops::BatchEntry> = items
890 .into_iter()
891 .map(|(prev_id, _)| crate::ops::BatchEntry {
892 id: prev_id,
893 action: "not_applied".to_string(),
894 error: None,
895 })
896 .collect();
897 results.push(crate::ops::BatchEntry {
898 id,
899 action: "error".to_string(),
900 error: Some(batch_error_envelope(&e)),
901 });
902 for (rem_args, _) in iter {
904 results.push(crate::ops::BatchEntry {
905 id: rem_args.id,
906 action: "not_applied".to_string(),
907 error: None,
908 });
909 }
910 return Ok(crate::ops::BatchResult {
911 applied: false,
912 results,
913 succeeded: 0,
914 failed: 1,
915 commit_sha: String::new(),
916 });
917 }
918 }
919 }
920
921 for p in &prepared {
924 if let Err(e) = self.mounts[p.mount_idx]
925 .backend
926 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
927 {
928 self.store = store_snapshot;
929 self.discard_all_pending();
930 return Err(e.into());
931 }
932 }
933
934 let mut distinct_mounts: Vec<usize> = Vec::new();
936 for p in &prepared {
937 if !distinct_mounts.contains(&p.mount_idx) {
938 distinct_mounts.push(p.mount_idx);
939 }
940 }
941 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
942 for &m in &distinct_mounts {
943 let entity_ids: Vec<String> = prepared
944 .iter()
945 .filter(|p| p.mount_idx == m)
946 .map(|p| p.id.to_string())
947 .collect();
948 let count = entity_ids.len();
949 let subject = format!("memstead: batch-update ({count} entities)");
950 let ctx = CommitContext {
951 actor,
952 client: client.cloned(),
953 tool: Some("batch_update"),
954 note: None,
955 logical_operation_id: None,
956 entity_ids: Some(entity_ids),
960 };
961 match self.mounts[m].backend.commit(&subject, &ctx) {
962 Ok(sha) => mount_commits.push((m, sha)),
963 Err(e) => {
964 self.store = store_snapshot;
968 self.discard_all_pending();
969 return Err(e.into());
970 }
971 }
972 }
973
974 for (p, note) in prepared.iter().zip(notes.iter()) {
978 let commit_sha = mount_commits
979 .iter()
980 .find(|(m, _)| *m == p.mount_idx)
981 .map(|(_, s)| s.clone())
982 .unwrap_or_default();
983 self.mounts[p.mount_idx]
984 .backend
985 .append_provenance(&Provenance::new(
986 std::time::SystemTime::now(),
987 ProvenanceKind::Update,
988 Some(p.id.to_string()),
989 actor,
990 client.cloned(),
991 note.clone(),
992 ))?;
993 self.record_self_write(p.mount_idx, &commit_sha);
994 self.apply_prepared_to_store(p)?;
995 }
996
997 self.invalidate_communities();
998 self.invalidate_search_indexes();
999
1000 let commit_sha = mount_commits
1003 .last()
1004 .map(|(_, s)| s.clone())
1005 .unwrap_or_default();
1006 let succeeded = items.len();
1007 let results: Vec<crate::ops::BatchEntry> = items
1008 .into_iter()
1009 .map(|(id, item)| crate::ops::BatchEntry {
1010 id,
1011 action: match item {
1012 Item::Prepared => "updated".to_string(),
1013 Item::Noop => "noop".to_string(),
1014 },
1015 error: None,
1016 })
1017 .collect();
1018
1019 Ok(crate::ops::BatchResult {
1020 applied: true,
1021 results,
1022 succeeded,
1023 failed: 0,
1024 commit_sha,
1025 })
1026 }
1027
1028 fn discard_all_pending(&self) {
1033 for mount in &self.mounts {
1034 let _ = mount.backend.discard_pending();
1035 }
1036 }
1037
1038 pub fn update_entity_with_ctx(
1041 &mut self,
1042 args: UpdateEntityArgs,
1043 ctx: &CommitContext<'_>,
1044 ) -> Result<UpdateEntityOutcome, EngineError> {
1045 self.update_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1046 }
1047}
1048
1049fn batch_error_envelope(err: &EngineError) -> crate::ops::BatchError {
1056 let code = err.code().to_string();
1062 let message = err.to_string();
1063 let details = err.details();
1064 crate::ops::BatchError {
1065 code,
1066 message,
1067 details,
1068 }
1069}
1070
1071fn apply_declare_relations(
1086 engine: &mut Engine,
1087 next: &mut Entity,
1088 declarations: &[crate::ops::RelateArg],
1089 source_mem: &str,
1090 source_mount_idx: usize,
1091 type_def: &memstead_schema::TypeDefinition,
1092 schema: &memstead_schema::Schema,
1093) -> Result<Vec<RelationDeclared>, EngineError> {
1094 let _ = type_def; let _ = source_mount_idx; let mut declared: Vec<RelationDeclared> = Vec::with_capacity(declarations.len());
1097 for rel in declarations {
1098 let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
1101 .unwrap_or_else(|_| rel.rel_type.clone());
1102
1103 validate_relation_target_grammar(&rel.to)?;
1104
1105 let target_mem = rel.to.mem().to_string();
1106 super::validate_cross_mem_add_policy(engine, source_mem, &target_mem)?;
1107 if target_mem != source_mem
1108 && let Some(mount) = engine.mount(&target_mem)
1109 && mount.capability == MountCapability::ReadOnly
1110 && !engine.store.contains(&rel.to)
1111 {
1112 return Err(EngineError::CrossMemTargetNotFound {
1113 target_id: rel.to.to_string(),
1114 target_mem: target_mem.clone(),
1115 });
1116 }
1117
1118 let target_type = engine
1127 .store
1128 .get(&rel.to)
1129 .map(|e| e.entity_type.clone())
1130 .filter(|t| !t.is_empty());
1131 let _ = schema; let _ = super::route_edge_validation(
1133 engine,
1134 &canonical,
1135 next.entity_type.as_str(),
1136 target_type.as_deref(),
1137 source_mem,
1138 &target_mem,
1139 &next.id,
1140 &rel.to,
1141 true,
1142 )?;
1143
1144 let normalised_description =
1149 crate::entity::normalise_description(rel.description.as_deref());
1150 super::validate_description_posture(
1151 engine,
1152 &canonical,
1153 normalised_description.as_deref(),
1154 source_mem,
1155 &target_mem,
1156 &next.id,
1157 &rel.to,
1158 )?;
1159 super::validate_manual_authoring_posture(
1162 engine, &canonical, source_mem, &next.id, &rel.to,
1163 )?;
1164
1165 let exists = next
1170 .relationships
1171 .iter()
1172 .any(|r| r.rel_type == canonical && r.target == rel.to);
1173 if !exists {
1174 next.relationships.push(Relationship {
1175 rel_type: canonical.clone(),
1176 target: rel.to.clone(),
1177 description: normalised_description,
1178 });
1179 }
1180
1181 let target_was_stubbed = !engine.store.contains(&rel.to);
1186 if target_was_stubbed && !exists {
1187 engine.store.upsert(
1188 rel.to.clone(),
1189 make_stub(&rel.to, crate::entity::StubKind::ForwardReference),
1190 );
1191 }
1192
1193 declared.push(RelationDeclared {
1194 rel_type: canonical,
1195 target: rel.to.clone(),
1196 target_was_stubbed,
1197 });
1198 }
1199 Ok(declared)
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204
1205 use indexmap::IndexMap;
1206 use tempfile::TempDir;
1207
1208 use crate::backend::MemBackend;
1209 use crate::engine::test_helpers::*;
1210 use crate::engine::{
1211 CreateEntityArgs, Engine, EngineError, RelateEntityArgs, UpdateEntityArgs,
1212 };
1213 use crate::entity::EntityId;
1214
1215 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1216 use crate::vcs::Actor;
1217
1218 #[test]
1219 fn batch_update_empty_batch_returns_zero_counts() {
1220 let tmp = TempDir::new().unwrap();
1223 let mem_dir = tmp.path().to_path_buf();
1224 let writer = FilesystemMemWriter::new(mem_dir.clone());
1225 let mut engine = Engine::from_mounts(vec![(
1226 folder_mount("specs", mem_dir),
1227 Box::new(writer) as Box<dyn MemBackend>,
1228 )])
1229 .unwrap();
1230
1231 let result = engine.batch_update(Vec::new(), Actor::Cli, None).unwrap();
1232 assert!(result.applied, "empty batch is a vacuous success");
1233 assert_eq!(result.results.len(), 0);
1234 assert_eq!(result.succeeded, 0);
1235 assert_eq!(result.failed, 0);
1236 assert_eq!(result.commit_sha, "");
1237 }
1238
1239 #[test]
1240 fn batch_update_refuses_whole_batch_when_one_item_fails() {
1241 let tmp = TempDir::new().unwrap();
1248 let mem_dir = tmp.path().to_path_buf();
1249 let writer = FilesystemMemWriter::new(mem_dir.clone());
1250 let mut engine = Engine::from_mounts(vec![(
1251 folder_mount("specs", mem_dir),
1252 Box::new(writer) as Box<dyn MemBackend>,
1253 )])
1254 .unwrap();
1255
1256 let create_args = CreateEntityArgs {
1258 mem: "specs".to_string(),
1259 title: "Seed".to_string(),
1260 entity_type: "spec".to_string(),
1261 sections: IndexMap::from_iter([
1262 ("identity".to_string(), "seed identity".to_string()),
1263 ("purpose".to_string(), "seed purpose".to_string()),
1264 ]),
1265 metadata: IndexMap::new(),
1266 relations: Vec::new(),
1267 dry_run: false,
1268 };
1269 let created = engine
1270 .create_entity(create_args, Actor::Cli, None, None)
1271 .unwrap();
1272
1273 let valid_update = UpdateEntityArgs {
1275 id: created.id.clone(),
1276 expected_hash: Some(created.content_hash.clone()),
1277 sections: IndexMap::from_iter([("identity".to_string(), "updated body".to_string())]),
1278 append_sections: IndexMap::new(),
1279 patch_sections: IndexMap::new(),
1280 metadata: IndexMap::new(),
1281 metadata_unset: Vec::new(),
1282 declare_relations: Vec::new(),
1283 dry_run: false,
1284 relations_unset: Vec::new(),
1285 };
1286 let missing_update = UpdateEntityArgs {
1287 id: EntityId("specs--nonexistent".to_string()),
1288 expected_hash: None,
1289 sections: IndexMap::new(),
1290 append_sections: IndexMap::new(),
1291 patch_sections: IndexMap::new(),
1292 metadata: IndexMap::new(),
1293 metadata_unset: Vec::new(),
1294 declare_relations: Vec::new(),
1295 dry_run: false,
1296 relations_unset: Vec::new(),
1297 };
1298
1299 let result = engine
1300 .batch_update(
1301 vec![(valid_update, None), (missing_update, None)],
1302 Actor::Cli,
1303 None,
1304 )
1305 .unwrap();
1306 assert!(!result.applied, "a failing item must refuse the batch");
1308 assert_eq!(result.results.len(), 2);
1309 assert_eq!(result.succeeded, 0);
1310 assert_eq!(result.failed, 1);
1311 assert_eq!(result.commit_sha, "", "refused batch must not commit");
1312 assert_eq!(result.results[0].action, "not_applied");
1315 assert!(result.results[0].error.is_none());
1316 assert_eq!(result.results[1].action, "error");
1318 let err = result.results[1]
1319 .error
1320 .as_ref()
1321 .expect("failed entry must carry a structured error envelope");
1322 assert_eq!(err.code, "ENTITY_NOT_FOUND");
1323 assert!(err.message.contains("not found"), "got: {}", err.message);
1324
1325 let seed = engine.get_entity(&created.id).unwrap();
1328 assert_eq!(
1329 seed.sections.get("identity").map(String::as_str),
1330 Some("seed identity"),
1331 "refused batch must leave the in-memory store untouched",
1332 );
1333 assert_eq!(
1334 seed.content_hash, created.content_hash,
1335 "refused batch must not change the entity's content hash",
1336 );
1337 }
1338
1339 #[test]
1340 fn batch_update_applies_all_valid_items_as_one_commit() {
1341 let tmp = TempDir::new().unwrap();
1345 let mem_dir = tmp.path().to_path_buf();
1346 let writer = FilesystemMemWriter::new(mem_dir.clone());
1347 let mut engine = Engine::from_mounts(vec![(
1348 folder_mount("specs", mem_dir),
1349 Box::new(writer) as Box<dyn MemBackend>,
1350 )])
1351 .unwrap();
1352
1353 let mk = |title: &str| CreateEntityArgs {
1354 mem: "specs".to_string(),
1355 title: title.to_string(),
1356 entity_type: "spec".to_string(),
1357 sections: IndexMap::from_iter([
1358 ("identity".to_string(), "id".to_string()),
1359 ("purpose".to_string(), "purp".to_string()),
1360 ]),
1361 metadata: IndexMap::new(),
1362 relations: Vec::new(),
1363 dry_run: false,
1364 };
1365 let a = engine
1366 .create_entity(mk("A"), Actor::Cli, None, None)
1367 .unwrap();
1368 let b = engine
1369 .create_entity(mk("B"), Actor::Cli, None, None)
1370 .unwrap();
1371
1372 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
1373 id,
1374 expected_hash: Some(hash),
1375 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
1376 append_sections: IndexMap::new(),
1377 patch_sections: IndexMap::new(),
1378 metadata: IndexMap::new(),
1379 metadata_unset: Vec::new(),
1380 declare_relations: Vec::new(),
1381 dry_run: false,
1382 relations_unset: Vec::new(),
1383 };
1384
1385 let result = engine
1386 .batch_update(
1387 vec![
1388 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
1389 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
1390 ],
1391 Actor::Cli,
1392 None,
1393 )
1394 .unwrap();
1395 assert!(result.applied);
1396 assert_eq!(result.succeeded, 2);
1397 assert_eq!(result.failed, 0);
1398 assert!(
1399 !result.commit_sha.is_empty(),
1400 "applied batch carries the commit"
1401 );
1402 assert!(result.results.iter().all(|e| e.action == "updated"));
1403 assert_eq!(
1405 engine
1406 .get_entity(&a.id)
1407 .unwrap()
1408 .sections
1409 .get("identity")
1410 .map(String::as_str),
1411 Some("A body"),
1412 );
1413 assert_eq!(
1414 engine
1415 .get_entity(&b.id)
1416 .unwrap()
1417 .sections
1418 .get("identity")
1419 .map(String::as_str),
1420 Some("B body"),
1421 );
1422 }
1423
1424 #[test]
1425 fn batch_update_rolls_back_in_memory_store_auto_stub_on_refusal() {
1426 let tmp = TempDir::new().unwrap();
1434 let mem_dir = tmp.path().to_path_buf();
1435 let writer = FilesystemMemWriter::new(mem_dir.clone());
1436 let mut engine = Engine::from_mounts(vec![(
1437 folder_mount("specs", mem_dir.clone()),
1438 Box::new(writer) as Box<dyn MemBackend>,
1439 )])
1440 .unwrap();
1441 engine.set_workspace_root(mem_dir);
1442 let (actor, client) = cli_actor();
1443
1444 let a = engine
1445 .create_entity(
1446 empty_create_args("specs", "Anchor"),
1447 actor,
1448 Some(&client),
1449 None,
1450 )
1451 .unwrap();
1452
1453 let stub_target = EntityId::new("specs", "would-be-stub");
1454 let item1 = UpdateEntityArgs {
1455 relations_unset: Vec::new(),
1456 id: a.id.clone(),
1457 expected_hash: Some(a.content_hash.clone()),
1458 sections: IndexMap::new(),
1459 append_sections: IndexMap::new(),
1460 patch_sections: IndexMap::new(),
1461 metadata: IndexMap::new(),
1462 metadata_unset: Vec::new(),
1463 declare_relations: vec![crate::ops::RelateArg {
1464 rel_type: "USES".to_string(),
1465 to: stub_target.clone(),
1466 description: None,
1467 }],
1468 dry_run: false,
1469 };
1470 let item2 = UpdateEntityArgs {
1471 id: EntityId::new("specs", "nonexistent"),
1472 expected_hash: None,
1473 sections: IndexMap::from_iter([("identity".to_string(), "x".to_string())]),
1474 append_sections: IndexMap::new(),
1475 patch_sections: IndexMap::new(),
1476 metadata: IndexMap::new(),
1477 metadata_unset: Vec::new(),
1478 declare_relations: Vec::new(),
1479 dry_run: false,
1480 relations_unset: Vec::new(),
1481 };
1482
1483 assert!(engine.get_entity(&stub_target).is_none());
1485
1486 let result = engine
1487 .batch_update(vec![(item1, None), (item2, None)], actor, Some(&client))
1488 .unwrap();
1489 assert!(!result.applied, "missing item 2 must refuse the batch");
1490
1491 assert!(
1494 engine.get_entity(&stub_target).is_none(),
1495 "refused batch must roll the in-memory auto-stub back out of the store",
1496 );
1497 let anchor = engine.get_entity(&a.id).unwrap();
1499 assert!(
1500 !anchor.relationships.iter().any(|r| r.target == stub_target),
1501 "refused batch must not leave the declared relation on the anchor",
1502 );
1503 }
1504
1505 #[test]
1506 fn update_entity_replaces_a_section_and_logs_provenance() {
1507 let tmp = TempDir::new().unwrap();
1508 let (mut engine, seeded) = engine_with_seed(&tmp, "Updatable");
1509 let (actor, client) = cli_actor();
1510
1511 let mut sections = IndexMap::new();
1512 sections.insert("identity".to_string(), "Updated body.".to_string());
1513
1514 let outcome = engine
1515 .update_entity(
1516 UpdateEntityArgs {
1517 id: seeded.id.clone(),
1518 expected_hash: Some(seeded.content_hash.clone()),
1519 sections,
1520 append_sections: IndexMap::new(),
1521 patch_sections: IndexMap::new(),
1522 metadata: IndexMap::new(),
1523 metadata_unset: Vec::new(),
1524 declare_relations: Vec::new(),
1525 dry_run: false,
1526 relations_unset: Vec::new(),
1527 },
1528 actor,
1529 Some(&client),
1530 Some("section update"),
1531 )
1532 .unwrap();
1533
1534 assert_eq!(
1535 outcome.modified_sections.replaced,
1536 vec!["identity".to_string()]
1537 );
1538 assert_ne!(
1539 outcome.content_hash, seeded.content_hash,
1540 "hash must change"
1541 );
1542 let entity = engine.get_entity(&seeded.id).unwrap();
1544 assert!(
1545 entity
1546 .sections
1547 .get("identity")
1548 .unwrap()
1549 .contains("Updated body.")
1550 );
1551 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
1553 assert!(log.contains("\"kind\":\"update\""));
1554 assert!(log.contains("\"note\":\"section update\""));
1555 }
1556
1557 #[test]
1558 fn update_entity_rejects_hash_mismatch() {
1559 let tmp = TempDir::new().unwrap();
1560 let (mut engine, seeded) = engine_with_seed(&tmp, "Hash Guarded");
1561 let (actor, client) = cli_actor();
1562 let err = engine
1563 .update_entity(
1564 UpdateEntityArgs {
1565 id: seeded.id.clone(),
1566 expected_hash: Some("wrong-hash".to_string()),
1567 sections: IndexMap::new(),
1568 append_sections: IndexMap::new(),
1569 patch_sections: IndexMap::new(),
1570 metadata: IndexMap::new(),
1571 metadata_unset: Vec::new(),
1572 declare_relations: Vec::new(),
1573 dry_run: false,
1574 relations_unset: Vec::new(),
1575 },
1576 actor,
1577 Some(&client),
1578 None,
1579 )
1580 .unwrap_err();
1581 match err {
1582 EngineError::HashMismatch {
1583 id,
1584 current,
1585 is_stub,
1586 } => {
1587 assert_eq!(id, seeded.id.to_string());
1588 assert_eq!(current, seeded.content_hash);
1589 assert!(!is_stub, "real entity must not flag as stub");
1590 }
1591 other => panic!("expected HashMismatch, got {other:?}"),
1592 }
1593 }
1594
1595 #[test]
1596 fn update_entity_rejects_unknown_id() {
1597 let tmp = TempDir::new().unwrap();
1598 let (mut engine, _) = engine_with_seed(&tmp, "Anchor");
1599 let (actor, client) = cli_actor();
1600 let err = engine
1601 .update_entity(
1602 UpdateEntityArgs {
1603 id: crate::EntityId::new("specs", "ghost"),
1604 expected_hash: None,
1605 sections: IndexMap::new(),
1606 append_sections: IndexMap::new(),
1607 patch_sections: IndexMap::new(),
1608 metadata: IndexMap::new(),
1609 metadata_unset: Vec::new(),
1610 declare_relations: Vec::new(),
1611 dry_run: false,
1612 relations_unset: Vec::new(),
1613 },
1614 actor,
1615 Some(&client),
1616 None,
1617 )
1618 .unwrap_err();
1619 assert!(matches!(err, EngineError::NotFound { .. }));
1620 }
1621
1622 #[test]
1623 fn update_entity_rejects_read_only_mount() {
1624 let tmp = TempDir::new().unwrap();
1625 let archive_path = build_archive(
1626 tmp.path(),
1627 "ext",
1628 &[(
1629 "a.md",
1630 b"---\ntype: spec\n---\n# A\n\n## Identity\n\nbody.\n",
1631 )],
1632 );
1633 let mut engine = Engine::from_mounts(vec![(
1634 archive_mount("external", archive_path.clone()),
1635 Box::new(ArchiveBackend::new(archive_path)),
1636 )])
1637 .unwrap();
1638 let (actor, client) = cli_actor();
1639 let id = crate::EntityId::new("external", "a");
1640 let err = engine
1641 .update_entity(
1642 UpdateEntityArgs {
1643 id,
1644 expected_hash: None,
1645 sections: IndexMap::new(),
1646 append_sections: IndexMap::new(),
1647 patch_sections: IndexMap::new(),
1648 metadata: IndexMap::new(),
1649 metadata_unset: Vec::new(),
1650 declare_relations: Vec::new(),
1651 dry_run: false,
1652 relations_unset: Vec::new(),
1653 },
1654 actor,
1655 Some(&client),
1656 None,
1657 )
1658 .unwrap_err();
1659 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "external"));
1660 }
1661
1662 #[test]
1663 fn update_entity_patches_section_with_find_and_replace() {
1664 let tmp = TempDir::new().unwrap();
1665 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Subject");
1666 let (actor, client) = cli_actor();
1667
1668 let mut replace = IndexMap::new();
1671 replace.insert("identity".to_string(), "hello world hello".to_string());
1672 let replaced = engine
1673 .update_entity(
1674 UpdateEntityArgs {
1675 id: seeded.id.clone(),
1676 expected_hash: Some(seeded.content_hash.clone()),
1677 sections: replace,
1678 append_sections: IndexMap::new(),
1679 patch_sections: IndexMap::new(),
1680 metadata: IndexMap::new(),
1681 metadata_unset: Vec::new(),
1682 declare_relations: Vec::new(),
1683 dry_run: false,
1684 relations_unset: Vec::new(),
1685 },
1686 actor,
1687 Some(&client),
1688 None,
1689 )
1690 .unwrap();
1691
1692 let mut patches = IndexMap::new();
1694 patches.insert(
1695 "identity".to_string(),
1696 crate::ops::PatchArg {
1697 old: "hello".to_string(),
1698 new: "HI".to_string(),
1699 all: false,
1700 },
1701 );
1702 let outcome = engine
1703 .update_entity(
1704 UpdateEntityArgs {
1705 id: seeded.id.clone(),
1706 expected_hash: Some(replaced.content_hash.clone()),
1707 sections: IndexMap::new(),
1708 append_sections: IndexMap::new(),
1709 patch_sections: patches,
1710 metadata: IndexMap::new(),
1711 metadata_unset: Vec::new(),
1712 declare_relations: Vec::new(),
1713 dry_run: false,
1714 relations_unset: Vec::new(),
1715 },
1716 actor,
1717 Some(&client),
1718 None,
1719 )
1720 .unwrap();
1721 assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
1722 let body = engine
1723 .get_entity(&seeded.id)
1724 .unwrap()
1725 .sections
1726 .get("identity")
1727 .unwrap()
1728 .clone();
1729 assert!(body.contains("HI world hello"), "first-only: {body:?}");
1730 }
1731
1732 #[test]
1733 fn update_entity_patch_rejects_missing_old_substring() {
1734 let tmp = TempDir::new().unwrap();
1735 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Miss");
1736 let (actor, client) = cli_actor();
1737 let mut patches = IndexMap::new();
1738 patches.insert(
1739 "identity".to_string(),
1740 crate::ops::PatchArg {
1741 old: "this-substring-does-not-exist".to_string(),
1742 new: "nope".to_string(),
1743 all: false,
1744 },
1745 );
1746 let err = engine
1747 .update_entity(
1748 UpdateEntityArgs {
1749 id: seeded.id.clone(),
1750 expected_hash: Some(seeded.content_hash.clone()),
1751 sections: IndexMap::new(),
1752 append_sections: IndexMap::new(),
1753 patch_sections: patches,
1754 metadata: IndexMap::new(),
1755 metadata_unset: Vec::new(),
1756 declare_relations: Vec::new(),
1757 dry_run: false,
1758 relations_unset: Vec::new(),
1759 },
1760 actor,
1761 Some(&client),
1762 None,
1763 )
1764 .unwrap_err();
1765 match err {
1766 EngineError::PatchOldNotFound { section, .. } => {
1767 assert_eq!(section, "identity");
1768 }
1769 other => panic!("expected PatchOldNotFound, got {other:?}"),
1770 }
1771 }
1772
1773 #[test]
1774 fn update_entity_appends_to_existing_section_with_newline_separator() {
1775 let tmp = TempDir::new().unwrap();
1776 let (mut engine, seeded) = engine_with_seed(&tmp, "Append Subject");
1777 let (actor, client) = cli_actor();
1778
1779 let mut appends = IndexMap::new();
1780 appends.insert("identity".to_string(), "appended tail.".to_string());
1781
1782 let outcome = engine
1783 .update_entity(
1784 UpdateEntityArgs {
1785 id: seeded.id.clone(),
1786 expected_hash: Some(seeded.content_hash.clone()),
1787 sections: IndexMap::new(),
1788 append_sections: appends,
1789 patch_sections: IndexMap::new(),
1790 metadata: IndexMap::new(),
1791 metadata_unset: Vec::new(),
1792 declare_relations: Vec::new(),
1793 dry_run: false,
1794 relations_unset: Vec::new(),
1795 },
1796 actor,
1797 Some(&client),
1798 None,
1799 )
1800 .unwrap();
1801
1802 assert_eq!(outcome.modified_sections.appended, vec!["identity"]);
1805 assert!(outcome.modified_sections.replaced.is_empty());
1806
1807 let updated = engine.get_entity(&seeded.id).unwrap();
1809 let body = updated.sections.get("identity").expect("identity section");
1810 assert!(
1811 body.contains("appended tail."),
1812 "appended body missing: {body:?}"
1813 );
1814 }
1815
1816 #[test]
1823 fn update_entity_against_stub_surfaces_typed_stub_not_updatable() {
1824 let tmp = TempDir::new().unwrap();
1825 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1826 let (actor, client) = cli_actor();
1827 let stub_id = crate::EntityId::new("specs", "stub-update-target");
1830 engine
1831 .relate_entity(
1832 RelateEntityArgs {
1833 source: source.id.clone(),
1834 expected_hash: Some(source.content_hash.clone()),
1835 rel_type: "USES".to_string(),
1836 target: stub_id.clone(),
1837 remove: false,
1838 description: None,
1839 },
1840 actor,
1841 Some(&client),
1842 None,
1843 )
1844 .unwrap();
1845
1846 let err = engine
1847 .update_entity(
1848 UpdateEntityArgs {
1849 id: stub_id.clone(),
1850 expected_hash: Some(String::new()),
1851 sections: IndexMap::from_iter([("identity".to_string(), "body".to_string())]),
1852 append_sections: IndexMap::new(),
1853 patch_sections: IndexMap::new(),
1854 metadata: IndexMap::new(),
1855 metadata_unset: Vec::new(),
1856 declare_relations: Vec::new(),
1857 dry_run: false,
1858 relations_unset: Vec::new(),
1859 },
1860 actor,
1861 Some(&client),
1862 None,
1863 )
1864 .unwrap_err();
1865 match err {
1866 EngineError::StubNotUpdatable { id } => assert_eq!(id, stub_id.to_string()),
1867 other => panic!("expected StubNotUpdatable, got {other:?}"),
1868 }
1869 }
1870
1871 #[test]
1872 fn update_entity_rejects_conflicting_section_modes() {
1873 let tmp = TempDir::new().unwrap();
1874 let (mut engine, seeded) = engine_with_seed(&tmp, "Conflict");
1875 let (actor, client) = cli_actor();
1876
1877 let mut sections = IndexMap::new();
1878 sections.insert("identity".to_string(), "replace".to_string());
1879 let mut appends = IndexMap::new();
1880 appends.insert("identity".to_string(), "append".to_string());
1881
1882 let err = engine
1883 .update_entity(
1884 UpdateEntityArgs {
1885 id: seeded.id.clone(),
1886 expected_hash: Some(seeded.content_hash.clone()),
1887 sections,
1888 append_sections: appends,
1889 patch_sections: IndexMap::new(),
1890 metadata: IndexMap::new(),
1891 metadata_unset: Vec::new(),
1892 declare_relations: Vec::new(),
1893 dry_run: false,
1894 relations_unset: Vec::new(),
1895 },
1896 actor,
1897 Some(&client),
1898 None,
1899 )
1900 .unwrap_err();
1901
1902 match err {
1903 EngineError::ConflictingSectionModes { section, modes } => {
1904 assert_eq!(section, "identity");
1905 assert_eq!(modes, vec!["sections", "append_sections"]);
1906 }
1907 other => panic!("expected ConflictingSectionModes, got {other:?}"),
1908 }
1909 }
1910
1911 #[test]
1912 fn update_entity_rejects_overlapping_metadata_and_metadata_unset_keys() {
1913 let tmp = TempDir::new().unwrap();
1918 let (mut engine, seeded) = engine_with_seed(&tmp, "Overlap Subject");
1919 let (actor, client) = cli_actor();
1920
1921 let mut metadata = IndexMap::new();
1922 metadata.insert("tags".to_string(), "foo".to_string());
1926
1927 let err = engine
1928 .update_entity(
1929 UpdateEntityArgs {
1930 id: seeded.id.clone(),
1931 expected_hash: Some(seeded.content_hash.clone()),
1932 sections: IndexMap::new(),
1933 append_sections: IndexMap::new(),
1934 patch_sections: IndexMap::new(),
1935 metadata,
1936 metadata_unset: vec!["tags".to_string()],
1937 declare_relations: Vec::new(),
1938 dry_run: false,
1939 relations_unset: Vec::new(),
1940 },
1941 actor,
1942 Some(&client),
1943 None,
1944 )
1945 .unwrap_err();
1946 match err {
1947 EngineError::SetAndUnsetConflict { keys } => {
1948 assert_eq!(keys, vec!["tags".to_string()]);
1949 }
1950 other => panic!("expected SetAndUnsetConflict, got {other:?}"),
1951 }
1952 }
1953
1954 #[test]
1955 fn update_entity_pointer_schema_auto_synthesises_references_from_body_link() {
1956 use crate::EntityId;
1964 use crate::engine::UpdateEntityArgs;
1965 use indexmap::IndexMap;
1966 use tempfile::TempDir;
1967
1968 let tmp = TempDir::new().unwrap();
1969 let mem_dir = tmp.path().to_path_buf();
1970 let writer = FilesystemMemWriter::new(mem_dir.clone());
1971 let mut engine = Engine::from_mounts(vec![(
1972 folder_mount("specs", mem_dir.clone()),
1973 Box::new(writer) as Box<dyn MemBackend>,
1974 )])
1975 .unwrap();
1976 engine.set_workspace_root(mem_dir.clone());
1977 let (actor, client) = cli_actor();
1978
1979 let target = engine
1980 .create_entity(
1981 empty_create_args("specs", "Target"),
1982 actor,
1983 Some(&client),
1984 None,
1985 )
1986 .unwrap();
1987 let source = engine
1988 .create_entity(
1989 empty_create_args("specs", "Source"),
1990 actor,
1991 Some(&client),
1992 None,
1993 )
1994 .unwrap();
1995
1996 let mut sections: IndexMap<String, String> = IndexMap::new();
1997 sections.insert(
1998 "purpose".to_string(),
1999 "see [[target]] for context".to_string(),
2000 );
2001 let outcome = engine
2002 .update_entity(
2003 UpdateEntityArgs {
2004 id: source.id.clone(),
2005 expected_hash: Some(source.content_hash.clone()),
2006 sections,
2007 append_sections: IndexMap::new(),
2008 patch_sections: IndexMap::new(),
2009 metadata: IndexMap::new(),
2010 metadata_unset: Vec::new(),
2011 declare_relations: Vec::new(),
2012 dry_run: false,
2013 relations_unset: Vec::new(),
2014 },
2015 actor,
2016 Some(&client),
2017 None,
2018 )
2019 .expect("auto-synthesis must satisfy the alias-existence invariant");
2020 assert!(
2022 outcome
2023 .modified_sections
2024 .replaced
2025 .iter()
2026 .any(|s| s == "purpose"),
2027 );
2028 let in_mem = engine.get_entity(&source.id).unwrap();
2029 assert_eq!(
2030 in_mem
2031 .sections
2032 .get("purpose")
2033 .map(String::as_str)
2034 .unwrap_or(""),
2035 "see [[target]] for context",
2036 );
2037 assert!(
2039 in_mem
2040 .relationships
2041 .iter()
2042 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2043 "synthesis must emit REFERENCES → target; relationships: {:?}",
2044 in_mem.relationships,
2045 );
2046 let _ = EntityId::new("specs", "x");
2048 }
2049
2050 #[test]
2051 fn update_entity_declare_relations_passes_strict_validator_in_one_call() {
2052 use crate::engine::UpdateEntityArgs;
2059 use crate::ops::RelateArg;
2060 use indexmap::IndexMap;
2061 use tempfile::TempDir;
2062
2063 let tmp = TempDir::new().unwrap();
2064 let mem_dir = tmp.path().to_path_buf();
2065 let writer = FilesystemMemWriter::new(mem_dir.clone());
2066 let mut engine = Engine::from_mounts(vec![(
2067 folder_mount("specs", mem_dir.clone()),
2068 Box::new(writer) as Box<dyn MemBackend>,
2069 )])
2070 .unwrap();
2071 engine.set_workspace_root(mem_dir.clone());
2072 let (actor, client) = cli_actor();
2073
2074 let target = engine
2075 .create_entity(
2076 empty_create_args("specs", "Target"),
2077 actor,
2078 Some(&client),
2079 None,
2080 )
2081 .unwrap();
2082 let source = engine
2083 .create_entity(
2084 empty_create_args("specs", "Source"),
2085 actor,
2086 Some(&client),
2087 None,
2088 )
2089 .unwrap();
2090
2091 let mut sections: IndexMap<String, String> = IndexMap::new();
2099 sections.insert(
2100 "purpose".to_string(),
2101 "see [[target]] for context".to_string(),
2102 );
2103 let outcome = engine
2104 .update_entity(
2105 UpdateEntityArgs {
2106 relations_unset: Vec::new(),
2107 id: source.id.clone(),
2108 expected_hash: Some(source.content_hash.clone()),
2109 sections,
2110 append_sections: IndexMap::new(),
2111 patch_sections: IndexMap::new(),
2112 metadata: IndexMap::new(),
2113 metadata_unset: Vec::new(),
2114 dry_run: false,
2115 declare_relations: vec![RelateArg {
2116 rel_type: "USES".to_string(),
2117 to: target.id.clone(),
2118 description: None,
2119 }],
2120 },
2121 actor,
2122 Some(&client),
2123 None,
2124 )
2125 .expect("declare_relations + body update must succeed in one call");
2126
2127 assert_eq!(outcome.relations_declared.len(), 1);
2128 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
2129 assert_eq!(outcome.relations_declared[0].target, target.id);
2130 assert!(
2131 !outcome.relations_declared[0].target_was_stubbed,
2132 "target was already present in store; target_was_stubbed must be false"
2133 );
2134
2135 let in_mem = engine.get_entity(&source.id).unwrap();
2136 assert!(
2137 in_mem.relationships.iter().any(|r| r.target == target.id),
2138 "declared relation must land in entity.relationships; got {:?}",
2139 in_mem.relationships
2140 );
2141 }
2142
2143 #[test]
2144 fn update_entity_declare_relations_auto_stubs_absent_target() {
2145 use crate::EntityId;
2149 use crate::engine::UpdateEntityArgs;
2150 use crate::ops::RelateArg;
2151 use indexmap::IndexMap;
2152
2153 let tmp = TempDir::new().unwrap();
2154 let (mut engine, source) = engine_with_seed(&tmp, "Source");
2155 let (actor, client) = cli_actor();
2156 let absent_target = EntityId::new("specs", "not-yet-existing");
2157 assert!(!engine.store().contains(&absent_target));
2158
2159 let outcome = engine
2160 .update_entity(
2161 UpdateEntityArgs {
2162 relations_unset: Vec::new(),
2163 id: source.id.clone(),
2164 expected_hash: Some(source.content_hash.clone()),
2165 sections: IndexMap::new(),
2166 append_sections: IndexMap::new(),
2167 patch_sections: IndexMap::new(),
2168 metadata: IndexMap::new(),
2169 metadata_unset: Vec::new(),
2170 dry_run: false,
2171 declare_relations: vec![RelateArg {
2172 rel_type: "USES".to_string(),
2173 to: absent_target.clone(),
2174 description: None,
2175 }],
2176 },
2177 actor,
2178 Some(&client),
2179 None,
2180 )
2181 .unwrap();
2182
2183 assert_eq!(outcome.relations_declared.len(), 1);
2184 assert!(
2185 outcome.relations_declared[0].target_was_stubbed,
2186 "absent target must be auto-stubbed; got target_was_stubbed=false"
2187 );
2188 assert!(engine.store().contains(&absent_target));
2190 let stub = engine.get_entity(&absent_target).unwrap();
2191 assert!(stub.stub);
2192 }
2193
2194 #[test]
2195 fn update_entity_alias_synthesis_runs_unconditionally_for_pointer_schemas() {
2196 use crate::engine::UpdateEntityArgs;
2202 use indexmap::IndexMap;
2203 use tempfile::TempDir;
2204
2205 let tmp = TempDir::new().unwrap();
2206 let mem_dir = tmp.path().to_path_buf();
2207 let writer = FilesystemMemWriter::new(mem_dir.clone());
2208 let mut engine = Engine::from_mounts(vec![(
2209 folder_mount("specs", mem_dir.clone()),
2210 Box::new(writer) as Box<dyn MemBackend>,
2211 )])
2212 .unwrap();
2213 engine.set_workspace_root(mem_dir.clone());
2214 let (actor, client) = cli_actor();
2215 let target = engine
2216 .create_entity(
2217 empty_create_args("specs", "Target"),
2218 actor,
2219 Some(&client),
2220 None,
2221 )
2222 .unwrap();
2223 let source = engine
2224 .create_entity(
2225 empty_create_args("specs", "Source"),
2226 actor,
2227 Some(&client),
2228 None,
2229 )
2230 .unwrap();
2231
2232 let mut sections: IndexMap<String, String> = IndexMap::new();
2233 sections.insert(
2234 "purpose".to_string(),
2235 "see [[target]] for context".to_string(),
2236 );
2237 engine
2238 .update_entity(
2239 UpdateEntityArgs {
2240 id: source.id.clone(),
2241 expected_hash: Some(source.content_hash.clone()),
2242 sections,
2243 append_sections: IndexMap::new(),
2244 patch_sections: IndexMap::new(),
2245 metadata: IndexMap::new(),
2246 metadata_unset: Vec::new(),
2247 declare_relations: Vec::new(),
2248 dry_run: false,
2249 relations_unset: Vec::new(),
2250 },
2251 actor,
2252 Some(&client),
2253 None,
2254 )
2255 .expect("synthesis must back the wiki-link and let the body land");
2256 let in_mem = engine.get_entity(&source.id).unwrap();
2257 assert!(
2258 in_mem
2259 .relationships
2260 .iter()
2261 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2262 "synthesis must emit REFERENCES → target; relationships: {:?}",
2263 in_mem.relationships,
2264 );
2265 }
2266
2267 #[test]
2268 fn update_entity_dry_run_returns_prospective_hash_without_writing() {
2269 let tmp = TempDir::new().unwrap();
2270 let (mut engine, seeded) = engine_with_seed(&tmp, "Preview Subject");
2271 let (actor, client) = cli_actor();
2272 let original_hash = seeded.content_hash.clone();
2273
2274 let mut sections = IndexMap::new();
2275 sections.insert("identity".to_string(), "preview body".to_string());
2276
2277 let outcome = engine
2278 .update_entity(
2279 UpdateEntityArgs {
2280 id: seeded.id.clone(),
2281 expected_hash: Some("wrong-hash".to_string()),
2284 sections,
2285 append_sections: IndexMap::new(),
2286 patch_sections: IndexMap::new(),
2287 metadata: IndexMap::new(),
2288 metadata_unset: Vec::new(),
2289 declare_relations: Vec::new(),
2290 dry_run: true,
2291 relations_unset: Vec::new(),
2292 },
2293 actor,
2294 Some(&client),
2295 None,
2296 )
2297 .unwrap();
2298
2299 assert_eq!(outcome.content_hash, original_hash);
2302 let prospective = outcome
2303 .prospective_hash
2304 .expect("prospective_hash populated on dry_run");
2305 assert_ne!(prospective, original_hash);
2306 assert!(outcome.commit_sha.is_empty());
2307 let store_entity = engine.get_entity(&seeded.id).unwrap();
2309 assert_eq!(store_entity.content_hash, original_hash);
2310 }
2311
2312 #[test]
2331 fn references_edges_round_trip_across_full_crud_cycle() {
2332 let tmp = TempDir::new().unwrap();
2333 let mem_dir = tmp.path().to_path_buf();
2334 let writer = FilesystemMemWriter::new(mem_dir.clone());
2335 let mut engine = Engine::from_mounts(vec![(
2336 folder_mount("specs", mem_dir),
2337 Box::new(writer) as Box<dyn MemBackend>,
2338 )])
2339 .unwrap();
2340 let (actor, client) = cli_actor();
2341
2342 let foo = engine
2346 .create_entity(
2347 empty_create_args("specs", "Foo"),
2348 actor,
2349 Some(&client),
2350 None,
2351 )
2352 .unwrap();
2353 let bar = engine
2354 .create_entity(
2355 empty_create_args("specs", "Bar"),
2356 actor,
2357 Some(&client),
2358 None,
2359 )
2360 .unwrap();
2361
2362 let count_references = |engine: &Engine| -> usize {
2363 engine
2364 .store()
2365 .all_ids()
2366 .flat_map(|id| engine.store().outgoing(id))
2367 .filter(|e| e.rel_type == "REFERENCES")
2368 .count()
2369 };
2370
2371 let baseline_edges = engine.store().edge_count();
2372 let baseline_refs = count_references(&engine);
2373
2374 let mut sections = IndexMap::new();
2380 sections.insert(
2381 "identity".to_string(),
2382 "See [[foo]] and [[bar]] inline.".to_string(),
2383 );
2384 sections.insert("purpose".to_string(), "probe purpose".to_string());
2385 let probe = engine
2386 .create_entity(
2387 CreateEntityArgs {
2388 mem: "specs".to_string(),
2389 title: "Probe".to_string(),
2390 entity_type: "spec".to_string(),
2391 sections,
2392 metadata: IndexMap::new(),
2393 relations: Vec::new(),
2394 dry_run: false,
2395 },
2396 actor,
2397 Some(&client),
2398 None,
2399 )
2400 .unwrap();
2401 assert_eq!(count_references(&engine), baseline_refs + 2);
2402
2403 let relate1 = engine
2408 .relate_entity(
2409 RelateEntityArgs {
2410 source: probe.id.clone(),
2411 expected_hash: Some(probe.content_hash.clone()),
2412 rel_type: "INFORMED_BY".to_string(),
2413 target: foo.id.clone(),
2414 remove: false,
2415 description: None,
2416 },
2417 actor,
2418 Some(&client),
2419 None,
2420 )
2421 .unwrap();
2422 assert_eq!(
2423 count_references(&engine),
2424 baseline_refs + 2,
2425 "set-membership aliasing — adding INFORMED_BY does not \
2426 absorb the REFERENCES relation"
2427 );
2428
2429 let mut sections = IndexMap::new();
2433 sections.insert("identity".to_string(), "See [[foo]] inline.".to_string());
2434 let updated = engine
2435 .update_entity(
2436 UpdateEntityArgs {
2437 id: probe.id.clone(),
2438 expected_hash: Some(relate1.content_hash.clone()),
2439 sections,
2440 append_sections: IndexMap::new(),
2441 patch_sections: IndexMap::new(),
2442 metadata: IndexMap::new(),
2443 metadata_unset: Vec::new(),
2444 declare_relations: Vec::new(),
2445 dry_run: false,
2446 relations_unset: Vec::new(),
2447 },
2448 actor,
2449 Some(&client),
2450 None,
2451 )
2452 .unwrap();
2453 assert_eq!(
2454 count_references(&engine),
2455 baseline_refs + 1,
2456 "REFERENCES → bar must be auto-GC'd when its body link drops"
2457 );
2458
2459 let renamed = engine
2461 .rename_entity(
2462 crate::engine::RenameEntityArgs {
2463 id: probe.id.clone(),
2464 expected_hash: Some(updated.content_hash.clone()),
2465 new_title: "Probe Renamed".to_string(),
2466 },
2467 actor,
2468 Some(&client),
2469 None,
2470 )
2471 .unwrap();
2472 assert_eq!(count_references(&engine), baseline_refs + 1);
2473
2474 engine
2477 .delete_entity(
2478 crate::engine::DeleteEntityArgs {
2479 id: renamed.new_id.clone(),
2480 expected_hash: Some(renamed.content_hash.clone()),
2481 },
2482 actor,
2483 Some(&client),
2484 None,
2485 )
2486 .unwrap();
2487
2488 assert_eq!(
2490 engine.store().edge_count(),
2491 baseline_edges,
2492 "total edges must round-trip to baseline"
2493 );
2494 assert_eq!(
2495 count_references(&engine),
2496 baseline_refs,
2497 "REFERENCES counter must round-trip to baseline"
2498 );
2499
2500 engine.reload_one_mem("specs").unwrap();
2504 assert_eq!(
2505 engine.store().edge_count(),
2506 baseline_edges,
2507 "total edges must match disk after reload"
2508 );
2509 assert_eq!(
2510 count_references(&engine),
2511 baseline_refs,
2512 "REFERENCES must match disk after reload"
2513 );
2514 assert!(engine.store().contains(&foo.id));
2516 assert!(engine.store().contains(&bar.id));
2517 }
2518
2519 #[test]
2520 fn update_entity_returns_commit_sha_title_modified_date_warnings_shape() {
2521 let tmp = TempDir::new().unwrap();
2522 let (mut engine, seeded) = engine_with_seed(&tmp, "Subject");
2523 let (actor, client) = cli_actor();
2524
2525 let mut sections = IndexMap::new();
2526 sections.insert("identity".to_string(), "edited body".to_string());
2527
2528 let outcome = engine
2529 .update_entity(
2530 UpdateEntityArgs {
2531 id: seeded.id.clone(),
2532 expected_hash: Some(seeded.content_hash.clone()),
2533 sections,
2534 append_sections: IndexMap::new(),
2535 patch_sections: IndexMap::new(),
2536 metadata: IndexMap::new(),
2537 metadata_unset: Vec::new(),
2538 declare_relations: Vec::new(),
2539 dry_run: false,
2540 relations_unset: Vec::new(),
2541 },
2542 actor,
2543 Some(&client),
2544 None,
2545 )
2546 .unwrap();
2547
2548 assert!(
2550 !outcome.commit_sha.is_empty(),
2551 "commit_sha must be populated on a real update"
2552 );
2553 assert_eq!(outcome.title, "Subject");
2555 assert!(
2560 !outcome.modified_date.is_empty(),
2561 "modified_date must be auto-stamped on update for the default spec schema",
2562 );
2563 assert!(outcome.warnings.is_empty());
2567 assert_eq!(
2569 outcome.modified_sections.replaced,
2570 vec!["identity".to_string()]
2571 );
2572 }
2573
2574 #[test]
2583 fn update_entity_noop_resetting_section_to_current_value_preserves_last_modified() {
2584 let tmp = TempDir::new().unwrap();
2585 let (mut engine, seeded) = engine_with_seed(&tmp, "Section Resetter");
2586 let (actor, client) = cli_actor();
2587
2588 let pre_last_modified = engine
2591 .get_entity(&seeded.id)
2592 .and_then(|e| e.metadata.get("last_modified"))
2593 .map(|v| v.to_frontmatter_string())
2594 .expect("seeded entity has last_modified");
2595
2596 let mut sections = IndexMap::new();
2600 sections.insert("identity".to_string(), "fixture identity body".to_string());
2601 let outcome = engine
2602 .update_entity(
2603 UpdateEntityArgs {
2604 id: seeded.id.clone(),
2605 expected_hash: Some(seeded.content_hash.clone()),
2606 sections,
2607 append_sections: IndexMap::new(),
2608 patch_sections: IndexMap::new(),
2609 metadata: IndexMap::new(),
2610 metadata_unset: Vec::new(),
2611 declare_relations: Vec::new(),
2612 dry_run: false,
2613 relations_unset: Vec::new(),
2614 },
2615 actor,
2616 Some(&client),
2617 None,
2618 )
2619 .unwrap();
2620
2621 assert_eq!(outcome.commit_sha, "", "no-op must not commit");
2622 assert_eq!(
2623 outcome.content_hash, seeded.content_hash,
2624 "no-op must not advance content_hash",
2625 );
2626 assert!(
2627 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2628 "UPDATE_NOOP must fire on bytes-identical re-set",
2629 );
2630 assert_eq!(
2631 outcome.modified_date, pre_last_modified,
2632 "no-op must preserve last_modified at the pre-call value",
2633 );
2634 assert!(
2639 outcome.modified_sections.replaced.is_empty()
2640 && outcome.modified_sections.appended.is_empty()
2641 && outcome.modified_sections.patched.is_empty(),
2642 "no-op must report an empty section delta, got {:?}",
2643 outcome.modified_sections,
2644 );
2645
2646 let post_last_modified = engine
2650 .get_entity(&seeded.id)
2651 .and_then(|e| e.metadata.get("last_modified"))
2652 .map(|v| v.to_frontmatter_string())
2653 .expect("entity still in store");
2654 assert_eq!(post_last_modified, pre_last_modified);
2655 }
2656
2657 #[test]
2669 fn update_entity_empty_payload_refuses_with_typed_code() {
2670 let tmp = TempDir::new().unwrap();
2671 let (mut engine, seeded) = engine_with_seed(&tmp, "Empty Payload");
2672 let (actor, client) = cli_actor();
2673
2674 let err = engine
2675 .update_entity(
2676 UpdateEntityArgs {
2677 id: seeded.id.clone(),
2678 expected_hash: Some(seeded.content_hash.clone()),
2679 sections: IndexMap::new(),
2680 append_sections: IndexMap::new(),
2681 patch_sections: IndexMap::new(),
2682 metadata: IndexMap::new(),
2683 metadata_unset: Vec::new(),
2684 declare_relations: Vec::new(),
2685 dry_run: false,
2686 relations_unset: Vec::new(),
2687 },
2688 actor,
2689 Some(&client),
2690 None,
2691 )
2692 .unwrap_err();
2693 match err {
2694 EngineError::EmptyUpdate { id } => {
2695 assert_eq!(id, seeded.id.to_string());
2696 }
2697 other => panic!("expected EMPTY_UPDATE, got {other:?}"),
2698 }
2699 let log_path = tmp.path().join(".memstead/changes.jsonl");
2701 if let Ok(log) = std::fs::read_to_string(&log_path) {
2702 let updates = log.matches("\"kind\":\"update\"").count();
2703 assert_eq!(updates, 0, "EMPTY_UPDATE refusal must not log an update");
2704 }
2705 }
2706
2707 #[test]
2713 fn update_entity_noop_same_content_surfaces_warning() {
2714 let tmp = TempDir::new().unwrap();
2715 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Content Noop");
2716 let (actor, client) = cli_actor();
2717
2718 let mut sections = IndexMap::new();
2720 sections.insert("identity".to_string(), "fixture identity body".to_string());
2721
2722 let outcome = engine
2723 .update_entity(
2724 UpdateEntityArgs {
2725 id: seeded.id.clone(),
2726 expected_hash: Some(seeded.content_hash.clone()),
2727 sections,
2728 append_sections: IndexMap::new(),
2729 patch_sections: IndexMap::new(),
2730 metadata: IndexMap::new(),
2731 metadata_unset: Vec::new(),
2732 declare_relations: Vec::new(),
2733 dry_run: false,
2734 relations_unset: Vec::new(),
2735 },
2736 actor,
2737 Some(&client),
2738 None,
2739 )
2740 .unwrap();
2741
2742 assert_eq!(outcome.commit_sha, "");
2743 assert_eq!(outcome.content_hash, seeded.content_hash);
2744 let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code()).collect();
2745 assert!(
2746 codes.contains(&"UPDATE_NOOP"),
2747 "same-content update must surface UPDATE_NOOP; got {codes:?}",
2748 );
2749 }
2750
2751 #[test]
2752 fn update_entity_noop_metadata_unset_on_absent_key() {
2753 let tmp = TempDir::new().unwrap();
2758 let (mut engine, seeded) = engine_with_seed(&tmp, "Absent Key Noop");
2759 let (actor, client) = cli_actor();
2760
2761 let outcome = engine
2762 .update_entity(
2763 UpdateEntityArgs {
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!["tags".to_string()],
2774 declare_relations: Vec::new(),
2775 dry_run: false,
2776 relations_unset: Vec::new(),
2777 },
2778 actor,
2779 Some(&client),
2780 None,
2781 )
2782 .unwrap();
2783
2784 assert_eq!(outcome.commit_sha, "");
2785 assert_eq!(outcome.content_hash, seeded.content_hash);
2786 assert!(
2787 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2788 "absent-key metadata_unset must surface UPDATE_NOOP",
2789 );
2790 assert!(
2793 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
2794 "no-op must report an empty metadata delta, got {:?}",
2795 outcome.modified_metadata,
2796 );
2797
2798 let mut sections = IndexMap::new();
2801 sections.insert("identity".to_string(), "real change".to_string());
2802 let real = engine
2803 .update_entity(
2804 UpdateEntityArgs {
2805 id: seeded.id.clone(),
2806 expected_hash: Some(seeded.content_hash.clone()),
2807 sections,
2808 append_sections: IndexMap::new(),
2809 patch_sections: IndexMap::new(),
2810 metadata: IndexMap::new(),
2811 metadata_unset: Vec::new(),
2812 declare_relations: Vec::new(),
2813 dry_run: false,
2814 relations_unset: Vec::new(),
2815 },
2816 actor,
2817 Some(&client),
2818 None,
2819 )
2820 .unwrap();
2821 assert!(!real.commit_sha.is_empty());
2822 assert_ne!(real.content_hash, seeded.content_hash);
2823 }
2824
2825 #[test]
2832 fn update_entity_noop_setting_metadata_to_current_value_reports_empty_delta() {
2833 let tmp = TempDir::new().unwrap();
2834 let (mut engine, seeded) = engine_with_seed(&tmp, "Stability Resetter");
2835 let (actor, client) = cli_actor();
2836
2837 let mut metadata = IndexMap::new();
2840 metadata.insert("level".to_string(), "M0".to_string());
2841 let outcome = engine
2842 .update_entity(
2843 UpdateEntityArgs {
2844 id: seeded.id.clone(),
2845 expected_hash: Some(seeded.content_hash.clone()),
2846 sections: IndexMap::new(),
2847 append_sections: IndexMap::new(),
2848 patch_sections: IndexMap::new(),
2849 metadata,
2850 metadata_unset: Vec::new(),
2851 declare_relations: Vec::new(),
2852 dry_run: false,
2853 relations_unset: Vec::new(),
2854 },
2855 actor,
2856 Some(&client),
2857 None,
2858 )
2859 .unwrap();
2860
2861 assert_eq!(outcome.commit_sha, "", "no-op must not commit");
2862 assert_eq!(
2863 outcome.content_hash, seeded.content_hash,
2864 "no-op must not advance hash"
2865 );
2866 assert!(
2867 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2868 "re-set to current value must surface UPDATE_NOOP",
2869 );
2870 assert!(
2871 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
2872 "no-op must not claim `level` was set — applied delta is empty, got {:?}",
2873 outcome.modified_metadata,
2874 );
2875 }
2876
2877 #[test]
2878 fn update_entity_noop_declare_already_related_edge() {
2879 use crate::ops::RelateArg;
2884 let tmp = TempDir::new().unwrap();
2885 let mem_dir = tmp.path().to_path_buf();
2886 let writer = FilesystemMemWriter::new(mem_dir.clone());
2887 let mut engine = Engine::from_mounts(vec![(
2888 folder_mount("specs", mem_dir),
2889 Box::new(writer) as Box<dyn MemBackend>,
2890 )])
2891 .unwrap();
2892 let (actor, client) = cli_actor();
2893 let target = engine
2894 .create_entity(
2895 empty_create_args("specs", "Target Already Related"),
2896 actor,
2897 Some(&client),
2898 None,
2899 )
2900 .unwrap();
2901 let source = engine
2902 .create_entity(
2903 empty_create_args("specs", "Source Already Related"),
2904 actor,
2905 Some(&client),
2906 None,
2907 )
2908 .unwrap();
2909 let after_relate = engine
2910 .relate_entity(
2911 RelateEntityArgs {
2912 source: source.id.clone(),
2913 expected_hash: Some(source.content_hash.clone()),
2914 rel_type: "USES".to_string(),
2915 target: target.id.clone(),
2916 remove: false,
2917 description: None,
2918 },
2919 actor,
2920 Some(&client),
2921 None,
2922 )
2923 .unwrap();
2924 let outcome = engine
2926 .update_entity(
2927 UpdateEntityArgs {
2928 relations_unset: Vec::new(),
2929 id: source.id.clone(),
2930 expected_hash: Some(after_relate.content_hash.clone()),
2931 sections: IndexMap::new(),
2932 append_sections: IndexMap::new(),
2933 patch_sections: IndexMap::new(),
2934 metadata: IndexMap::new(),
2935 metadata_unset: Vec::new(),
2936 declare_relations: vec![RelateArg {
2937 rel_type: "USES".to_string(),
2938 to: target.id.clone(),
2939 description: None,
2940 }],
2941 dry_run: false,
2942 },
2943 actor,
2944 Some(&client),
2945 None,
2946 )
2947 .unwrap();
2948
2949 assert_eq!(outcome.commit_sha, "");
2950 assert_eq!(outcome.content_hash, after_relate.content_hash);
2951 assert!(
2952 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2953 "duplicate declare must surface UPDATE_NOOP",
2954 );
2955 assert_eq!(outcome.relations_declared.len(), 1);
2958 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
2959 assert_eq!(outcome.relations_declared[0].target, target.id);
2960 assert!(!outcome.relations_declared[0].target_was_stubbed);
2961 }
2962
2963 #[test]
2964 fn update_entity_real_change_still_commits_and_advances_hash() {
2965 let tmp = TempDir::new().unwrap();
2970 let (mut engine, seeded) = engine_with_seed(&tmp, "Real Change Subject");
2971 let (actor, client) = cli_actor();
2972
2973 let mut sections = IndexMap::new();
2974 sections.insert("identity".to_string(), "definitely new body".to_string());
2975
2976 let outcome = engine
2977 .update_entity(
2978 UpdateEntityArgs {
2979 id: seeded.id.clone(),
2980 expected_hash: Some(seeded.content_hash.clone()),
2981 sections,
2982 append_sections: IndexMap::new(),
2983 patch_sections: IndexMap::new(),
2984 metadata: IndexMap::new(),
2985 metadata_unset: Vec::new(),
2986 declare_relations: Vec::new(),
2987 dry_run: false,
2988 relations_unset: Vec::new(),
2989 },
2990 actor,
2991 Some(&client),
2992 None,
2993 )
2994 .unwrap();
2995
2996 assert!(!outcome.commit_sha.is_empty(), "real change must commit");
2997 assert_ne!(
2998 outcome.content_hash, seeded.content_hash,
2999 "real change must advance content_hash",
3000 );
3001 assert!(
3002 !outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3003 "real change must not surface UPDATE_NOOP",
3004 );
3005 }
3006
3007 #[test]
3008 fn update_entity_noop_preserves_expected_hash_across_chain() {
3009 let tmp = TempDir::new().unwrap();
3014 let (mut engine, seeded) = engine_with_seed(&tmp, "Chained Noops Subject");
3015 let (actor, client) = cli_actor();
3016
3017 let mut noop_sections = IndexMap::new();
3022 noop_sections.insert("identity".to_string(), "fixture identity body".to_string());
3023 for _ in 0..2 {
3024 let outcome = engine
3025 .update_entity(
3026 UpdateEntityArgs {
3027 id: seeded.id.clone(),
3028 expected_hash: Some(seeded.content_hash.clone()),
3029 sections: noop_sections.clone(),
3030 append_sections: IndexMap::new(),
3031 patch_sections: IndexMap::new(),
3032 metadata: IndexMap::new(),
3033 metadata_unset: Vec::new(),
3034 declare_relations: Vec::new(),
3035 dry_run: false,
3036 relations_unset: Vec::new(),
3037 },
3038 actor,
3039 Some(&client),
3040 None,
3041 )
3042 .unwrap();
3043 assert_eq!(outcome.commit_sha, "");
3044 assert_eq!(outcome.content_hash, seeded.content_hash);
3045 }
3046
3047 let mut sections = IndexMap::new();
3050 sections.insert(
3051 "identity".to_string(),
3052 "third call: real change".to_string(),
3053 );
3054 let real = engine
3055 .update_entity(
3056 UpdateEntityArgs {
3057 id: seeded.id.clone(),
3058 expected_hash: Some(seeded.content_hash.clone()),
3059 sections,
3060 append_sections: IndexMap::new(),
3061 patch_sections: IndexMap::new(),
3062 metadata: IndexMap::new(),
3063 metadata_unset: Vec::new(),
3064 declare_relations: Vec::new(),
3065 dry_run: false,
3066 relations_unset: Vec::new(),
3067 },
3068 actor,
3069 Some(&client),
3070 None,
3071 )
3072 .unwrap();
3073 assert!(!real.commit_sha.is_empty());
3074 assert_ne!(real.content_hash, seeded.content_hash);
3075 }
3076
3077 #[test]
3086 fn synthesis_gc_drops_auto_emitted_reference_when_body_link_removed() {
3087 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3091 use indexmap::IndexMap;
3092 use tempfile::TempDir;
3093
3094 let tmp = TempDir::new().unwrap();
3095 let mem_dir = tmp.path().to_path_buf();
3096 let writer = FilesystemMemWriter::new(mem_dir.clone());
3097 let mut engine = Engine::from_mounts(vec![(
3098 folder_mount("specs", mem_dir.clone()),
3099 Box::new(writer) as Box<dyn MemBackend>,
3100 )])
3101 .unwrap();
3102 engine.set_workspace_root(mem_dir.clone());
3103 let (actor, client) = cli_actor();
3104
3105 let target = engine
3106 .create_entity(
3107 empty_create_args("specs", "Target"),
3108 actor,
3109 Some(&client),
3110 None,
3111 )
3112 .unwrap();
3113 let mut sections: IndexMap<String, String> = IndexMap::new();
3116 sections.insert("identity".to_string(), "source identity".to_string());
3117 sections.insert(
3118 "purpose".to_string(),
3119 "see [[target]] for context".to_string(),
3120 );
3121 let source = engine
3122 .create_entity(
3123 CreateEntityArgs {
3124 mem: "specs".to_string(),
3125 title: "Source".to_string(),
3126 entity_type: "spec".to_string(),
3127 sections,
3128 metadata: IndexMap::new(),
3129 relations: Vec::new(),
3130 dry_run: false,
3131 },
3132 actor,
3133 Some(&client),
3134 None,
3135 )
3136 .unwrap();
3137 assert!(
3138 engine
3139 .get_entity(&source.id)
3140 .unwrap()
3141 .relationships
3142 .iter()
3143 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3144 "create-time synthesis must emit REFERENCES → target",
3145 );
3146
3147 let mut new_sections: IndexMap<String, String> = IndexMap::new();
3150 new_sections.insert("purpose".to_string(), "no link any more".to_string());
3151 engine
3152 .update_entity(
3153 UpdateEntityArgs {
3154 id: source.id.clone(),
3155 expected_hash: Some(source.content_hash.clone()),
3156 sections: new_sections,
3157 append_sections: IndexMap::new(),
3158 patch_sections: IndexMap::new(),
3159 metadata: IndexMap::new(),
3160 metadata_unset: Vec::new(),
3161 declare_relations: Vec::new(),
3162 dry_run: false,
3163 relations_unset: Vec::new(),
3164 },
3165 actor,
3166 Some(&client),
3167 None,
3168 )
3169 .expect("update must succeed; GC drops the now-orphan REFERENCES");
3170 let in_mem = engine.get_entity(&source.id).unwrap();
3171 assert!(
3172 !in_mem
3173 .relationships
3174 .iter()
3175 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3176 "GC must drop the auto-emitted REFERENCES after body link removal; got {:?}",
3177 in_mem.relationships,
3178 );
3179 }
3180
3181 #[test]
3182 fn update_gc_removes_orphan_stub_when_last_body_link_dropped() {
3183 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3191 use indexmap::IndexMap;
3192 use tempfile::TempDir;
3193
3194 let tmp = TempDir::new().unwrap();
3195 let mem_dir = tmp.path().to_path_buf();
3196 let writer = FilesystemMemWriter::new(mem_dir.clone());
3197 let mut engine = Engine::from_mounts(vec![(
3198 folder_mount("specs", mem_dir.clone()),
3199 Box::new(writer) as Box<dyn MemBackend>,
3200 )])
3201 .unwrap();
3202 engine.set_workspace_root(mem_dir.clone());
3203 let (actor, client) = cli_actor();
3204
3205 let ghost = crate::EntityId::new("specs", "ghost");
3206 let mut sections: IndexMap<String, String> = IndexMap::new();
3207 sections.insert("identity".to_string(), "source identity".to_string());
3208 sections.insert(
3209 "purpose".to_string(),
3210 "see [[ghost]] for context".to_string(),
3211 );
3212 let source = engine
3213 .create_entity(
3214 CreateEntityArgs {
3215 mem: "specs".to_string(),
3216 title: "Source".to_string(),
3217 entity_type: "spec".to_string(),
3218 sections,
3219 metadata: IndexMap::new(),
3220 relations: Vec::new(),
3221 dry_run: false,
3222 },
3223 actor,
3224 Some(&client),
3225 None,
3226 )
3227 .unwrap();
3228 assert!(
3229 engine.store().contains(&ghost) && engine.get_entity(&ghost).unwrap().stub,
3230 "body wiki-link to an absent target must auto-stub it",
3231 );
3232 assert_eq!(
3233 engine.health().stub_count,
3234 1,
3235 "one stub before the link drop"
3236 );
3237
3238 let mut new_sections: IndexMap<String, String> = IndexMap::new();
3239 new_sections.insert("purpose".to_string(), "no link any more".to_string());
3240 let outcome = engine
3241 .update_entity(
3242 UpdateEntityArgs {
3243 id: source.id.clone(),
3244 expected_hash: Some(source.content_hash.clone()),
3245 sections: new_sections,
3246 append_sections: IndexMap::new(),
3247 patch_sections: IndexMap::new(),
3248 metadata: IndexMap::new(),
3249 metadata_unset: Vec::new(),
3250 declare_relations: Vec::new(),
3251 dry_run: false,
3252 relations_unset: Vec::new(),
3253 },
3254 actor,
3255 Some(&client),
3256 None,
3257 )
3258 .expect("update must succeed and GC the now-orphan stub");
3259
3260 assert_eq!(
3261 outcome.orphan_stubs_removed,
3262 vec![ghost.clone()],
3263 "the update that dropped the last body link must report the GC'd stub",
3264 );
3265 assert!(
3266 !engine.store().contains(&ghost),
3267 "orphan stub must be gone from the in-memory store",
3268 );
3269 assert_eq!(
3270 engine.health().stub_count,
3271 0,
3272 "stub count decremented in-session"
3273 );
3274
3275 engine.reload_each_writable_mem().unwrap();
3279 assert!(
3280 !engine.store().contains(&ghost),
3281 "stub stays gone after reload-from-disk",
3282 );
3283 assert_eq!(
3284 engine.health().stub_count,
3285 0,
3286 "reloaded-from-disk store carries the same stub count as the in-session post-update state",
3287 );
3288 }
3289
3290 #[test]
3291 fn update_gc_noop_when_section_edit_changes_no_body_link() {
3292 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3297 use indexmap::IndexMap;
3298 use tempfile::TempDir;
3299
3300 let tmp = TempDir::new().unwrap();
3301 let mem_dir = tmp.path().to_path_buf();
3302 let writer = FilesystemMemWriter::new(mem_dir.clone());
3303 let mut engine = Engine::from_mounts(vec![(
3304 folder_mount("specs", mem_dir.clone()),
3305 Box::new(writer) as Box<dyn MemBackend>,
3306 )])
3307 .unwrap();
3308 engine.set_workspace_root(mem_dir.clone());
3309 let (actor, client) = cli_actor();
3310
3311 let ghost = crate::EntityId::new("specs", "ghost");
3312 let mut sections: IndexMap<String, String> = IndexMap::new();
3313 sections.insert("identity".to_string(), "original identity".to_string());
3314 sections.insert(
3315 "purpose".to_string(),
3316 "see [[ghost]] for context".to_string(),
3317 );
3318 let source = engine
3319 .create_entity(
3320 CreateEntityArgs {
3321 mem: "specs".to_string(),
3322 title: "Source".to_string(),
3323 entity_type: "spec".to_string(),
3324 sections,
3325 metadata: IndexMap::new(),
3326 relations: Vec::new(),
3327 dry_run: false,
3328 },
3329 actor,
3330 Some(&client),
3331 None,
3332 )
3333 .unwrap();
3334 assert!(engine.store().contains(&ghost), "ghost stub materialised");
3335
3336 let mut edit: IndexMap<String, String> = IndexMap::new();
3339 edit.insert("identity".to_string(), "edited identity".to_string());
3340 let outcome = engine
3341 .update_entity(
3342 UpdateEntityArgs {
3343 id: source.id.clone(),
3344 expected_hash: Some(source.content_hash.clone()),
3345 sections: edit,
3346 append_sections: IndexMap::new(),
3347 patch_sections: IndexMap::new(),
3348 metadata: IndexMap::new(),
3349 metadata_unset: Vec::new(),
3350 declare_relations: Vec::new(),
3351 dry_run: false,
3352 relations_unset: Vec::new(),
3353 },
3354 actor,
3355 Some(&client),
3356 None,
3357 )
3358 .expect("update must succeed");
3359 assert!(
3360 outcome.orphan_stubs_removed.is_empty(),
3361 "an edit that keeps every body wiki-link orphans nothing; got {:?}",
3362 outcome.orphan_stubs_removed,
3363 );
3364 assert!(
3365 engine.store().contains(&ghost),
3366 "the still-referenced stub survives the unrelated section edit",
3367 );
3368 }
3369
3370 #[test]
3371 fn update_gc_preserves_stub_with_surviving_referrer() {
3372 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3376 use indexmap::IndexMap;
3377 use tempfile::TempDir;
3378
3379 let tmp = TempDir::new().unwrap();
3380 let mem_dir = tmp.path().to_path_buf();
3381 let writer = FilesystemMemWriter::new(mem_dir.clone());
3382 let mut engine = Engine::from_mounts(vec![(
3383 folder_mount("specs", mem_dir.clone()),
3384 Box::new(writer) as Box<dyn MemBackend>,
3385 )])
3386 .unwrap();
3387 engine.set_workspace_root(mem_dir.clone());
3388 let (actor, client) = cli_actor();
3389
3390 let ghost = crate::EntityId::new("specs", "ghost");
3391 let make_with_link = |title: &str| {
3392 let mut sections: IndexMap<String, String> = IndexMap::new();
3393 sections.insert("identity".to_string(), format!("{title} identity"));
3394 sections.insert("purpose".to_string(), "see [[ghost]]".to_string());
3395 CreateEntityArgs {
3396 mem: "specs".to_string(),
3397 title: title.to_string(),
3398 entity_type: "spec".to_string(),
3399 sections,
3400 metadata: IndexMap::new(),
3401 relations: Vec::new(),
3402 dry_run: false,
3403 }
3404 };
3405 let source_a = engine
3406 .create_entity(make_with_link("Source A"), actor, Some(&client), None)
3407 .unwrap();
3408 engine
3409 .create_entity(make_with_link("Source B"), actor, Some(&client), None)
3410 .unwrap();
3411 assert!(engine.store().contains(&ghost), "ghost stub materialised");
3412
3413 let mut drop_link: IndexMap<String, String> = IndexMap::new();
3415 drop_link.insert("purpose".to_string(), "no link here".to_string());
3416 let outcome = engine
3417 .update_entity(
3418 UpdateEntityArgs {
3419 id: source_a.id.clone(),
3420 expected_hash: Some(source_a.content_hash.clone()),
3421 sections: drop_link,
3422 append_sections: IndexMap::new(),
3423 patch_sections: IndexMap::new(),
3424 metadata: IndexMap::new(),
3425 metadata_unset: Vec::new(),
3426 declare_relations: Vec::new(),
3427 dry_run: false,
3428 relations_unset: Vec::new(),
3429 },
3430 actor,
3431 Some(&client),
3432 None,
3433 )
3434 .expect("update must succeed");
3435 assert!(
3436 outcome.orphan_stubs_removed.is_empty(),
3437 "the stub keeps a referrer (source B), so nothing is GC'd; got {:?}",
3438 outcome.orphan_stubs_removed,
3439 );
3440 assert!(
3441 engine.store().contains(&ghost),
3442 "stub survives via the surviving referrer",
3443 );
3444 }
3445
3446 #[test]
3447 fn synthesis_gc_preserves_non_pointer_explicit_relation_across_body_update() {
3448 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
3457 use indexmap::IndexMap;
3458 use tempfile::TempDir;
3459
3460 let tmp = TempDir::new().unwrap();
3461 let mem_dir = tmp.path().to_path_buf();
3462 let writer = FilesystemMemWriter::new(mem_dir.clone());
3463 let mut engine = Engine::from_mounts(vec![(
3464 folder_mount("specs", mem_dir.clone()),
3465 Box::new(writer) as Box<dyn MemBackend>,
3466 )])
3467 .unwrap();
3468 engine.set_workspace_root(mem_dir.clone());
3469 let (actor, client) = cli_actor();
3470
3471 let target = engine
3472 .create_entity(
3473 empty_create_args("specs", "Target"),
3474 actor,
3475 Some(&client),
3476 None,
3477 )
3478 .unwrap();
3479 let source = engine
3480 .create_entity(
3481 empty_create_args("specs", "Source"),
3482 actor,
3483 Some(&client),
3484 None,
3485 )
3486 .unwrap();
3487
3488 let relate = engine
3490 .relate_entity(
3491 RelateEntityArgs {
3492 source: source.id.clone(),
3493 expected_hash: Some(source.content_hash.clone()),
3494 rel_type: "USES".to_string(),
3495 target: target.id.clone(),
3496 remove: false,
3497 description: None,
3498 },
3499 actor,
3500 Some(&client),
3501 None,
3502 )
3503 .unwrap();
3504
3505 let mut sections: IndexMap<String, String> = IndexMap::new();
3508 sections.insert("purpose".to_string(), "unrelated edit".to_string());
3509 engine
3510 .update_entity(
3511 UpdateEntityArgs {
3512 id: source.id.clone(),
3513 expected_hash: Some(relate.content_hash.clone()),
3514 sections,
3515 append_sections: IndexMap::new(),
3516 patch_sections: IndexMap::new(),
3517 metadata: IndexMap::new(),
3518 metadata_unset: Vec::new(),
3519 declare_relations: Vec::new(),
3520 dry_run: false,
3521 relations_unset: Vec::new(),
3522 },
3523 actor,
3524 Some(&client),
3525 None,
3526 )
3527 .expect("update must succeed");
3528 let in_mem = engine.get_entity(&source.id).unwrap();
3529 assert!(
3530 in_mem
3531 .relationships
3532 .iter()
3533 .any(|r| r.rel_type == "USES" && r.target == target.id),
3534 "explicit USES must survive an unrelated body update; got {:?}",
3535 in_mem.relationships,
3536 );
3537 }
3538
3539 #[test]
3540 fn synthesis_dedupes_repeated_body_links_to_same_target() {
3541 use crate::engine::UpdateEntityArgs;
3544 use indexmap::IndexMap;
3545 use tempfile::TempDir;
3546
3547 let tmp = TempDir::new().unwrap();
3548 let mem_dir = tmp.path().to_path_buf();
3549 let writer = FilesystemMemWriter::new(mem_dir.clone());
3550 let mut engine = Engine::from_mounts(vec![(
3551 folder_mount("specs", mem_dir.clone()),
3552 Box::new(writer) as Box<dyn MemBackend>,
3553 )])
3554 .unwrap();
3555 engine.set_workspace_root(mem_dir.clone());
3556 let (actor, client) = cli_actor();
3557
3558 let target = engine
3559 .create_entity(
3560 empty_create_args("specs", "Target"),
3561 actor,
3562 Some(&client),
3563 None,
3564 )
3565 .unwrap();
3566 let source = engine
3567 .create_entity(
3568 empty_create_args("specs", "Source"),
3569 actor,
3570 Some(&client),
3571 None,
3572 )
3573 .unwrap();
3574
3575 let mut sections: IndexMap<String, String> = IndexMap::new();
3576 sections.insert(
3577 "purpose".to_string(),
3578 "see [[target]] and again [[target]]".to_string(),
3579 );
3580 engine
3581 .update_entity(
3582 UpdateEntityArgs {
3583 id: source.id.clone(),
3584 expected_hash: Some(source.content_hash.clone()),
3585 sections,
3586 append_sections: IndexMap::new(),
3587 patch_sections: IndexMap::new(),
3588 metadata: IndexMap::new(),
3589 metadata_unset: Vec::new(),
3590 declare_relations: Vec::new(),
3591 dry_run: false,
3592 relations_unset: Vec::new(),
3593 },
3594 actor,
3595 Some(&client),
3596 None,
3597 )
3598 .unwrap();
3599 let in_mem = engine.get_entity(&source.id).unwrap();
3600 let count = in_mem
3601 .relationships
3602 .iter()
3603 .filter(|r| r.rel_type == "REFERENCES" && r.target == target.id)
3604 .count();
3605 assert_eq!(
3606 count, 1,
3607 "dedupe must leave exactly one REFERENCES → target; got {:?}",
3608 in_mem.relationships,
3609 );
3610 }
3611
3612 #[test]
3613 fn synthesis_coexists_with_explicit_uses_to_same_target() {
3614 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
3619 use indexmap::IndexMap;
3620 use tempfile::TempDir;
3621
3622 let tmp = TempDir::new().unwrap();
3623 let mem_dir = tmp.path().to_path_buf();
3624 let writer = FilesystemMemWriter::new(mem_dir.clone());
3625 let mut engine = Engine::from_mounts(vec![(
3626 folder_mount("specs", mem_dir.clone()),
3627 Box::new(writer) as Box<dyn MemBackend>,
3628 )])
3629 .unwrap();
3630 engine.set_workspace_root(mem_dir.clone());
3631 let (actor, client) = cli_actor();
3632
3633 let target = engine
3634 .create_entity(
3635 empty_create_args("specs", "Target"),
3636 actor,
3637 Some(&client),
3638 None,
3639 )
3640 .unwrap();
3641 let source = engine
3642 .create_entity(
3643 empty_create_args("specs", "Source"),
3644 actor,
3645 Some(&client),
3646 None,
3647 )
3648 .unwrap();
3649 let relate = engine
3651 .relate_entity(
3652 RelateEntityArgs {
3653 source: source.id.clone(),
3654 expected_hash: Some(source.content_hash.clone()),
3655 rel_type: "USES".to_string(),
3656 target: target.id.clone(),
3657 remove: false,
3658 description: None,
3659 },
3660 actor,
3661 Some(&client),
3662 None,
3663 )
3664 .unwrap();
3665 let mut sections: IndexMap<String, String> = IndexMap::new();
3667 sections.insert(
3668 "purpose".to_string(),
3669 "we also reference [[target]]".to_string(),
3670 );
3671 engine
3672 .update_entity(
3673 UpdateEntityArgs {
3674 id: source.id.clone(),
3675 expected_hash: Some(relate.content_hash.clone()),
3676 sections,
3677 append_sections: IndexMap::new(),
3678 patch_sections: IndexMap::new(),
3679 metadata: IndexMap::new(),
3680 metadata_unset: Vec::new(),
3681 declare_relations: Vec::new(),
3682 dry_run: false,
3683 relations_unset: Vec::new(),
3684 },
3685 actor,
3686 Some(&client),
3687 None,
3688 )
3689 .unwrap();
3690 let in_mem = engine.get_entity(&source.id).unwrap();
3691 assert!(
3692 in_mem
3693 .relationships
3694 .iter()
3695 .any(|r| r.rel_type == "USES" && r.target == target.id),
3696 "USES must survive — synthesis dedupes on (rel_type, target)",
3697 );
3698 assert!(
3699 in_mem
3700 .relationships
3701 .iter()
3702 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3703 "REFERENCES must be synthesised even though USES already targets the same entity",
3704 );
3705 }
3706
3707 mod alias_synthesis_custom_schema {
3719 use std::path::Path;
3720
3721 use indexmap::IndexMap;
3722 use memstead_schema::SchemaRef;
3723 use tempfile::TempDir;
3724
3725 use crate::backend::MemBackend;
3726 use crate::engine::test_helpers::*;
3727 use crate::engine::{CreateEntityArgs, Engine, EngineError, UpdateEntityArgs};
3728 use crate::storage::FilesystemMemWriter;
3729 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
3730
3731 const TYPE_BODY: &str = r#"description: t
3732when_to_use: tests
3733sections:
3734 - key: body
3735 heading: Body
3736 required: true
3737 search_weight: 10.0
3738 catch_all: true
3739 write_rules: []
3740metadata_fields: []
3741title_weight: 100.0
3742text_fields:
3743 - body
3744hierarchy_relationship: _default
3745propagating_relationships: []
3746updatable_fields:
3747 - title
3748 - body
3749health_required_fields:
3750 - body
3751staleness_threshold_days: 90
3752write_rules: []
3753"#;
3754
3755 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
3756 let dir = root.join(name);
3757 std::fs::create_dir_all(dir.join("types")).unwrap();
3758 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
3759 for (type_name, body) in types {
3760 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
3761 }
3762 }
3763
3764 fn make_type_yaml(name: &str) -> String {
3765 format!("name: {name}\n{TYPE_BODY}")
3766 }
3767
3768 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
3769 Mount {
3770 mem: mem.to_string(),
3771 schema: Some(pin),
3772 storage: MountStorage::Folder { path },
3773 capability: MountCapability::Write,
3774 lifecycle: MountLifecycle::Eager,
3775 cross_linkable: true,
3776 migration_target: None,
3777 }
3778 }
3779
3780 fn engine_with_schema(
3781 manifest: &str,
3782 type_yaml_name: &str,
3783 schema_name: &str,
3784 schema_version: semver::Version,
3785 ) -> (Engine, TempDir) {
3786 let tmp = TempDir::new().unwrap();
3787 let schemas_dir = tmp.path().join("schemas");
3788 std::fs::create_dir_all(&schemas_dir).unwrap();
3789 write_schema_files(
3790 &schemas_dir,
3791 schema_name,
3792 manifest,
3793 &[(type_yaml_name, &make_type_yaml(type_yaml_name))],
3794 );
3795 let mem_dir = tmp.path().join("mem");
3796 std::fs::create_dir_all(&mem_dir).unwrap();
3797 let writer = FilesystemMemWriter::new(mem_dir.clone());
3798 let pin = SchemaRef::new(schema_name, schema_version);
3799 let mount = folder_mount_with_pin("v", mem_dir, pin);
3800 let mut engine = Engine::from_mounts_with_schemas_dir(
3801 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3802 Some(&schemas_dir),
3803 )
3804 .expect("engine with custom schema constructs");
3805 engine.set_workspace_root(tmp.path().to_path_buf());
3806 (engine, tmp)
3807 }
3808
3809 #[test]
3810 fn non_references_alias_pointer_emits_named_rel_type_from_body_link() {
3811 let manifest = r#"name: aliased
3816version: 0.1.0
3817description: alias-synthesis fixture using a non-REFERENCES pointer
3818when_to_use: tests prove the engine does not hard-code REFERENCES
3819types:
3820 - doc
3821relationships:
3822 mode: strict
3823 definitions:
3824 - name: CITES
3825 description: Citation — auto-emitted from body wiki-links
3826 default_weight: 0.5
3827 - name: PART_OF
3828 description: Hierarchy
3829 default_weight: 3.0
3830 acyclic: true
3831 - name: _default
3832 description: Fallback
3833 default_weight: 1.0
3834alias_target_rel_type: CITES
3835community:
3836 resolution: 1.0
3837 seed: 42
3838"#;
3839 let (mut engine, _tmp) =
3840 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
3841 let (actor, client) = cli_actor();
3842
3843 let target = engine
3844 .create_entity(
3845 CreateEntityArgs {
3846 mem: "v".to_string(),
3847 title: "Target".to_string(),
3848 entity_type: "doc".to_string(),
3849 sections: IndexMap::from_iter([(
3850 "body".to_string(),
3851 "target body".to_string(),
3852 )]),
3853 metadata: IndexMap::new(),
3854 relations: Vec::new(),
3855 dry_run: false,
3856 },
3857 actor,
3858 Some(&client),
3859 None,
3860 )
3861 .unwrap();
3862
3863 let mut sections: IndexMap<String, String> = IndexMap::new();
3864 sections.insert("body".to_string(), "see [[target]]".to_string());
3865 let source = engine
3866 .create_entity(
3867 CreateEntityArgs {
3868 mem: "v".to_string(),
3869 title: "Source".to_string(),
3870 entity_type: "doc".to_string(),
3871 sections,
3872 metadata: IndexMap::new(),
3873 relations: Vec::new(),
3874 dry_run: false,
3875 },
3876 actor,
3877 Some(&client),
3878 None,
3879 )
3880 .expect("create must succeed; CITES is auto-emitted by synthesis");
3881
3882 let in_mem = engine.get_entity(&source.id).unwrap();
3883 assert!(
3884 in_mem
3885 .relationships
3886 .iter()
3887 .any(|r| r.rel_type == "CITES" && r.target == target.id),
3888 "synthesis must emit CITES (the pointer rel-type), not REFERENCES; got {:?}",
3889 in_mem.relationships,
3890 );
3891 assert!(
3892 !in_mem
3893 .relationships
3894 .iter()
3895 .any(|r| r.rel_type == "REFERENCES"),
3896 "engine must not hard-code REFERENCES — pointer rel-type is CITES; got {:?}",
3897 in_mem.relationships,
3898 );
3899 }
3900
3901 #[test]
3902 fn no_pointer_schema_refuses_unbacked_body_wiki_link() {
3903 let manifest = r#"name: no-alias
3908version: 0.1.0
3909description: schema without alias_target_rel_type pointer
3910when_to_use: tests prove strict validator still fires for opt-out schemas
3911types:
3912 - doc
3913relationships:
3914 mode: strict
3915 definitions:
3916 - name: USES
3917 description: Use
3918 default_weight: 1.0
3919 - name: PART_OF
3920 description: Hierarchy
3921 default_weight: 3.0
3922 acyclic: true
3923 - name: _default
3924 description: Fallback
3925 default_weight: 1.0
3926community:
3927 resolution: 1.0
3928 seed: 42
3929"#;
3930 let (mut engine, _tmp) =
3931 engine_with_schema(manifest, "doc", "no-alias", semver::Version::new(0, 1, 0));
3932 let (actor, client) = cli_actor();
3933
3934 let target = engine
3935 .create_entity(
3936 CreateEntityArgs {
3937 mem: "v".to_string(),
3938 title: "Target".to_string(),
3939 entity_type: "doc".to_string(),
3940 sections: IndexMap::from_iter([(
3941 "body".to_string(),
3942 "target body".to_string(),
3943 )]),
3944 metadata: IndexMap::new(),
3945 relations: Vec::new(),
3946 dry_run: false,
3947 },
3948 actor,
3949 Some(&client),
3950 None,
3951 )
3952 .unwrap();
3953 let source = engine
3954 .create_entity(
3955 CreateEntityArgs {
3956 mem: "v".to_string(),
3957 title: "Source".to_string(),
3958 entity_type: "doc".to_string(),
3959 sections: IndexMap::from_iter([(
3960 "body".to_string(),
3961 "source body".to_string(),
3962 )]),
3963 metadata: IndexMap::new(),
3964 relations: Vec::new(),
3965 dry_run: false,
3966 },
3967 actor,
3968 Some(&client),
3969 None,
3970 )
3971 .unwrap();
3972
3973 let mut sections: IndexMap<String, String> = IndexMap::new();
3977 sections.insert("body".to_string(), "see [[target]]".to_string());
3978 let err = engine
3979 .update_entity(
3980 UpdateEntityArgs {
3981 id: source.id.clone(),
3982 expected_hash: Some(source.content_hash.clone()),
3983 sections,
3984 append_sections: IndexMap::new(),
3985 patch_sections: IndexMap::new(),
3986 metadata: IndexMap::new(),
3987 metadata_unset: Vec::new(),
3988 declare_relations: Vec::new(),
3989 dry_run: false,
3990 relations_unset: Vec::new(),
3991 },
3992 actor,
3993 Some(&client),
3994 None,
3995 )
3996 .unwrap_err();
3997 match err {
3998 EngineError::WikiLinkWithoutRelation { from_id, missing } => {
3999 assert_eq!(from_id, source.id.to_string());
4000 assert_eq!(missing.len(), 1);
4001 assert_eq!(missing[0].section_key, "body");
4002 assert_eq!(missing[0].target_id, target.id.to_string());
4003 }
4004 other => panic!(
4005 "no-pointer schema must refuse with WikiLinkWithoutRelation; got {other:?}"
4006 ),
4007 }
4008 }
4009
4010 #[test]
4019 fn natural_form_body_wiki_link_refuses_with_typed_envelope() {
4020 let manifest = r#"name: aliased
4021version: 0.1.0
4022description: alias-synthesis fixture
4023when_to_use: tests prove strict wiki-link grammar at mutation entry
4024types:
4025 - doc
4026relationships:
4027 mode: strict
4028 definitions:
4029 - name: REFERENCES
4030 description: Reference — auto-emitted from body wiki-links
4031 default_weight: 0.5
4032 - name: PART_OF
4033 description: Hierarchy
4034 default_weight: 3.0
4035 acyclic: true
4036 - name: _default
4037 description: Fallback
4038 default_weight: 1.0
4039alias_target_rel_type: REFERENCES
4040community:
4041 resolution: 1.0
4042 seed: 42
4043"#;
4044 let (mut engine, _tmp) =
4045 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4046 let (actor, client) = cli_actor();
4047
4048 let mut sections: IndexMap<String, String> = IndexMap::new();
4049 sections.insert("body".to_string(), "see [[Knowledge Graph]]".to_string());
4050 let err = engine
4051 .create_entity(
4052 CreateEntityArgs {
4053 mem: "v".to_string(),
4054 title: "Source".to_string(),
4055 entity_type: "doc".to_string(),
4056 sections,
4057 metadata: IndexMap::new(),
4058 relations: Vec::new(),
4059 dry_run: false,
4060 },
4061 actor,
4062 Some(&client),
4063 None,
4064 )
4065 .unwrap_err();
4066 match err {
4067 EngineError::InvalidWikiLinkTarget {
4068 raw,
4069 suggested,
4070 section,
4071 link_source,
4072 ..
4073 } => {
4074 assert_eq!(raw, "Knowledge Graph");
4075 assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
4076 assert_eq!(section, "body");
4077 assert_eq!(link_source, "body_link");
4078 }
4079 other => panic!(
4080 "natural-form body wiki-link must refuse with InvalidWikiLinkTarget; got {other:?}"
4081 ),
4082 }
4083 }
4084
4085 #[test]
4091 fn tier_two_bad_mem_prefix_refuses_with_distinct_envelope() {
4092 let manifest = r#"name: aliased
4093version: 0.1.0
4094description: alias-synthesis fixture
4095when_to_use: tests prove strict mem-prefix grammar at mutation entry
4096types:
4097 - doc
4098relationships:
4099 mode: strict
4100 definitions:
4101 - name: REFERENCES
4102 description: Reference
4103 default_weight: 0.5
4104 - name: PART_OF
4105 description: Hierarchy
4106 default_weight: 3.0
4107 acyclic: true
4108 - name: _default
4109 description: Fallback
4110 default_weight: 1.0
4111alias_target_rel_type: REFERENCES
4112community:
4113 resolution: 1.0
4114 seed: 42
4115"#;
4116 let (mut engine, _tmp) =
4117 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4118 let (actor, client) = cli_actor();
4119
4120 let mut sections: IndexMap<String, String> = IndexMap::new();
4121 sections.insert("body".to_string(), "see [[Other Mem:foo]]".to_string());
4122 let err = engine
4123 .create_entity(
4124 CreateEntityArgs {
4125 mem: "v".to_string(),
4126 title: "Source".to_string(),
4127 entity_type: "doc".to_string(),
4128 sections,
4129 metadata: IndexMap::new(),
4130 relations: Vec::new(),
4131 dry_run: false,
4132 },
4133 actor,
4134 Some(&client),
4135 None,
4136 )
4137 .unwrap_err();
4138 match err {
4139 EngineError::InvalidWikiLinkMem { raw, section, .. } => {
4140 assert_eq!(raw, "Other Mem");
4141 assert_eq!(section, "body");
4142 }
4143 other => panic!(
4144 "Tier-2 bad mem prefix must refuse with InvalidWikiLinkMem; got {other:?}"
4145 ),
4146 }
4147 }
4148
4149 #[test]
4156 fn hierarchical_dash_form_body_link_refuses_with_colon_suggestion() {
4157 let manifest = r#"name: aliased
4158version: 0.1.0
4159description: alias-synthesis fixture
4160when_to_use: tests prove hierarchical dash-form refusal at mutation entry
4161types:
4162 - doc
4163relationships:
4164 mode: strict
4165 definitions:
4166 - name: REFERENCES
4167 description: Reference — auto-emitted from body wiki-links
4168 default_weight: 0.5
4169 - name: PART_OF
4170 description: Hierarchy
4171 default_weight: 3.0
4172 acyclic: true
4173 - name: _default
4174 description: Fallback
4175 default_weight: 1.0
4176alias_target_rel_type: REFERENCES
4177community:
4178 resolution: 1.0
4179 seed: 42
4180"#;
4181 let (mut engine, _tmp) =
4182 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4183 let (actor, client) = cli_actor();
4184
4185 let mut sections: IndexMap<String, String> = IndexMap::new();
4186 sections.insert(
4187 "body".to_string(),
4188 "see [[team/sub-mem--target]]".to_string(),
4189 );
4190 let err = engine
4191 .create_entity(
4192 CreateEntityArgs {
4193 mem: "v".to_string(),
4194 title: "Source".to_string(),
4195 entity_type: "doc".to_string(),
4196 sections,
4197 metadata: IndexMap::new(),
4198 relations: Vec::new(),
4199 dry_run: false,
4200 },
4201 actor,
4202 Some(&client),
4203 None,
4204 )
4205 .unwrap_err();
4206 match err {
4207 EngineError::InvalidWikiLinkTarget {
4208 raw,
4209 suggested,
4210 section,
4211 link_source,
4212 ..
4213 } => {
4214 assert_eq!(raw, "team/sub-mem--target");
4215 assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
4216 assert_eq!(section, "body");
4217 assert_eq!(link_source, "body_link");
4218 }
4219 other => panic!(
4220 "hierarchical dash-form body link must refuse with InvalidWikiLinkTarget; got {other:?}"
4221 ),
4222 }
4223
4224 let listed = engine.store().all_entities().collect::<Vec<_>>();
4227 assert!(
4228 listed.is_empty(),
4229 "refused create must not leave any entity behind, got: {listed:?}"
4230 );
4231 }
4232 }
4233
4234 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";
4243
4244 fn repair_engine() -> (TempDir, Engine) {
4245 let tmp = TempDir::new().unwrap();
4246 let mem_dir = tmp.path().to_path_buf();
4247 std::fs::write(
4248 mem_dir.join("anchor.md"),
4249 "---\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",
4250 )
4251 .unwrap();
4252 std::fs::write(mem_dir.join("drifted.md"), DRIFTED_MD).unwrap();
4253 let writer = FilesystemMemWriter::new(mem_dir.clone());
4254 let engine = Engine::from_mounts(vec![(
4255 folder_mount("specs", mem_dir),
4256 Box::new(writer) as Box<dyn MemBackend>,
4257 )])
4258 .unwrap();
4259 (tmp, engine)
4260 }
4261
4262 fn repair_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
4263 UpdateEntityArgs {
4264 id,
4265 expected_hash: hash,
4266 sections: IndexMap::new(),
4267 append_sections: IndexMap::new(),
4268 patch_sections: IndexMap::new(),
4269 metadata: IndexMap::new(),
4270 metadata_unset: Vec::new(),
4271 declare_relations: Vec::new(),
4272 dry_run: false,
4273 relations_unset: vec![crate::ops::RelationUnsetArg {
4274 rel_type: "USES".to_string(),
4275 target: EntityId::new("specs", "anchor"),
4276 }],
4277 }
4278 }
4279
4280 #[test]
4285 fn relations_unset_on_conformant_entity_refuses_repair_not_needed() {
4286 let (_tmp, mut engine) = repair_engine();
4287 let anchor = EntityId::new("specs", "anchor");
4290 let drifted = EntityId::new("specs", "drifted");
4291 engine
4292 .relate_entity(
4293 RelateEntityArgs {
4294 source: anchor.clone(),
4295 expected_hash: None,
4296 rel_type: "USES".to_string(),
4297 target: drifted.clone(),
4298 remove: false,
4299 description: None,
4300 },
4301 Actor::Cli,
4302 None,
4303 None,
4304 )
4305 .expect("relate on conformant entity works");
4306 let mut args = repair_args(anchor.clone(), None);
4307 args.relations_unset[0].target = drifted.clone();
4308 let err = engine
4309 .update_entity(args, Actor::Cli, None, None)
4310 .unwrap_err();
4311 match err {
4312 EngineError::RepairNotNeeded { id, recovery } => {
4313 assert_eq!(id, anchor.to_string());
4314 assert!(
4315 recovery.contains("memstead_relate"),
4316 "recovery must point at the focused tool; got {recovery}"
4317 );
4318 }
4319 other => panic!("expected RepairNotNeeded, got {other:?}"),
4320 }
4321 let entity = engine.store().get(&anchor).unwrap();
4323 assert!(
4324 entity.relationships.iter().any(|r| r.target == drifted),
4325 "gate must not modify the entity"
4326 );
4327 }
4328
4329 #[test]
4334 fn relations_unset_repairs_non_conformant_entity_atomically() {
4335 let (_tmp, mut engine) = repair_engine();
4336 let drifted = EntityId::new("specs", "drifted");
4337 let pre = engine.conformance_findings("specs", None).unwrap();
4339 assert!(
4340 pre.iter().any(|f| f.id == drifted.to_string()),
4341 "fixture must lint non-conformant; got {pre:?}"
4342 );
4343 let mut args = repair_args(drifted.clone(), None);
4344 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
4345 engine
4346 .update_entity(args, Actor::Cli, None, None)
4347 .expect("repair update lands");
4348 let entity = engine.store().get(&drifted).unwrap();
4349 assert!(
4350 entity.relationships.is_empty(),
4351 "relation must be removed; got {:?}",
4352 entity.relationships
4353 );
4354 assert!(
4355 !entity.metadata.contains_key("zzz_bogus_field"),
4356 "conformance break must be repaired in the same update"
4357 );
4358 let post = engine.conformance_findings("specs", None).unwrap();
4359 assert!(
4360 post.iter().all(|f| f.id != drifted.to_string()),
4361 "post-repair entity must be conformant; got {post:?}"
4362 );
4363 }
4364
4365 #[test]
4369 fn relations_unset_post_state_must_still_validate() {
4370 let (_tmp, mut engine) = repair_engine();
4371 let drifted = EntityId::new("specs", "drifted");
4372 let mut args = repair_args(drifted.clone(), None);
4373 args.sections = IndexMap::from_iter([("nonexistent_section".to_string(), "x".to_string())]);
4376 let err = engine
4377 .update_entity(args, Actor::Cli, None, None)
4378 .unwrap_err();
4379 assert_eq!(
4380 err.code(),
4381 "UNKNOWN_SECTION",
4382 "strict-write post-condition must hold during repair; got {err:?}"
4383 );
4384 let entity = engine.store().get(&drifted).unwrap();
4386 assert!(
4387 !entity.relationships.is_empty(),
4388 "refused repair must not partially apply"
4389 );
4390 }
4391
4392 #[test]
4395 fn relations_unset_absent_pair_is_silent_noop() {
4396 let (_tmp, mut engine) = repair_engine();
4397 let drifted = EntityId::new("specs", "drifted");
4398 let mut args = repair_args(drifted.clone(), None);
4399 args.relations_unset[0].rel_type = "NEVER_DECLARED".to_string();
4400 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
4402 engine
4403 .update_entity(args, Actor::Cli, None, None)
4404 .expect("absent pair no-ops, update lands");
4405 let entity = engine.store().get(&drifted).unwrap();
4406 assert_eq!(
4407 entity.relationships.len(),
4408 1,
4409 "the USES relation must survive an unmatched unset"
4410 );
4411 }
4412}