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_unsettable_metadata_key, 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, unknown_type_error,
23 validate_relation_target_grammar,
24};
25use crate::engine::outcomes::RelationDeclared;
26use crate::entity::{Entity, Relationship};
27
28use std::sync::Arc;
29
30enum PrepareOutcome {
34 Done(UpdateEntityOutcome),
37 Prepared(PreparedUpdate),
40}
41
42struct PreparedUpdate {
46 mount_idx: usize,
47 id: EntityId,
48 mem: String,
49 type_def: Arc<memstead_schema::TypeDefinition>,
50 file_path: String,
51 markdown: String,
52 prev_body_targets: std::collections::HashSet<EntityId>,
55 modified_date: String,
56 modified_sections: ModifiedSections,
57 modified_metadata: ModifiedMetadata,
58 warnings: Vec<WarningHint>,
59 relations_declared: Vec<RelationDeclared>,
60 anchors: Vec<crate::anchor::Anchor>,
64 anchor_unsets: Vec<crate::anchor::AnchorUnset>,
68 anchor_only: bool,
79}
80
81struct AppliedWrite {
84 content_hash: String,
85 title: String,
86 orphan_stubs_removed: Vec<EntityId>,
87}
88
89impl Engine {
90 pub fn update_entity(
106 &mut self,
107 args: UpdateEntityArgs,
108 actor: Actor,
109 client: Option<&ClientId>,
110 note: Option<&str>,
111 ) -> Result<UpdateEntityOutcome, EngineError> {
112 let mut drift_warnings = self.reload_if_stale(Some(args.id.mem()));
119 if args.declare_relations.iter().any(|r| {
129 self.schemas.get(args.id.mem()).is_some_and(|s| {
130 s.relationship_acyclic(&r.rel_type)
131 || s.acyclic_set_containing(&r.rel_type).is_some()
132 }) || self
133 .schemas
134 .get(r.to.mem())
135 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
136 }) || self
137 .schemas
138 .get(args.id.mem())
139 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
140 {
141 self.ensure_mems_loaded(None);
142 }
143 let mut outcome = match self.prepare_update(args)? {
144 PrepareOutcome::Done(outcome) => outcome,
145 PrepareOutcome::Prepared(prepared) => {
146 self.commit_prepared_update(prepared, actor, client, note)?
147 }
148 };
149 drift_warnings.append(&mut outcome.warnings);
150 outcome.warnings = drift_warnings;
151 Ok(outcome)
152 }
153
154 fn commit_prepared_update(
159 &mut self,
160 prepared: PreparedUpdate,
161 actor: Actor,
162 client: Option<&ClientId>,
163 note: Option<&str>,
164 ) -> Result<UpdateEntityOutcome, EngineError> {
165 let signal_snapshot = {
174 let mut candidates: Vec<EntityId> = vec![prepared.id.clone()];
175 candidates.extend(
176 self.store
177 .outgoing(&prepared.id)
178 .iter()
179 .map(|e| e.target.clone()),
180 );
181 candidates.extend(
182 self.store
183 .incoming(&prepared.id)
184 .iter()
185 .map(|e| e.from.clone()),
186 );
187 if let Ok(parsed) = parse_markdown(
188 &prepared.markdown,
189 &prepared.file_path,
190 prepared.type_def.as_ref(),
191 &prepared.mem,
192 ) {
193 candidates.extend(parsed.entity.relationships.iter().map(|r| r.target.clone()));
194 }
195 crate::ops::signals::snapshot_levels(&self.store, &self.schemas, candidates.iter())
196 };
197 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
198 backend.write_entity(Path::new(&prepared.file_path), prepared.markdown.as_bytes())?;
199 if !prepared.anchors.is_empty() || !prepared.anchor_unsets.is_empty() {
202 super::stage_anchors_sidecar(
203 backend,
204 &prepared.id,
205 &prepared.anchor_unsets,
206 prepared.anchors.clone(),
207 )?;
208 }
209 if let Some(schema) = self.schemas.get(prepared.id.mem()) {
213 for r in prepared
214 .relations_declared
215 .iter()
216 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
217 {
218 let hash = self
219 .store
220 .get(&r.target)
221 .map(|e| e.content_hash.clone())
222 .unwrap_or_default();
223 let (from, rel, to) = (
224 prepared.id.to_string(),
225 r.rel_type.clone(),
226 r.target.to_string(),
227 );
228 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
229 }
230 }
231 let commit_subject = if prepared.anchor_only {
237 format!("memstead: anchor {}", prepared.id)
238 } else {
239 format!("memstead: update {}", prepared.id)
240 };
241 let ctx = CommitContext {
242 actor,
243 client: client.cloned(),
244 tool: Some("update_entity"),
245 note: note.map(String::from),
246 role: self.current_role,
247 logical_operation_id: None,
248 entity_ids: None,
249 };
250 let commit_sha = backend.commit(&commit_subject, &ctx)?;
251 backend.append_provenance(
252 &Provenance::new(
253 std::time::SystemTime::now(),
254 ProvenanceKind::Update,
255 Some(prepared.id.to_string()),
256 actor,
257 client.cloned(),
258 note.map(String::from),
259 )
260 .with_role(self.current_role),
261 )?;
262 self.record_self_write(prepared.mount_idx, &commit_sha);
263 self.stamp_mutation_versions(prepared.mount_idx);
264
265 let applied = self.apply_prepared_to_store(&prepared)?;
266
267 self.invalidate_communities();
268 self.maintain_search_indexes(std::slice::from_ref(&prepared.id));
272
273 let mut warnings = prepared.warnings;
277 warnings.extend(crate::ops::signals::crossing_warnings(
280 &self.store,
281 &self.schemas,
282 &signal_snapshot,
283 ));
284 if let Some(w) = self.note_missing_warning("update_entity", note) {
285 warnings.push(w);
286 }
287
288 Ok(UpdateEntityOutcome {
289 id: prepared.id.clone(),
290 title: applied.title,
291 file_path: prepared.file_path,
292 content_hash: applied.content_hash,
293 commit_sha,
294 modified_date: prepared.modified_date,
295 orphan_stubs_removed: applied.orphan_stubs_removed,
296 modified_sections: prepared.modified_sections,
297 modified_metadata: prepared.modified_metadata,
298 prospective_hash: None,
299 warnings,
300 relations_declared: prepared.relations_declared,
301 })
302 }
303
304 fn apply_prepared_to_store(
311 &mut self,
312 prepared: &PreparedUpdate,
313 ) -> Result<AppliedWrite, EngineError> {
314 let parse_result = parse_markdown(
315 &prepared.markdown,
316 &prepared.file_path,
317 prepared.type_def.as_ref(),
318 &prepared.mem,
319 )
320 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
321 let content_hash = parse_result.entity.content_hash.clone();
322 let title = parse_result.entity.title.clone();
323 let fallback = engine_fallback_type();
324 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
325 crate::entity::store_builder::remap_alias_target_edge_sources(
326 &mut self.store,
327 &self.schemas,
328 );
329 let orphan_stubs_removed =
330 super::gc_orphan_stubs_among(&mut self.store, &prepared.prev_body_targets);
331 Ok(AppliedWrite {
332 content_hash,
333 title,
334 orphan_stubs_removed,
335 })
336 }
337
338 fn prepare_update(&mut self, args: UpdateEntityArgs) -> Result<PrepareOutcome, EngineError> {
346 let id = &args.id;
347 let mem = id.mem().to_string();
348
349 let mount_idx = self
350 .mounts
351 .iter()
352 .position(|m| m.mount.mem == mem)
353 .ok_or_else(|| self.unknown_mem_error(&mem))?;
354 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
355 return Err(EngineError::ReadOnlyMount(mem));
356 }
357
358 let entity = self
359 .store
360 .get(id)
361 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
362
363 let prev_body_targets = super::collect_body_link_targets(entity);
369
370 if entity.stub {
378 return Err(EngineError::StubNotUpdatable { id: id.to_string() });
379 }
380
381 if !args.dry_run
387 && let Some(expected) = args.expected_hash.as_deref()
388 && entity.content_hash != expected
389 {
390 return Err(EngineError::HashMismatch {
391 id: id.to_string(),
392 current: entity.content_hash.clone(),
393 is_stub: entity.stub,
394 });
395 }
396
397 if args.sections.is_empty()
409 && args.append_sections.is_empty()
410 && args.patch_sections.is_empty()
411 && args.metadata.is_empty()
412 && args.metadata_unset.is_empty()
413 && args.declare_relations.is_empty()
414 && args.relations_unset.is_empty()
415 && args.anchors.is_empty()
416 && args.anchors_unset.is_empty()
417 {
418 return Err(EngineError::EmptyUpdate { id: id.to_string() });
419 }
420
421 let validated_anchors = self.validate_anchor_inputs(&mem, &args.anchors)?;
427 let validated_anchor_unsets = Self::validate_anchor_unsets(&args.anchors_unset)?;
428
429 let schema = self
430 .schemas
431 .get(&mem)
432 .expect("schema present for every registered mount")
433 .clone();
434 let type_def = schema
435 .get_type(&entity.entity_type)
436 .ok_or_else(|| unknown_type_error(schema.as_ref(), &entity.entity_type))?;
437
438 for key in args.sections.keys() {
445 let mut modes = vec!["sections".to_string()];
446 if args.append_sections.contains_key(key) {
447 modes.push("append_sections".to_string());
448 }
449 if args.patch_sections.contains_key(key) {
450 modes.push("patch_sections".to_string());
451 }
452 if modes.len() > 1 {
453 return Err(EngineError::ConflictingSectionModes {
454 section: key.clone(),
455 modes,
456 });
457 }
458 }
459 for key in args.append_sections.keys() {
460 if args.patch_sections.contains_key(key) {
461 return Err(EngineError::ConflictingSectionModes {
462 section: key.clone(),
463 modes: vec!["append_sections".to_string(), "patch_sections".to_string()],
464 });
465 }
466 }
467
468 validate_section_keys(
469 args.sections
470 .keys()
471 .chain(args.append_sections.keys())
472 .chain(args.patch_sections.keys())
473 .map(String::as_str),
474 type_def.as_ref(),
475 )?;
476 validate_section_content(
482 args.sections
483 .iter()
484 .map(|(k, v)| (k.as_str(), v.as_str()))
485 .chain(
486 args.append_sections
487 .iter()
488 .map(|(k, v)| (k.as_str(), v.as_str())),
489 )
490 .chain(
491 args.patch_sections
492 .iter()
493 .map(|(k, p)| (k.as_str(), p.new.as_str())),
494 ),
495 )?;
496 for key in args.sections.keys() {
497 validate_updatable_section(key.as_str(), type_def.as_ref())?;
498 }
499 for key in args.append_sections.keys() {
500 validate_updatable_section(key.as_str(), type_def.as_ref())?;
501 }
502 for key in args.patch_sections.keys() {
503 validate_updatable_section(key.as_str(), type_def.as_ref())?;
504 }
505 for key in args.metadata.keys() {
506 validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
507 }
508 for key in &args.metadata_unset {
514 validate_unsettable_metadata_key(key.as_str(), type_def.as_ref())?;
515 }
516
517 let mut overlap: Vec<String> = args
524 .metadata
525 .keys()
526 .filter(|k| args.metadata_unset.iter().any(|u| u == k.as_str()))
527 .cloned()
528 .collect();
529 if !overlap.is_empty() {
530 overlap.sort();
531 overlap.dedup();
532 return Err(EngineError::SetAndUnsetConflict { keys: overlap });
533 }
534
535 if !args.relations_unset.is_empty() {
544 let findings = crate::ops::integrity::entity_conformance_findings(
545 &self.store,
546 entity,
547 schema.as_ref(),
548 &self.schemas,
549 );
550 if findings.is_empty() {
551 return Err(EngineError::RepairNotNeeded {
552 id: id.to_string(),
553 recovery: "use memstead_relate(remove=true) to detach an edge from a conformant entity, or the additive memstead_update params to evolve it"
554 .to_string(),
555 });
556 }
557 }
558
559 let mut next = entity.clone();
560
561 for unset in &args.relations_unset {
568 let canonical = crate::entity::id::validate_rel_type(&unset.rel_type)
569 .unwrap_or_else(|_| unset.rel_type.clone());
570 next.relationships
571 .retain(|r| !(r.rel_type == canonical && r.target == unset.target));
572 }
573
574 let relations_declared = apply_declare_relations(
584 self,
585 &mut next,
586 &args.declare_relations,
587 &mem,
588 mount_idx,
589 type_def.as_ref(),
590 schema.as_ref(),
591 )?;
592
593 let format_touched: std::collections::HashSet<String> = args
597 .sections
598 .keys()
599 .chain(args.append_sections.keys())
600 .chain(args.patch_sections.keys())
601 .cloned()
602 .collect();
603
604 let mut modified_sections: Vec<String> = Vec::new();
605 for (key, body) in args.sections {
606 modified_sections.push(key.clone());
607 next.sections.insert(key, body);
608 }
609
610 let mut modified_sections_appended: Vec<String> = Vec::new();
614 for (key, value) in args.append_sections {
615 let existing = next.sections.get(&key).cloned().unwrap_or_default();
616 let new_content = if existing.trim().is_empty() {
617 value
618 } else {
619 format!("{existing}\n{value}")
620 };
621 next.sections.insert(key.clone(), new_content);
622 modified_sections_appended.push(key);
623 }
624
625 let mut modified_sections_patched: Vec<String> = Vec::new();
633 for (key, patch) in args.patch_sections {
634 let existing = next
635 .sections
636 .get(&key)
637 .ok_or_else(|| EngineError::PatchSectionEmpty {
638 section: key.clone(),
639 })?
640 .clone();
641 if !existing.contains(&patch.old) {
642 let cap = PATCH_OLD_NOT_FOUND_CONTENT_CAP;
643 let truncated = existing.len() > cap;
644 let mut cut = cap.min(existing.len());
647 while cut > 0 && !existing.is_char_boundary(cut) {
648 cut -= 1;
649 }
650 let current_content = if truncated {
651 existing[..cut].to_string()
652 } else {
653 existing.clone()
654 };
655 return Err(EngineError::PatchOldNotFound {
656 section: key,
657 current_content,
658 truncated,
659 });
660 }
661 let patched = if patch.all {
662 existing.replace(&patch.old, &patch.new)
663 } else {
664 existing.replacen(&patch.old, &patch.new, 1)
665 };
666 next.sections.insert(key.clone(), patched);
667 modified_sections_patched.push(key);
668 }
669
670 let mut modified_metadata_set: Vec<String> = Vec::new();
671 for (key, value) in &args.metadata {
672 let parsed = parse_metadata_value(key.as_str(), value.as_str(), type_def.as_ref())?;
673 modified_metadata_set.push(key.clone());
674 next.metadata.insert(key.clone(), parsed);
675 }
676
677 let mut modified_metadata_unset: Vec<String> = Vec::new();
678 for key in args.metadata_unset {
679 if crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str()) {
691 if key == "type" {
692 let authoritative =
693 crate::entity::MetadataValue::String(next.entity_type.clone());
694 if next
695 .metadata
696 .shift_remove("type")
697 .is_some_and(|removed| removed != authoritative)
698 {
699 modified_metadata_unset.push(key);
700 }
701 next.metadata.insert("type".to_string(), authoritative);
702 } else if next.metadata.shift_remove(&key).is_some() {
703 modified_metadata_unset.push(key);
704 }
705 continue;
706 }
707 let field_def = type_def.metadata_field(&key);
712 let is_required = field_def.map(|f| f.is_required()).unwrap_or(false);
713 if is_required {
714 let (field_description, enum_values) = match field_def {
715 Some(f) => (
716 Some(f.description.clone()),
717 f.enum_values.clone().unwrap_or_default(),
718 ),
719 None => (None, Vec::new()),
720 };
721 return Err(EngineError::RequiredFieldUnset {
722 field: key,
723 entity_type: type_def.name.clone(),
724 field_description,
725 enum_values,
726 type_write_rules: type_def.write_rules.clone(),
727 on_create: false,
733 missing: Vec::new(),
738 });
739 }
740 if next.metadata.shift_remove(&key).is_some() {
741 modified_metadata_unset.push(key);
742 }
743 }
744
745 let today = self.now_iso();
754
755 let (synthesised_relations, self_link_ignored) =
766 super::synthesise_alias_relations(self, &prev_body_targets, &mut next)?;
767
768 let missing = super::scan_wikilinks_without_relation(&next)?;
774 if !missing.is_empty() {
775 return Err(EngineError::WikiLinkWithoutRelation {
776 from_id: id.to_string(),
777 missing: missing
778 .into_iter()
779 .map(|(section_key, target)| crate::engine::MissingWikiLink {
780 section_key,
781 target_id: target.to_string(),
782 })
783 .collect(),
784 });
785 }
786
787 let file_path = next.file_path.clone();
788
789 let markdown_pre_stamp = generate_markdown(&next, type_def.as_ref());
798
799 let content_unchanged =
810 crate::entity::parser::compute_hash(&markdown_pre_stamp) == next.content_hash;
811
812 if !args.dry_run {
827 if content_unchanged
832 && validated_anchors.is_empty()
833 && validated_anchor_unsets.is_empty()
834 {
835 let modified_date = next
840 .metadata
841 .get("last_modified")
842 .and_then(|v| v.as_str().map(str::to_string))
843 .unwrap_or_default();
844 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
845 id: id.clone(),
846 title: next.title.clone(),
847 file_path,
848 content_hash: next.content_hash.clone(),
849 commit_sha: String::new(),
850 modified_date,
851 modified_sections: ModifiedSections::default(),
860 modified_metadata: ModifiedMetadata::default(),
861 prospective_hash: None,
862 orphan_stubs_removed: Vec::new(),
865 warnings: vec![WarningHint::UpdateNoop { id: id.clone() }],
866 relations_declared,
867 }));
868 }
869 }
870
871 if !content_unchanged {
881 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
882 }
883 let markdown = generate_markdown(&next, type_def.as_ref());
884
885 let mut warnings: Vec<WarningHint> = Vec::new();
886
887 for key in modified_sections
896 .iter()
897 .chain(modified_sections_appended.iter())
898 .chain(modified_sections_patched.iter())
899 {
900 let Some(def) = type_def.section(key) else {
901 continue;
902 };
903 if let Some(existing) = next.raw_section_headings.iter().find(|h| {
904 h.as_str() != def.heading && memstead_schema::derive_section_key(h) == *key
905 }) {
906 warnings.push(WarningHint::SectionHeadingDivergence {
907 entity_id: id.clone(),
908 section_key: key.clone(),
909 writing_heading: def.heading.clone(),
910 existing_heading: existing.clone(),
911 });
912 }
913 }
914
915 for def in &type_def.sections {
929 if def.format_severity != memstead_schema::ConstraintSeverity::Block {
930 continue;
931 }
932 if !format_touched.contains(def.key.as_str()) {
933 continue;
934 }
935 let Some(body) = next.sections.get(def.key.as_str()) else {
936 continue;
937 };
938 if let Some(first) = crate::section_format::check_section_format(def, body)
939 .into_iter()
940 .next()
941 {
942 return Err(EngineError::SectionFormatRefused {
943 entity_type: next.entity_type.clone(),
944 entity_id: id.to_string(),
945 violation: first,
946 });
947 }
948 }
949
950 let unsatisfied =
951 crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref());
952 if !unsatisfied.is_empty() {
953 let blocked: Vec<_> = unsatisfied
957 .iter()
958 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
959 .cloned()
960 .collect();
961 if !blocked.is_empty() {
962 return Err(EngineError::RequiredOutgoingUnsatisfied {
963 entity_type: next.entity_type.clone(),
964 entity_id: id.to_string(),
965 missing: blocked,
966 });
967 }
968 warnings.push(WarningHint::MissingRequiredOutgoing {
969 entity_type: next.entity_type.clone(),
970 entity_id: id.clone(),
971 missing: unsatisfied,
972 });
973 }
974
975 let violated = crate::ops::health::unsatisfied_constraints(
979 &self.store,
980 &next,
981 type_def.as_ref(),
982 Some(id),
983 );
984 if !violated.is_empty() {
985 let blocked: Vec<_> = violated
986 .iter()
987 .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
988 .cloned()
989 .collect();
990 if !blocked.is_empty() {
991 return Err(EngineError::ConstraintUnsatisfied {
992 entity_type: next.entity_type.clone(),
993 entity_id: id.to_string(),
994 violations: blocked,
995 });
996 }
997 warnings.push(WarningHint::ConstraintUnsatisfied {
998 entity_type: next.entity_type.clone(),
999 entity_id: id.clone(),
1000 violations: violated,
1001 });
1002 }
1003
1004 let auto_stubbed: Vec<EntityId> = synthesised_relations
1012 .iter()
1013 .filter_map(|rel| {
1014 if !self.store.contains(&rel.target) {
1015 Some(rel.target.clone())
1016 } else {
1017 None
1018 }
1019 })
1020 .collect();
1021 if !auto_stubbed.is_empty() {
1022 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
1023 from: id.clone(),
1024 stubs: auto_stubbed,
1025 });
1026 }
1027 if self_link_ignored {
1030 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
1031 }
1032
1033 if args.dry_run {
1040 let prospective = crate::entity::parser::compute_hash(&markdown);
1041 let current_hash = next.content_hash.clone();
1045 let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1046 today.clone()
1047 } else {
1048 String::new()
1049 };
1050 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
1051 id: id.clone(),
1052 title: next.title.clone(),
1053 file_path,
1054 content_hash: current_hash,
1055 commit_sha: String::new(),
1056 modified_date,
1057 modified_sections: ModifiedSections {
1058 replaced: modified_sections,
1059 appended: modified_sections_appended,
1060 patched: modified_sections_patched,
1061 },
1062 modified_metadata: ModifiedMetadata {
1063 set: modified_metadata_set,
1064 unset: modified_metadata_unset,
1065 },
1066 prospective_hash: Some(prospective),
1067 orphan_stubs_removed: Vec::new(),
1070 warnings,
1071 relations_declared: relations_declared.clone(),
1072 }));
1073 }
1074
1075 let modified_date = if content_unchanged {
1082 next.metadata
1085 .get("last_modified")
1086 .and_then(|v| v.as_str().map(str::to_string))
1087 .unwrap_or_default()
1088 } else if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1089 today.clone()
1090 } else {
1091 String::new()
1092 };
1093
1094 Ok(PrepareOutcome::Prepared(PreparedUpdate {
1095 mount_idx,
1096 id: id.clone(),
1097 mem,
1098 type_def,
1099 file_path,
1100 markdown,
1101 prev_body_targets,
1102 modified_date,
1103 modified_sections: ModifiedSections {
1104 replaced: modified_sections,
1105 appended: modified_sections_appended,
1106 patched: modified_sections_patched,
1107 },
1108 modified_metadata: ModifiedMetadata {
1109 set: modified_metadata_set,
1110 unset: modified_metadata_unset,
1111 },
1112 warnings,
1115 relations_declared,
1116 anchor_only: content_unchanged
1124 && (!validated_anchors.is_empty() || !validated_anchor_unsets.is_empty()),
1125 anchors: validated_anchors,
1126 anchor_unsets: validated_anchor_unsets,
1127 }))
1128 }
1129
1130 pub fn batch_update(
1172 &mut self,
1173 updates: Vec<(UpdateEntityArgs, Option<String>)>,
1174 actor: Actor,
1175 client: Option<&ClientId>,
1176 dry_run: bool,
1177 ) -> Result<crate::ops::BatchResult, EngineError> {
1178 if updates.is_empty() {
1179 return Ok(crate::ops::BatchResult {
1180 orphan_stubs_removed: Vec::new(),
1181 errors_suppressed: 0,
1182 applied: true,
1183 results: Vec::new(),
1184 succeeded: 0,
1185 failed: 0,
1186 commit_sha: String::new(),
1187 });
1188 }
1189
1190 let mut touched_mems: Vec<String> = updates
1197 .iter()
1198 .map(|(a, _)| a.id.mem().to_string())
1199 .collect();
1200 touched_mems.sort();
1201 touched_mems.dedup();
1202 for v in &touched_mems {
1203 self.reload_if_stale(Some(v));
1204 }
1205 if updates.iter().any(|(a, _)| {
1212 a.declare_relations.iter().any(|r| {
1213 self.schemas.get(a.id.mem()).is_some_and(|s| {
1214 s.relationship_acyclic(&r.rel_type)
1215 || s.acyclic_set_containing(&r.rel_type).is_some()
1216 }) || self
1217 .schemas
1218 .get(r.to.mem())
1219 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1220 }) || self
1221 .schemas
1222 .get(a.id.mem())
1223 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1224 }) {
1225 self.ensure_mems_loaded(None);
1226 }
1227
1228 let store_snapshot = self.store.clone();
1234
1235 enum Item {
1241 Prepared,
1242 Noop,
1243 Error,
1244 }
1245 let mut items: Vec<(EntityId, Item)> = Vec::with_capacity(updates.len());
1246 let mut prepared: Vec<PreparedUpdate> = Vec::new();
1247 let mut notes: Vec<Option<String>> = Vec::new();
1248 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1249
1250 for (i, (args, note)) in updates.into_iter().enumerate() {
1255 let id = args.id.clone();
1256 let mut args = args;
1261 args.dry_run = false;
1262 match self.prepare_update(args) {
1263 Ok(PrepareOutcome::Done(_)) => {
1264 items.push((id, Item::Noop));
1266 }
1267 Ok(PrepareOutcome::Prepared(p)) => {
1268 prepared.push(p);
1269 notes.push(note);
1270 items.push((id, Item::Prepared));
1271 }
1272 Err(e) => {
1273 items.push((id, Item::Error));
1274 errors.push((i, e));
1275 }
1276 }
1277 }
1278
1279 if !errors.is_empty() {
1280 self.store = store_snapshot;
1285 self.discard_all_pending();
1286 let failed = errors.len();
1287 let mut error_map: std::collections::HashMap<usize, EngineError> =
1288 errors.into_iter().collect();
1289 let mut reported = 0usize;
1290 let mut suppressed = 0usize;
1291 let results: Vec<crate::ops::BatchEntry> = items
1292 .into_iter()
1293 .enumerate()
1294 .map(|(i, (id, _))| match error_map.remove(&i) {
1295 Some(e) => {
1296 if reported < Self::BATCH_ERROR_REPORT_CAP {
1297 reported += 1;
1298 crate::ops::BatchEntry {
1299 id,
1300 action: "error".to_string(),
1301 error: Some(batch_error_envelope(&e)),
1302 }
1303 } else {
1304 suppressed += 1;
1305 crate::ops::BatchEntry {
1306 id,
1307 action: "error".to_string(),
1308 error: None,
1309 }
1310 }
1311 }
1312 None => crate::ops::BatchEntry {
1313 id,
1314 action: "not_applied".to_string(),
1315 error: None,
1316 },
1317 })
1318 .collect();
1319 return Ok(crate::ops::BatchResult {
1320 orphan_stubs_removed: Vec::new(),
1321 errors_suppressed: suppressed,
1322 applied: false,
1323 results,
1324 succeeded: 0,
1325 failed,
1326 commit_sha: String::new(),
1327 });
1328 }
1329
1330 if dry_run {
1336 self.store = store_snapshot;
1337 self.discard_all_pending();
1338 let succeeded = items.len();
1339 let results: Vec<crate::ops::BatchEntry> = items
1340 .into_iter()
1341 .map(|(id, item)| crate::ops::BatchEntry {
1342 id,
1343 action: match item {
1344 Item::Prepared => "updated".to_string(),
1345 Item::Noop => "noop".to_string(),
1346 Item::Error => unreachable!("refusal path returned above"),
1347 },
1348 error: None,
1349 })
1350 .collect();
1351 return Ok(crate::ops::BatchResult {
1352 orphan_stubs_removed: Vec::new(),
1353 errors_suppressed: 0,
1354 applied: true,
1355 results,
1356 succeeded,
1357 failed: 0,
1358 commit_sha: String::new(),
1359 });
1360 }
1361
1362 for p in &prepared {
1365 if let Err(e) = self.mounts[p.mount_idx]
1366 .backend
1367 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1368 {
1369 self.store = store_snapshot;
1370 self.discard_all_pending();
1371 return Err(e.into());
1372 }
1373 if (!p.anchors.is_empty() || !p.anchor_unsets.is_empty())
1376 && let Err(e) = super::stage_anchors_sidecar(
1377 self.mounts[p.mount_idx].backend.as_ref(),
1378 &p.id,
1379 &p.anchor_unsets,
1380 p.anchors.clone(),
1381 )
1382 {
1383 self.store = store_snapshot;
1384 self.discard_all_pending();
1385 return Err(e);
1386 }
1387 if let Some(schema) = self.schemas.get(p.id.mem()) {
1390 for r in p
1391 .relations_declared
1392 .iter()
1393 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1394 {
1395 let hash = self
1396 .store
1397 .get(&r.target)
1398 .map(|e| e.content_hash.clone())
1399 .unwrap_or_default();
1400 let (from, rel, to) =
1401 (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1402 if let Err(e) = super::stage_derivation_sidecar(
1403 self.mounts[p.mount_idx].backend.as_ref(),
1404 |s| s.set(&from, &rel, &to, &hash),
1405 ) {
1406 self.store = store_snapshot;
1407 self.discard_all_pending();
1408 return Err(e);
1409 }
1410 }
1411 }
1412 }
1413
1414 let mut distinct_mounts: Vec<usize> = Vec::new();
1416 for p in &prepared {
1417 if !distinct_mounts.contains(&p.mount_idx) {
1418 distinct_mounts.push(p.mount_idx);
1419 }
1420 }
1421 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1422 for &m in &distinct_mounts {
1423 let entity_ids: Vec<String> = prepared
1424 .iter()
1425 .filter(|p| p.mount_idx == m)
1426 .map(|p| p.id.to_string())
1427 .collect();
1428 let count = entity_ids.len();
1429 let subject = format!("memstead: batch-update ({count} entities)");
1430 let note_lines: Vec<String> = prepared
1435 .iter()
1436 .zip(notes.iter())
1437 .filter(|(p, _)| p.mount_idx == m)
1438 .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.id)))
1439 .collect();
1440 let ctx = CommitContext {
1441 actor,
1442 client: client.cloned(),
1443 tool: Some("batch_update"),
1444 note: if note_lines.is_empty() {
1445 None
1446 } else {
1447 Some(note_lines.join("\n"))
1448 },
1449 role: self.current_role,
1450 logical_operation_id: None,
1451 entity_ids: Some(entity_ids),
1455 };
1456 match self.mounts[m].backend.commit(&subject, &ctx) {
1457 Ok(sha) => mount_commits.push((m, sha)),
1458 Err(e) => {
1459 self.store = store_snapshot;
1463 self.discard_all_pending();
1464 return Err(e.into());
1465 }
1466 }
1467 }
1468
1469 for (p, note) in prepared.iter().zip(notes.iter()) {
1473 let commit_sha = mount_commits
1474 .iter()
1475 .find(|(m, _)| *m == p.mount_idx)
1476 .map(|(_, s)| s.clone())
1477 .unwrap_or_default();
1478 self.mounts[p.mount_idx].backend.append_provenance(
1479 &Provenance::new(
1480 std::time::SystemTime::now(),
1481 ProvenanceKind::Update,
1482 Some(p.id.to_string()),
1483 actor,
1484 client.cloned(),
1485 note.clone(),
1486 )
1487 .with_role(self.current_role),
1488 )?;
1489 self.record_self_write(p.mount_idx, &commit_sha);
1490 self.stamp_mutation_versions(p.mount_idx);
1491 self.apply_prepared_to_store(p)?;
1492 }
1493
1494 self.invalidate_communities();
1495 self.invalidate_search_indexes();
1496
1497 let commit_sha = mount_commits
1500 .last()
1501 .map(|(_, s)| s.clone())
1502 .unwrap_or_default();
1503 let succeeded = items.len();
1504 let results: Vec<crate::ops::BatchEntry> = items
1505 .into_iter()
1506 .map(|(id, item)| crate::ops::BatchEntry {
1507 id,
1508 action: match item {
1509 Item::Prepared => "updated".to_string(),
1510 Item::Noop => "noop".to_string(),
1511 Item::Error => unreachable!("refusal path returned above"),
1512 },
1513 error: None,
1514 })
1515 .collect();
1516
1517 Ok(crate::ops::BatchResult {
1518 orphan_stubs_removed: Vec::new(),
1519 errors_suppressed: 0,
1520 applied: true,
1521 results,
1522 succeeded,
1523 failed: 0,
1524 commit_sha,
1525 })
1526 }
1527
1528 pub(super) fn discard_all_pending(&self) {
1533 for mount in &self.mounts {
1534 let _ = mount.backend.discard_pending();
1535 }
1536 }
1537
1538 pub fn update_entity_with_ctx(
1541 &mut self,
1542 args: UpdateEntityArgs,
1543 ctx: &CommitContext<'_>,
1544 ) -> Result<UpdateEntityOutcome, EngineError> {
1545 self.update_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1546 }
1547}
1548
1549pub(super) fn batch_error_envelope(err: &EngineError) -> crate::ops::BatchError {
1556 let code = err.code().to_string();
1562 let message = err.to_string();
1563 let details = err.details();
1564 crate::ops::BatchError {
1565 code,
1566 message,
1567 details,
1568 }
1569}
1570
1571fn apply_declare_relations(
1586 engine: &mut Engine,
1587 next: &mut Entity,
1588 declarations: &[crate::ops::RelateArg],
1589 source_mem: &str,
1590 source_mount_idx: usize,
1591 type_def: &memstead_schema::TypeDefinition,
1592 schema: &memstead_schema::Schema,
1593) -> Result<Vec<RelationDeclared>, EngineError> {
1594 let _ = type_def; let _ = source_mount_idx; let mut declared: Vec<RelationDeclared> = Vec::with_capacity(declarations.len());
1597 for rel in declarations {
1598 let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
1601 .unwrap_or_else(|_| rel.rel_type.clone());
1602
1603 validate_relation_target_grammar(&rel.to)?;
1604
1605 let target_mem = rel.to.mem().to_string();
1606 super::validate_cross_mem_add_policy(engine, source_mem, &rel.to)?;
1609
1610 let target_type = engine
1619 .store
1620 .get(&rel.to)
1621 .map(|e| e.entity_type.clone())
1622 .filter(|t| !t.is_empty());
1623 let target_type = match target_type {
1626 Some(t) => Some(t),
1627 None => super::peek_deferred_target_type(engine, &rel.to)?,
1628 };
1629 let _ = super::route_edge_validation(
1630 engine,
1631 &canonical,
1632 next.entity_type.as_str(),
1633 target_type.as_deref(),
1634 source_mem,
1635 &target_mem,
1636 &next.id,
1637 &rel.to,
1638 true,
1639 )?;
1640
1641 let normalised_description =
1646 crate::entity::normalise_description(rel.description.as_deref());
1647 super::validate_description_posture(
1648 engine,
1649 &canonical,
1650 normalised_description.as_deref(),
1651 source_mem,
1652 &target_mem,
1653 &next.id,
1654 &rel.to,
1655 )?;
1656 super::validate_manual_authoring_posture(
1659 engine, &canonical, source_mem, &next.id, &rel.to,
1660 )?;
1661
1662 super::validate_edge_acyclicity(
1666 &engine.store,
1667 schema,
1668 &next.id,
1669 next.entity_type.as_str(),
1670 &rel.to,
1671 &canonical,
1672 )?;
1673
1674 let exists = next
1679 .relationships
1680 .iter()
1681 .any(|r| r.rel_type == canonical && r.target == rel.to);
1682 if !exists {
1683 next.relationships.push(Relationship {
1684 rel_type: canonical.clone(),
1685 target: rel.to.clone(),
1686 description: normalised_description,
1687 });
1688 }
1689
1690 let target_was_stubbed = !engine.store.contains(&rel.to);
1695 if target_was_stubbed && !exists {
1696 let kind = super::deferred_verified_stub_kind(engine, &rel.to)?;
1697 engine
1698 .store
1699 .upsert(rel.to.clone(), make_stub(&rel.to, kind));
1700 }
1701
1702 declared.push(RelationDeclared {
1703 rel_type: canonical,
1704 target: rel.to.clone(),
1705 target_was_stubbed,
1706 });
1707 }
1708 Ok(declared)
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713
1714 use indexmap::IndexMap;
1715 use tempfile::TempDir;
1716
1717 use crate::backend::MemBackend;
1718 use crate::engine::test_helpers::*;
1719 use crate::engine::{
1720 CreateEntityArgs, Engine, EngineError, RelateEntityArgs, UpdateEntityArgs,
1721 };
1722 use crate::entity::EntityId;
1723
1724 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1725 use crate::vcs::Actor;
1726
1727 #[test]
1733 fn update_warns_on_section_heading_divergence_and_still_commits() {
1734 let tmp = TempDir::new().unwrap();
1735 let mem_dir = tmp.path().to_path_buf();
1736 std::fs::write(
1739 mem_dir.join("diverged.md"),
1740 "---\ntype: spec\n---\n# Diverged\n\n## IDENTITY\n\nold text.\n",
1741 )
1742 .unwrap();
1743 let writer = FilesystemMemWriter::new(mem_dir.clone());
1744 let mut engine = Engine::from_mounts(vec![(
1745 folder_mount("specs", mem_dir),
1746 Box::new(writer) as Box<dyn MemBackend>,
1747 )])
1748 .unwrap();
1749 let (actor, client) = cli_actor();
1750 let id = EntityId::new("specs", "diverged");
1751
1752 let update_identity = |engine: &mut Engine, body: &str| {
1753 let current = engine.get_entity(&id).unwrap().content_hash.clone();
1754 let mut sections = IndexMap::new();
1755 sections.insert("identity".to_string(), body.to_string());
1756 engine
1757 .update_entity(
1758 UpdateEntityArgs {
1759 anchors: Vec::new(),
1760 id: id.clone(),
1761 expected_hash: Some(current),
1762 sections,
1763 append_sections: IndexMap::new(),
1764 patch_sections: IndexMap::new(),
1765 metadata: IndexMap::new(),
1766 metadata_unset: Vec::new(),
1767 declare_relations: Vec::new(),
1768 dry_run: false,
1769 relations_unset: Vec::new(),
1770 anchors_unset: Vec::new(),
1771 },
1772 actor,
1773 Some(&client),
1774 None,
1775 )
1776 .unwrap()
1777 };
1778
1779 let outcome = update_identity(&mut engine, "new text.");
1780 assert!(!outcome.commit_sha.is_empty(), "the mutation still commits");
1781 let divergences: Vec<_> = outcome
1782 .warnings
1783 .iter()
1784 .filter_map(|w| match w {
1785 crate::ops::WarningHint::SectionHeadingDivergence {
1786 section_key,
1787 writing_heading,
1788 existing_heading,
1789 ..
1790 } => Some((
1791 section_key.clone(),
1792 writing_heading.clone(),
1793 existing_heading.clone(),
1794 )),
1795 _ => None,
1796 })
1797 .collect();
1798 assert_eq!(
1799 divergences,
1800 vec![(
1801 "identity".to_string(),
1802 "Identity".to_string(),
1803 "IDENTITY".to_string()
1804 )],
1805 "warning names both headings; all warnings = {:?}",
1806 outcome.warnings
1807 );
1808
1809 let outcome2 = update_identity(&mut engine, "third text.");
1812 assert!(
1813 !outcome2
1814 .warnings
1815 .iter()
1816 .any(|w| matches!(w, crate::ops::WarningHint::SectionHeadingDivergence { .. })),
1817 "matching heading emits no divergence warning: {:?}",
1818 outcome2.warnings
1819 );
1820 }
1821
1822 #[test]
1823 fn batch_update_empty_batch_returns_zero_counts() {
1824 let tmp = TempDir::new().unwrap();
1827 let mem_dir = tmp.path().to_path_buf();
1828 let writer = FilesystemMemWriter::new(mem_dir.clone());
1829 let mut engine = Engine::from_mounts(vec![(
1830 folder_mount("specs", mem_dir),
1831 Box::new(writer) as Box<dyn MemBackend>,
1832 )])
1833 .unwrap();
1834
1835 let result = engine
1836 .batch_update(Vec::new(), Actor::Cli, None, false)
1837 .unwrap();
1838 assert!(result.applied, "empty batch is a vacuous success");
1839 assert_eq!(result.results.len(), 0);
1840 assert_eq!(result.succeeded, 0);
1841 assert_eq!(result.failed, 0);
1842 assert_eq!(result.commit_sha, "");
1843 }
1844
1845 #[test]
1846 fn batch_update_refuses_whole_batch_when_one_item_fails() {
1847 let tmp = TempDir::new().unwrap();
1854 let mem_dir = tmp.path().to_path_buf();
1855 let writer = FilesystemMemWriter::new(mem_dir.clone());
1856 let mut engine = Engine::from_mounts(vec![(
1857 folder_mount("specs", mem_dir),
1858 Box::new(writer) as Box<dyn MemBackend>,
1859 )])
1860 .unwrap();
1861
1862 let create_args = CreateEntityArgs {
1864 anchors: Vec::new(),
1865 mem: "specs".to_string(),
1866 title: "Seed".to_string(),
1867 entity_type: "spec".to_string(),
1868 sections: IndexMap::from_iter([
1869 ("identity".to_string(), "seed identity".to_string()),
1870 ("purpose".to_string(), "seed purpose".to_string()),
1871 ]),
1872 metadata: IndexMap::new(),
1873 relations: Vec::new(),
1874 dry_run: false,
1875 };
1876 let created = engine
1877 .create_entity(create_args, Actor::Cli, None, None)
1878 .unwrap();
1879
1880 let valid_update = UpdateEntityArgs {
1882 anchors: Vec::new(),
1883 id: created.id.clone(),
1884 expected_hash: Some(created.content_hash.clone()),
1885 sections: IndexMap::from_iter([("identity".to_string(), "updated body".to_string())]),
1886 append_sections: IndexMap::new(),
1887 patch_sections: IndexMap::new(),
1888 metadata: IndexMap::new(),
1889 metadata_unset: Vec::new(),
1890 declare_relations: Vec::new(),
1891 dry_run: false,
1892 relations_unset: Vec::new(),
1893 anchors_unset: Vec::new(),
1894 };
1895 let missing_update = UpdateEntityArgs {
1896 anchors: Vec::new(),
1897 id: EntityId("specs--nonexistent".to_string()),
1898 expected_hash: None,
1899 sections: IndexMap::new(),
1900 append_sections: IndexMap::new(),
1901 patch_sections: IndexMap::new(),
1902 metadata: IndexMap::new(),
1903 metadata_unset: Vec::new(),
1904 declare_relations: Vec::new(),
1905 dry_run: false,
1906 relations_unset: Vec::new(),
1907 anchors_unset: Vec::new(),
1908 };
1909
1910 let result = engine
1911 .batch_update(
1912 vec![(valid_update, None), (missing_update, None)],
1913 Actor::Cli,
1914 None,
1915 false,
1916 )
1917 .unwrap();
1918 assert!(!result.applied, "a failing item must refuse the batch");
1920 assert_eq!(result.results.len(), 2);
1921 assert_eq!(result.succeeded, 0);
1922 assert_eq!(result.failed, 1);
1923 assert_eq!(result.commit_sha, "", "refused batch must not commit");
1924 assert_eq!(result.results[0].action, "not_applied");
1927 assert!(result.results[0].error.is_none());
1928 assert_eq!(result.results[1].action, "error");
1930 let err = result.results[1]
1931 .error
1932 .as_ref()
1933 .expect("failed entry must carry a structured error envelope");
1934 assert_eq!(err.code, "ENTITY_NOT_FOUND");
1935 assert!(err.message.contains("not found"), "got: {}", err.message);
1936
1937 let seed = engine.get_entity(&created.id).unwrap();
1940 assert_eq!(
1941 seed.sections.get("identity").map(String::as_str),
1942 Some("seed identity"),
1943 "refused batch must leave the in-memory store untouched",
1944 );
1945 assert_eq!(
1946 seed.content_hash, created.content_hash,
1947 "refused batch must not change the entity's content hash",
1948 );
1949 }
1950
1951 #[test]
1952 fn batch_update_applies_all_valid_items_as_one_commit() {
1953 let tmp = TempDir::new().unwrap();
1957 let mem_dir = tmp.path().to_path_buf();
1958 let writer = FilesystemMemWriter::new(mem_dir.clone());
1959 let mut engine = Engine::from_mounts(vec![(
1960 folder_mount("specs", mem_dir),
1961 Box::new(writer) as Box<dyn MemBackend>,
1962 )])
1963 .unwrap();
1964
1965 let mk = |title: &str| CreateEntityArgs {
1966 anchors: Vec::new(),
1967 mem: "specs".to_string(),
1968 title: title.to_string(),
1969 entity_type: "spec".to_string(),
1970 sections: IndexMap::from_iter([
1971 ("identity".to_string(), "id".to_string()),
1972 ("purpose".to_string(), "purp".to_string()),
1973 ]),
1974 metadata: IndexMap::new(),
1975 relations: Vec::new(),
1976 dry_run: false,
1977 };
1978 let a = engine
1979 .create_entity(mk("A"), Actor::Cli, None, None)
1980 .unwrap();
1981 let b = engine
1982 .create_entity(mk("B"), Actor::Cli, None, None)
1983 .unwrap();
1984
1985 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
1986 anchors: Vec::new(),
1987 id,
1988 expected_hash: Some(hash),
1989 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
1990 append_sections: IndexMap::new(),
1991 patch_sections: IndexMap::new(),
1992 metadata: IndexMap::new(),
1993 metadata_unset: Vec::new(),
1994 declare_relations: Vec::new(),
1995 dry_run: false,
1996 relations_unset: Vec::new(),
1997 anchors_unset: Vec::new(),
1998 };
1999
2000 let result = engine
2001 .batch_update(
2002 vec![
2003 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
2004 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
2005 ],
2006 Actor::Cli,
2007 None,
2008 false,
2009 )
2010 .unwrap();
2011 assert!(result.applied);
2012 assert_eq!(result.succeeded, 2);
2013 assert_eq!(result.failed, 0);
2014 assert!(
2015 !result.commit_sha.is_empty(),
2016 "applied batch carries the commit"
2017 );
2018 assert!(result.results.iter().all(|e| e.action == "updated"));
2019 assert_eq!(
2021 engine
2022 .get_entity(&a.id)
2023 .unwrap()
2024 .sections
2025 .get("identity")
2026 .map(String::as_str),
2027 Some("A body"),
2028 );
2029 assert_eq!(
2030 engine
2031 .get_entity(&b.id)
2032 .unwrap()
2033 .sections
2034 .get("identity")
2035 .map(String::as_str),
2036 Some("B body"),
2037 );
2038 }
2039
2040 #[test]
2049 fn batch_update_dry_run_reports_receipt_and_writes_nothing() {
2050 let tmp = TempDir::new().unwrap();
2051 let mem_dir = tmp.path().to_path_buf();
2052 let writer = FilesystemMemWriter::new(mem_dir.clone());
2053 let mut engine = Engine::from_mounts(vec![(
2054 folder_mount("specs", mem_dir),
2055 Box::new(writer) as Box<dyn MemBackend>,
2056 )])
2057 .unwrap();
2058
2059 let mk = |title: &str| CreateEntityArgs {
2060 anchors: Vec::new(),
2061 mem: "specs".to_string(),
2062 title: title.to_string(),
2063 entity_type: "spec".to_string(),
2064 sections: IndexMap::from_iter([
2065 ("identity".to_string(), "id".to_string()),
2066 ("purpose".to_string(), "purp".to_string()),
2067 ]),
2068 metadata: IndexMap::new(),
2069 relations: Vec::new(),
2070 dry_run: false,
2071 };
2072 let a = engine
2073 .create_entity(mk("A"), Actor::Cli, None, None)
2074 .unwrap();
2075 let b = engine
2076 .create_entity(mk("B"), Actor::Cli, None, None)
2077 .unwrap();
2078
2079 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
2080 anchors: Vec::new(),
2081 id,
2082 expected_hash: Some(hash),
2083 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
2084 append_sections: IndexMap::new(),
2085 patch_sections: IndexMap::new(),
2086 metadata: IndexMap::new(),
2087 metadata_unset: Vec::new(),
2088 declare_relations: Vec::new(),
2089 dry_run: false,
2090 relations_unset: Vec::new(),
2091 anchors_unset: Vec::new(),
2092 };
2093 let batch = || {
2094 vec![
2095 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
2096 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
2097 ]
2098 };
2099
2100 let rehearsed = engine
2101 .batch_update(batch(), Actor::Cli, None, true)
2102 .unwrap();
2103 assert!(rehearsed.applied, "{rehearsed:?}");
2104 assert_eq!(rehearsed.succeeded, 2);
2105 assert!(
2106 rehearsed.commit_sha.is_empty(),
2107 "marker form: empty commit_sha"
2108 );
2109 assert!(rehearsed.results.iter().all(|e| e.action == "updated"));
2110 let a_now = engine.get_entity(&a.id).unwrap();
2112 assert_eq!(
2113 a_now.sections.get("identity").map(String::as_str),
2114 Some("id")
2115 );
2116 assert_eq!(a_now.content_hash, a.content_hash);
2117
2118 let real = engine
2120 .batch_update(batch(), Actor::Cli, None, false)
2121 .unwrap();
2122 assert!(real.applied, "{real:?}");
2123 assert!(!real.commit_sha.is_empty());
2124 assert_eq!(
2125 engine
2126 .get_entity(&a.id)
2127 .unwrap()
2128 .sections
2129 .get("identity")
2130 .map(String::as_str),
2131 Some("A body"),
2132 );
2133 }
2134
2135 #[test]
2139 fn batch_update_dry_run_refuses_identically_to_real() {
2140 let tmp = TempDir::new().unwrap();
2141 let mem_dir = tmp.path().to_path_buf();
2142 let writer = FilesystemMemWriter::new(mem_dir.clone());
2143 let mut engine = Engine::from_mounts(vec![(
2144 folder_mount("specs", mem_dir),
2145 Box::new(writer) as Box<dyn MemBackend>,
2146 )])
2147 .unwrap();
2148 let created = engine
2149 .create_entity(
2150 CreateEntityArgs {
2151 anchors: Vec::new(),
2152 mem: "specs".to_string(),
2153 title: "Valid".to_string(),
2154 entity_type: "spec".to_string(),
2155 sections: IndexMap::from_iter([
2156 ("identity".to_string(), "x".to_string()),
2157 ("purpose".to_string(), "p".to_string()),
2158 ]),
2159 metadata: IndexMap::new(),
2160 relations: Vec::new(),
2161 dry_run: false,
2162 },
2163 Actor::Cli,
2164 None,
2165 None,
2166 )
2167 .unwrap();
2168 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2169 anchors: Vec::new(),
2170 id,
2171 expected_hash: hash,
2172 sections: IndexMap::from_iter([("identity".to_string(), "new".to_string())]),
2173 append_sections: IndexMap::new(),
2174 patch_sections: IndexMap::new(),
2175 metadata: IndexMap::new(),
2176 metadata_unset: Vec::new(),
2177 declare_relations: Vec::new(),
2178 dry_run: false,
2179 relations_unset: Vec::new(),
2180 anchors_unset: Vec::new(),
2181 };
2182 let batch = || {
2183 vec![
2184 (
2185 upd(created.id.clone(), Some("wrong-hash".to_string())),
2186 None,
2187 ),
2188 (upd(EntityId("specs--missing".to_string()), None), None),
2189 ]
2190 };
2191
2192 let rehearsed = engine
2193 .batch_update(batch(), Actor::Cli, None, true)
2194 .unwrap();
2195 let real = engine
2196 .batch_update(batch(), Actor::Cli, None, false)
2197 .unwrap();
2198 assert!(!rehearsed.applied && !real.applied);
2199 let envelope = |r: &crate::ops::BatchResult| {
2200 r.results
2201 .iter()
2202 .map(|e| {
2203 (
2204 e.id.to_string(),
2205 e.action.clone(),
2206 e.error.as_ref().map(|err| {
2207 (err.code.clone(), err.message.clone(), err.details.clone())
2208 }),
2209 )
2210 })
2211 .collect::<Vec<_>>()
2212 };
2213 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
2214 assert_eq!(
2216 engine
2217 .get_entity(&created.id)
2218 .unwrap()
2219 .sections
2220 .get("identity")
2221 .map(String::as_str),
2222 Some("x"),
2223 );
2224 }
2225
2226 #[test]
2230 fn batch_update_reports_every_failing_item() {
2231 let tmp = TempDir::new().unwrap();
2232 let mem_dir = tmp.path().to_path_buf();
2233 let writer = FilesystemMemWriter::new(mem_dir.clone());
2234 let mut engine = Engine::from_mounts(vec![(
2235 folder_mount("specs", mem_dir),
2236 Box::new(writer) as Box<dyn MemBackend>,
2237 )])
2238 .unwrap();
2239 let created = engine
2240 .create_entity(
2241 CreateEntityArgs {
2242 anchors: Vec::new(),
2243 mem: "specs".to_string(),
2244 title: "Seed".to_string(),
2245 entity_type: "spec".to_string(),
2246 sections: IndexMap::from_iter([
2247 ("identity".to_string(), "seed identity".to_string()),
2248 ("purpose".to_string(), "seed purpose".to_string()),
2249 ]),
2250 metadata: IndexMap::new(),
2251 relations: Vec::new(),
2252 dry_run: false,
2253 },
2254 Actor::Cli,
2255 None,
2256 None,
2257 )
2258 .unwrap();
2259
2260 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2261 anchors: Vec::new(),
2262 id,
2263 expected_hash: hash,
2264 sections: IndexMap::from_iter([("identity".to_string(), "new body".to_string())]),
2265 append_sections: IndexMap::new(),
2266 patch_sections: IndexMap::new(),
2267 metadata: IndexMap::new(),
2268 metadata_unset: Vec::new(),
2269 declare_relations: Vec::new(),
2270 dry_run: false,
2271 relations_unset: Vec::new(),
2272 anchors_unset: Vec::new(),
2273 };
2274 let result = engine
2275 .batch_update(
2276 vec![
2277 (upd(created.id.clone(), None), None),
2278 (upd(EntityId("specs--missing-one".to_string()), None), None),
2279 (upd(EntityId("specs--missing-two".to_string()), None), None),
2280 ],
2281 Actor::Cli,
2282 None,
2283 false,
2284 )
2285 .unwrap();
2286 assert!(!result.applied);
2287 assert_eq!(result.failed, 2, "{result:?}");
2288 assert_eq!(result.commit_sha, "");
2289 let codes: Vec<(usize, &str)> = result
2290 .results
2291 .iter()
2292 .enumerate()
2293 .filter(|(_, r)| r.action == "error")
2294 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
2295 .collect();
2296 assert_eq!(
2297 codes,
2298 vec![(1, "ENTITY_NOT_FOUND"), (2, "ENTITY_NOT_FOUND")],
2299 "BOTH failing items named, not just the first: {result:?}"
2300 );
2301 assert_eq!(result.results[0].action, "not_applied");
2302 assert_eq!(
2304 engine
2305 .get_entity(&created.id)
2306 .unwrap()
2307 .sections
2308 .get("identity")
2309 .map(String::as_str),
2310 Some("seed identity"),
2311 );
2312 }
2313
2314 #[test]
2315 fn batch_update_rolls_back_in_memory_store_auto_stub_on_refusal() {
2316 let tmp = TempDir::new().unwrap();
2324 let mem_dir = tmp.path().to_path_buf();
2325 let writer = FilesystemMemWriter::new(mem_dir.clone());
2326 let mut engine = Engine::from_mounts(vec![(
2327 folder_mount("specs", mem_dir.clone()),
2328 Box::new(writer) as Box<dyn MemBackend>,
2329 )])
2330 .unwrap();
2331 engine.set_workspace_root(mem_dir);
2332 let (actor, client) = cli_actor();
2333
2334 let a = engine
2335 .create_entity(
2336 empty_create_args("specs", "Anchor"),
2337 actor,
2338 Some(&client),
2339 None,
2340 )
2341 .unwrap();
2342
2343 let stub_target = EntityId::new("specs", "would-be-stub");
2344 let item1 = UpdateEntityArgs {
2345 anchors: Vec::new(),
2346 relations_unset: Vec::new(),
2347 anchors_unset: Vec::new(),
2348 id: a.id.clone(),
2349 expected_hash: Some(a.content_hash.clone()),
2350 sections: IndexMap::new(),
2351 append_sections: IndexMap::new(),
2352 patch_sections: IndexMap::new(),
2353 metadata: IndexMap::new(),
2354 metadata_unset: Vec::new(),
2355 declare_relations: vec![crate::ops::RelateArg {
2356 rel_type: "USES".to_string(),
2357 to: stub_target.clone(),
2358 description: None,
2359 }],
2360 dry_run: false,
2361 };
2362 let item2 = UpdateEntityArgs {
2363 anchors: Vec::new(),
2364 id: EntityId::new("specs", "nonexistent"),
2365 expected_hash: None,
2366 sections: IndexMap::from_iter([("identity".to_string(), "x".to_string())]),
2367 append_sections: IndexMap::new(),
2368 patch_sections: IndexMap::new(),
2369 metadata: IndexMap::new(),
2370 metadata_unset: Vec::new(),
2371 declare_relations: Vec::new(),
2372 dry_run: false,
2373 relations_unset: Vec::new(),
2374 anchors_unset: Vec::new(),
2375 };
2376
2377 assert!(engine.get_entity(&stub_target).is_none());
2379
2380 let result = engine
2381 .batch_update(
2382 vec![(item1, None), (item2, None)],
2383 actor,
2384 Some(&client),
2385 false,
2386 )
2387 .unwrap();
2388 assert!(!result.applied, "missing item 2 must refuse the batch");
2389
2390 assert!(
2393 engine.get_entity(&stub_target).is_none(),
2394 "refused batch must roll the in-memory auto-stub back out of the store",
2395 );
2396 let anchor = engine.get_entity(&a.id).unwrap();
2398 assert!(
2399 !anchor.relationships.iter().any(|r| r.target == stub_target),
2400 "refused batch must not leave the declared relation on the anchor",
2401 );
2402 }
2403
2404 #[test]
2405 fn update_entity_replaces_a_section_and_logs_provenance() {
2406 let tmp = TempDir::new().unwrap();
2407 let (mut engine, seeded) = engine_with_seed(&tmp, "Updatable");
2408 let (actor, client) = cli_actor();
2409
2410 let mut sections = IndexMap::new();
2411 sections.insert("identity".to_string(), "Updated body.".to_string());
2412
2413 let outcome = engine
2414 .update_entity(
2415 UpdateEntityArgs {
2416 anchors: Vec::new(),
2417 id: seeded.id.clone(),
2418 expected_hash: Some(seeded.content_hash.clone()),
2419 sections,
2420 append_sections: IndexMap::new(),
2421 patch_sections: IndexMap::new(),
2422 metadata: IndexMap::new(),
2423 metadata_unset: Vec::new(),
2424 declare_relations: Vec::new(),
2425 dry_run: false,
2426 relations_unset: Vec::new(),
2427 anchors_unset: Vec::new(),
2428 },
2429 actor,
2430 Some(&client),
2431 Some("section update"),
2432 )
2433 .unwrap();
2434
2435 assert_eq!(
2436 outcome.modified_sections.replaced,
2437 vec!["identity".to_string()]
2438 );
2439 assert_ne!(
2440 outcome.content_hash, seeded.content_hash,
2441 "hash must change"
2442 );
2443 let entity = engine.get_entity(&seeded.id).unwrap();
2445 assert!(
2446 entity
2447 .sections
2448 .get("identity")
2449 .unwrap()
2450 .contains("Updated body.")
2451 );
2452 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
2454 assert!(log.contains("\"kind\":\"update\""));
2455 assert!(log.contains("\"note\":\"section update\""));
2456 }
2457
2458 #[test]
2459 fn update_entity_rejects_hash_mismatch() {
2460 let tmp = TempDir::new().unwrap();
2461 let (mut engine, seeded) = engine_with_seed(&tmp, "Hash Guarded");
2462 let (actor, client) = cli_actor();
2463 let err = engine
2464 .update_entity(
2465 UpdateEntityArgs {
2466 anchors: Vec::new(),
2467 id: seeded.id.clone(),
2468 expected_hash: Some("wrong-hash".to_string()),
2469 sections: IndexMap::new(),
2470 append_sections: IndexMap::new(),
2471 patch_sections: IndexMap::new(),
2472 metadata: IndexMap::new(),
2473 metadata_unset: Vec::new(),
2474 declare_relations: Vec::new(),
2475 dry_run: false,
2476 relations_unset: Vec::new(),
2477 anchors_unset: Vec::new(),
2478 },
2479 actor,
2480 Some(&client),
2481 None,
2482 )
2483 .unwrap_err();
2484 match err {
2485 EngineError::HashMismatch {
2486 id,
2487 current,
2488 is_stub,
2489 } => {
2490 assert_eq!(id, seeded.id.to_string());
2491 assert_eq!(current, seeded.content_hash);
2492 assert!(!is_stub, "real entity must not flag as stub");
2493 }
2494 other => panic!("expected HashMismatch, got {other:?}"),
2495 }
2496 }
2497
2498 #[test]
2499 fn update_entity_rejects_unknown_id() {
2500 let tmp = TempDir::new().unwrap();
2501 let (mut engine, _) = engine_with_seed(&tmp, "Anchor");
2502 let (actor, client) = cli_actor();
2503 let err = engine
2504 .update_entity(
2505 UpdateEntityArgs {
2506 anchors: Vec::new(),
2507 id: crate::EntityId::new("specs", "ghost"),
2508 expected_hash: None,
2509 sections: IndexMap::new(),
2510 append_sections: IndexMap::new(),
2511 patch_sections: IndexMap::new(),
2512 metadata: IndexMap::new(),
2513 metadata_unset: Vec::new(),
2514 declare_relations: Vec::new(),
2515 dry_run: false,
2516 relations_unset: Vec::new(),
2517 anchors_unset: Vec::new(),
2518 },
2519 actor,
2520 Some(&client),
2521 None,
2522 )
2523 .unwrap_err();
2524 assert!(matches!(err, EngineError::NotFound { .. }));
2525 }
2526
2527 #[test]
2528 fn update_entity_rejects_read_only_mount() {
2529 let tmp = TempDir::new().unwrap();
2530 let archive_path = build_archive(
2531 tmp.path(),
2532 "ext",
2533 &[(
2534 "a.md",
2535 b"---\ntype: spec\n---\n# A\n\n## Identity\n\nbody.\n",
2536 )],
2537 );
2538 let mut engine = Engine::from_mounts(vec![(
2539 archive_mount("external", archive_path.clone()),
2540 Box::new(ArchiveBackend::new(archive_path)),
2541 )])
2542 .unwrap();
2543 let (actor, client) = cli_actor();
2544 let id = crate::EntityId::new("external", "a");
2545 let err = engine
2546 .update_entity(
2547 UpdateEntityArgs {
2548 anchors: Vec::new(),
2549 id,
2550 expected_hash: None,
2551 sections: IndexMap::new(),
2552 append_sections: IndexMap::new(),
2553 patch_sections: IndexMap::new(),
2554 metadata: IndexMap::new(),
2555 metadata_unset: Vec::new(),
2556 declare_relations: Vec::new(),
2557 dry_run: false,
2558 relations_unset: Vec::new(),
2559 anchors_unset: Vec::new(),
2560 },
2561 actor,
2562 Some(&client),
2563 None,
2564 )
2565 .unwrap_err();
2566 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "external"));
2567 }
2568
2569 #[test]
2570 fn update_entity_patches_section_with_find_and_replace() {
2571 let tmp = TempDir::new().unwrap();
2572 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Subject");
2573 let (actor, client) = cli_actor();
2574
2575 let mut replace = IndexMap::new();
2578 replace.insert("identity".to_string(), "hello world hello".to_string());
2579 let replaced = engine
2580 .update_entity(
2581 UpdateEntityArgs {
2582 anchors: Vec::new(),
2583 id: seeded.id.clone(),
2584 expected_hash: Some(seeded.content_hash.clone()),
2585 sections: replace,
2586 append_sections: IndexMap::new(),
2587 patch_sections: IndexMap::new(),
2588 metadata: IndexMap::new(),
2589 metadata_unset: Vec::new(),
2590 declare_relations: Vec::new(),
2591 dry_run: false,
2592 relations_unset: Vec::new(),
2593 anchors_unset: Vec::new(),
2594 },
2595 actor,
2596 Some(&client),
2597 None,
2598 )
2599 .unwrap();
2600
2601 let mut patches = IndexMap::new();
2603 patches.insert(
2604 "identity".to_string(),
2605 crate::ops::PatchArg {
2606 old: "hello".to_string(),
2607 new: "HI".to_string(),
2608 all: false,
2609 },
2610 );
2611 let outcome = engine
2612 .update_entity(
2613 UpdateEntityArgs {
2614 anchors: Vec::new(),
2615 id: seeded.id.clone(),
2616 expected_hash: Some(replaced.content_hash.clone()),
2617 sections: IndexMap::new(),
2618 append_sections: IndexMap::new(),
2619 patch_sections: patches,
2620 metadata: IndexMap::new(),
2621 metadata_unset: Vec::new(),
2622 declare_relations: Vec::new(),
2623 dry_run: false,
2624 relations_unset: Vec::new(),
2625 anchors_unset: Vec::new(),
2626 },
2627 actor,
2628 Some(&client),
2629 None,
2630 )
2631 .unwrap();
2632 assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
2633 let body = engine
2634 .get_entity(&seeded.id)
2635 .unwrap()
2636 .sections
2637 .get("identity")
2638 .unwrap()
2639 .clone();
2640 assert!(body.contains("HI world hello"), "first-only: {body:?}");
2641 }
2642
2643 #[test]
2644 fn update_entity_patch_rejects_missing_old_substring() {
2645 let tmp = TempDir::new().unwrap();
2646 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Miss");
2647 let (actor, client) = cli_actor();
2648 let mut patches = IndexMap::new();
2649 patches.insert(
2650 "identity".to_string(),
2651 crate::ops::PatchArg {
2652 old: "this-substring-does-not-exist".to_string(),
2653 new: "nope".to_string(),
2654 all: false,
2655 },
2656 );
2657 let err = engine
2658 .update_entity(
2659 UpdateEntityArgs {
2660 anchors: Vec::new(),
2661 id: seeded.id.clone(),
2662 expected_hash: Some(seeded.content_hash.clone()),
2663 sections: IndexMap::new(),
2664 append_sections: IndexMap::new(),
2665 patch_sections: patches,
2666 metadata: IndexMap::new(),
2667 metadata_unset: Vec::new(),
2668 declare_relations: Vec::new(),
2669 dry_run: false,
2670 relations_unset: Vec::new(),
2671 anchors_unset: Vec::new(),
2672 },
2673 actor,
2674 Some(&client),
2675 None,
2676 )
2677 .unwrap_err();
2678 match err {
2679 EngineError::PatchOldNotFound { section, .. } => {
2680 assert_eq!(section, "identity");
2681 }
2682 other => panic!("expected PatchOldNotFound, got {other:?}"),
2683 }
2684 }
2685
2686 #[test]
2687 fn update_entity_appends_to_existing_section_with_newline_separator() {
2688 let tmp = TempDir::new().unwrap();
2689 let (mut engine, seeded) = engine_with_seed(&tmp, "Append Subject");
2690 let (actor, client) = cli_actor();
2691
2692 let mut appends = IndexMap::new();
2693 appends.insert("identity".to_string(), "appended tail.".to_string());
2694
2695 let outcome = engine
2696 .update_entity(
2697 UpdateEntityArgs {
2698 anchors: Vec::new(),
2699 id: seeded.id.clone(),
2700 expected_hash: Some(seeded.content_hash.clone()),
2701 sections: IndexMap::new(),
2702 append_sections: appends,
2703 patch_sections: IndexMap::new(),
2704 metadata: IndexMap::new(),
2705 metadata_unset: Vec::new(),
2706 declare_relations: Vec::new(),
2707 dry_run: false,
2708 relations_unset: Vec::new(),
2709 anchors_unset: Vec::new(),
2710 },
2711 actor,
2712 Some(&client),
2713 None,
2714 )
2715 .unwrap();
2716
2717 assert_eq!(outcome.modified_sections.appended, vec!["identity"]);
2720 assert!(outcome.modified_sections.replaced.is_empty());
2721
2722 let updated = engine.get_entity(&seeded.id).unwrap();
2724 let body = updated.sections.get("identity").expect("identity section");
2725 assert!(
2726 body.contains("appended tail."),
2727 "appended body missing: {body:?}"
2728 );
2729 }
2730
2731 #[test]
2738 fn update_entity_against_stub_surfaces_typed_stub_not_updatable() {
2739 let tmp = TempDir::new().unwrap();
2740 let (mut engine, source) = engine_with_seed(&tmp, "Source");
2741 let (actor, client) = cli_actor();
2742 let stub_id = crate::EntityId::new("specs", "stub-update-target");
2745 engine
2746 .relate_entity(
2747 RelateEntityArgs {
2748 source: source.id.clone(),
2749 expected_hash: Some(source.content_hash.clone()),
2750 rel_type: "USES".to_string(),
2751 target: stub_id.clone(),
2752 remove: false,
2753 description: None,
2754 dry_run: false,
2755 },
2756 actor,
2757 Some(&client),
2758 None,
2759 )
2760 .unwrap();
2761
2762 let err = engine
2763 .update_entity(
2764 UpdateEntityArgs {
2765 anchors: Vec::new(),
2766 id: stub_id.clone(),
2767 expected_hash: Some(String::new()),
2768 sections: IndexMap::from_iter([("identity".to_string(), "body".to_string())]),
2769 append_sections: IndexMap::new(),
2770 patch_sections: IndexMap::new(),
2771 metadata: IndexMap::new(),
2772 metadata_unset: Vec::new(),
2773 declare_relations: Vec::new(),
2774 dry_run: false,
2775 relations_unset: Vec::new(),
2776 anchors_unset: Vec::new(),
2777 },
2778 actor,
2779 Some(&client),
2780 None,
2781 )
2782 .unwrap_err();
2783 match err {
2784 EngineError::StubNotUpdatable { id } => assert_eq!(id, stub_id.to_string()),
2785 other => panic!("expected StubNotUpdatable, got {other:?}"),
2786 }
2787 }
2788
2789 #[test]
2790 fn update_entity_rejects_conflicting_section_modes() {
2791 let tmp = TempDir::new().unwrap();
2792 let (mut engine, seeded) = engine_with_seed(&tmp, "Conflict");
2793 let (actor, client) = cli_actor();
2794
2795 let mut sections = IndexMap::new();
2796 sections.insert("identity".to_string(), "replace".to_string());
2797 let mut appends = IndexMap::new();
2798 appends.insert("identity".to_string(), "append".to_string());
2799
2800 let err = engine
2801 .update_entity(
2802 UpdateEntityArgs {
2803 anchors: Vec::new(),
2804 id: seeded.id.clone(),
2805 expected_hash: Some(seeded.content_hash.clone()),
2806 sections,
2807 append_sections: appends,
2808 patch_sections: IndexMap::new(),
2809 metadata: IndexMap::new(),
2810 metadata_unset: Vec::new(),
2811 declare_relations: Vec::new(),
2812 dry_run: false,
2813 relations_unset: Vec::new(),
2814 anchors_unset: Vec::new(),
2815 },
2816 actor,
2817 Some(&client),
2818 None,
2819 )
2820 .unwrap_err();
2821
2822 match err {
2823 EngineError::ConflictingSectionModes { section, modes } => {
2824 assert_eq!(section, "identity");
2825 assert_eq!(modes, vec!["sections", "append_sections"]);
2826 }
2827 other => panic!("expected ConflictingSectionModes, got {other:?}"),
2828 }
2829 }
2830
2831 #[test]
2832 fn update_entity_rejects_overlapping_metadata_and_metadata_unset_keys() {
2833 let tmp = TempDir::new().unwrap();
2838 let (mut engine, seeded) = engine_with_seed(&tmp, "Overlap Subject");
2839 let (actor, client) = cli_actor();
2840
2841 let mut metadata = IndexMap::new();
2842 metadata.insert("tags".to_string(), "foo".to_string());
2846
2847 let err = engine
2848 .update_entity(
2849 UpdateEntityArgs {
2850 anchors: Vec::new(),
2851 id: seeded.id.clone(),
2852 expected_hash: Some(seeded.content_hash.clone()),
2853 sections: IndexMap::new(),
2854 append_sections: IndexMap::new(),
2855 patch_sections: IndexMap::new(),
2856 metadata,
2857 metadata_unset: vec!["tags".to_string()],
2858 declare_relations: Vec::new(),
2859 dry_run: false,
2860 relations_unset: Vec::new(),
2861 anchors_unset: Vec::new(),
2862 },
2863 actor,
2864 Some(&client),
2865 None,
2866 )
2867 .unwrap_err();
2868 match err {
2869 EngineError::SetAndUnsetConflict { keys } => {
2870 assert_eq!(keys, vec!["tags".to_string()]);
2871 }
2872 other => panic!("expected SetAndUnsetConflict, got {other:?}"),
2873 }
2874 }
2875
2876 #[test]
2877 fn update_entity_pointer_schema_auto_synthesises_references_from_body_link() {
2878 use crate::EntityId;
2886 use crate::engine::UpdateEntityArgs;
2887 use indexmap::IndexMap;
2888 use tempfile::TempDir;
2889
2890 let tmp = TempDir::new().unwrap();
2891 let mem_dir = tmp.path().to_path_buf();
2892 let writer = FilesystemMemWriter::new(mem_dir.clone());
2893 let mut engine = Engine::from_mounts(vec![(
2894 folder_mount("specs", mem_dir.clone()),
2895 Box::new(writer) as Box<dyn MemBackend>,
2896 )])
2897 .unwrap();
2898 engine.set_workspace_root(mem_dir.clone());
2899 let (actor, client) = cli_actor();
2900
2901 let target = engine
2902 .create_entity(
2903 empty_create_args("specs", "Target"),
2904 actor,
2905 Some(&client),
2906 None,
2907 )
2908 .unwrap();
2909 let source = engine
2910 .create_entity(
2911 empty_create_args("specs", "Source"),
2912 actor,
2913 Some(&client),
2914 None,
2915 )
2916 .unwrap();
2917
2918 let mut sections: IndexMap<String, String> = IndexMap::new();
2919 sections.insert(
2920 "purpose".to_string(),
2921 "see [[target]] for context".to_string(),
2922 );
2923 let outcome = engine
2924 .update_entity(
2925 UpdateEntityArgs {
2926 anchors: Vec::new(),
2927 id: source.id.clone(),
2928 expected_hash: Some(source.content_hash.clone()),
2929 sections,
2930 append_sections: IndexMap::new(),
2931 patch_sections: IndexMap::new(),
2932 metadata: IndexMap::new(),
2933 metadata_unset: Vec::new(),
2934 declare_relations: Vec::new(),
2935 dry_run: false,
2936 relations_unset: Vec::new(),
2937 anchors_unset: Vec::new(),
2938 },
2939 actor,
2940 Some(&client),
2941 None,
2942 )
2943 .expect("auto-synthesis must satisfy the alias-existence invariant");
2944 assert!(
2946 outcome
2947 .modified_sections
2948 .replaced
2949 .iter()
2950 .any(|s| s == "purpose"),
2951 );
2952 let in_mem = engine.get_entity(&source.id).unwrap();
2953 assert_eq!(
2954 in_mem
2955 .sections
2956 .get("purpose")
2957 .map(String::as_str)
2958 .unwrap_or(""),
2959 "see [[target]] for context",
2960 );
2961 assert!(
2963 in_mem
2964 .relationships
2965 .iter()
2966 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2967 "synthesis must emit REFERENCES → target; relationships: {:?}",
2968 in_mem.relationships,
2969 );
2970 let _ = EntityId::new("specs", "x");
2972 }
2973
2974 #[test]
2975 fn update_entity_declare_relations_passes_strict_validator_in_one_call() {
2976 use crate::engine::UpdateEntityArgs;
2983 use crate::ops::RelateArg;
2984 use indexmap::IndexMap;
2985 use tempfile::TempDir;
2986
2987 let tmp = TempDir::new().unwrap();
2988 let mem_dir = tmp.path().to_path_buf();
2989 let writer = FilesystemMemWriter::new(mem_dir.clone());
2990 let mut engine = Engine::from_mounts(vec![(
2991 folder_mount("specs", mem_dir.clone()),
2992 Box::new(writer) as Box<dyn MemBackend>,
2993 )])
2994 .unwrap();
2995 engine.set_workspace_root(mem_dir.clone());
2996 let (actor, client) = cli_actor();
2997
2998 let target = engine
2999 .create_entity(
3000 empty_create_args("specs", "Target"),
3001 actor,
3002 Some(&client),
3003 None,
3004 )
3005 .unwrap();
3006 let source = engine
3007 .create_entity(
3008 empty_create_args("specs", "Source"),
3009 actor,
3010 Some(&client),
3011 None,
3012 )
3013 .unwrap();
3014
3015 let mut sections: IndexMap<String, String> = IndexMap::new();
3023 sections.insert(
3024 "purpose".to_string(),
3025 "see [[target]] for context".to_string(),
3026 );
3027 let outcome = engine
3028 .update_entity(
3029 UpdateEntityArgs {
3030 anchors: Vec::new(),
3031 relations_unset: Vec::new(),
3032 anchors_unset: Vec::new(),
3033 id: source.id.clone(),
3034 expected_hash: Some(source.content_hash.clone()),
3035 sections,
3036 append_sections: IndexMap::new(),
3037 patch_sections: IndexMap::new(),
3038 metadata: IndexMap::new(),
3039 metadata_unset: Vec::new(),
3040 dry_run: false,
3041 declare_relations: vec![RelateArg {
3042 rel_type: "USES".to_string(),
3043 to: target.id.clone(),
3044 description: None,
3045 }],
3046 },
3047 actor,
3048 Some(&client),
3049 None,
3050 )
3051 .expect("declare_relations + body update must succeed in one call");
3052
3053 assert_eq!(outcome.relations_declared.len(), 1);
3054 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
3055 assert_eq!(outcome.relations_declared[0].target, target.id);
3056 assert!(
3057 !outcome.relations_declared[0].target_was_stubbed,
3058 "target was already present in store; target_was_stubbed must be false"
3059 );
3060
3061 let in_mem = engine.get_entity(&source.id).unwrap();
3062 assert!(
3063 in_mem.relationships.iter().any(|r| r.target == target.id),
3064 "declared relation must land in entity.relationships; got {:?}",
3065 in_mem.relationships
3066 );
3067 }
3068
3069 #[test]
3070 fn update_entity_declare_relations_auto_stubs_absent_target() {
3071 use crate::EntityId;
3075 use crate::engine::UpdateEntityArgs;
3076 use crate::ops::RelateArg;
3077 use indexmap::IndexMap;
3078
3079 let tmp = TempDir::new().unwrap();
3080 let (mut engine, source) = engine_with_seed(&tmp, "Source");
3081 let (actor, client) = cli_actor();
3082 let absent_target = EntityId::new("specs", "not-yet-existing");
3083 assert!(!engine.store().contains(&absent_target));
3084
3085 let outcome = engine
3086 .update_entity(
3087 UpdateEntityArgs {
3088 anchors: Vec::new(),
3089 relations_unset: Vec::new(),
3090 anchors_unset: Vec::new(),
3091 id: source.id.clone(),
3092 expected_hash: Some(source.content_hash.clone()),
3093 sections: IndexMap::new(),
3094 append_sections: IndexMap::new(),
3095 patch_sections: IndexMap::new(),
3096 metadata: IndexMap::new(),
3097 metadata_unset: Vec::new(),
3098 dry_run: false,
3099 declare_relations: vec![RelateArg {
3100 rel_type: "USES".to_string(),
3101 to: absent_target.clone(),
3102 description: None,
3103 }],
3104 },
3105 actor,
3106 Some(&client),
3107 None,
3108 )
3109 .unwrap();
3110
3111 assert_eq!(outcome.relations_declared.len(), 1);
3112 assert!(
3113 outcome.relations_declared[0].target_was_stubbed,
3114 "absent target must be auto-stubbed; got target_was_stubbed=false"
3115 );
3116 assert!(engine.store().contains(&absent_target));
3118 let stub = engine.get_entity(&absent_target).unwrap();
3119 assert!(stub.stub);
3120 }
3121
3122 #[test]
3123 fn update_entity_alias_synthesis_runs_unconditionally_for_pointer_schemas() {
3124 use crate::engine::UpdateEntityArgs;
3130 use indexmap::IndexMap;
3131 use tempfile::TempDir;
3132
3133 let tmp = TempDir::new().unwrap();
3134 let mem_dir = tmp.path().to_path_buf();
3135 let writer = FilesystemMemWriter::new(mem_dir.clone());
3136 let mut engine = Engine::from_mounts(vec![(
3137 folder_mount("specs", mem_dir.clone()),
3138 Box::new(writer) as Box<dyn MemBackend>,
3139 )])
3140 .unwrap();
3141 engine.set_workspace_root(mem_dir.clone());
3142 let (actor, client) = cli_actor();
3143 let target = engine
3144 .create_entity(
3145 empty_create_args("specs", "Target"),
3146 actor,
3147 Some(&client),
3148 None,
3149 )
3150 .unwrap();
3151 let source = engine
3152 .create_entity(
3153 empty_create_args("specs", "Source"),
3154 actor,
3155 Some(&client),
3156 None,
3157 )
3158 .unwrap();
3159
3160 let mut sections: IndexMap<String, String> = IndexMap::new();
3161 sections.insert(
3162 "purpose".to_string(),
3163 "see [[target]] for context".to_string(),
3164 );
3165 engine
3166 .update_entity(
3167 UpdateEntityArgs {
3168 anchors: Vec::new(),
3169 id: source.id.clone(),
3170 expected_hash: Some(source.content_hash.clone()),
3171 sections,
3172 append_sections: IndexMap::new(),
3173 patch_sections: IndexMap::new(),
3174 metadata: IndexMap::new(),
3175 metadata_unset: Vec::new(),
3176 declare_relations: Vec::new(),
3177 dry_run: false,
3178 relations_unset: Vec::new(),
3179 anchors_unset: Vec::new(),
3180 },
3181 actor,
3182 Some(&client),
3183 None,
3184 )
3185 .expect("synthesis must back the wiki-link and let the body land");
3186 let in_mem = engine.get_entity(&source.id).unwrap();
3187 assert!(
3188 in_mem
3189 .relationships
3190 .iter()
3191 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3192 "synthesis must emit REFERENCES → target; relationships: {:?}",
3193 in_mem.relationships,
3194 );
3195 }
3196
3197 #[test]
3198 fn update_entity_dry_run_returns_prospective_hash_without_writing() {
3199 let tmp = TempDir::new().unwrap();
3200 let (mut engine, seeded) = engine_with_seed(&tmp, "Preview Subject");
3201 let (actor, client) = cli_actor();
3202 let original_hash = seeded.content_hash.clone();
3203
3204 let mut sections = IndexMap::new();
3205 sections.insert("identity".to_string(), "preview body".to_string());
3206
3207 let outcome = engine
3208 .update_entity(
3209 UpdateEntityArgs {
3210 anchors: Vec::new(),
3211 id: seeded.id.clone(),
3212 expected_hash: Some("wrong-hash".to_string()),
3215 sections,
3216 append_sections: IndexMap::new(),
3217 patch_sections: IndexMap::new(),
3218 metadata: IndexMap::new(),
3219 metadata_unset: Vec::new(),
3220 declare_relations: Vec::new(),
3221 dry_run: true,
3222 relations_unset: Vec::new(),
3223 anchors_unset: Vec::new(),
3224 },
3225 actor,
3226 Some(&client),
3227 None,
3228 )
3229 .unwrap();
3230
3231 assert_eq!(outcome.content_hash, original_hash);
3234 let prospective = outcome
3235 .prospective_hash
3236 .expect("prospective_hash populated on dry_run");
3237 assert_ne!(prospective, original_hash);
3238 assert!(outcome.commit_sha.is_empty());
3239 let store_entity = engine.get_entity(&seeded.id).unwrap();
3241 assert_eq!(store_entity.content_hash, original_hash);
3242 }
3243
3244 #[test]
3263 fn references_edges_round_trip_across_full_crud_cycle() {
3264 let tmp = TempDir::new().unwrap();
3265 let mem_dir = tmp.path().to_path_buf();
3266 let writer = FilesystemMemWriter::new(mem_dir.clone());
3267 let mut engine = Engine::from_mounts(vec![(
3268 folder_mount("specs", mem_dir),
3269 Box::new(writer) as Box<dyn MemBackend>,
3270 )])
3271 .unwrap();
3272 let (actor, client) = cli_actor();
3273
3274 let foo = engine
3278 .create_entity(
3279 empty_create_args("specs", "Foo"),
3280 actor,
3281 Some(&client),
3282 None,
3283 )
3284 .unwrap();
3285 let bar = engine
3286 .create_entity(
3287 empty_create_args("specs", "Bar"),
3288 actor,
3289 Some(&client),
3290 None,
3291 )
3292 .unwrap();
3293
3294 let count_references = |engine: &Engine| -> usize {
3295 engine
3296 .store()
3297 .all_ids()
3298 .flat_map(|id| engine.store().outgoing(id))
3299 .filter(|e| e.rel_type == "REFERENCES")
3300 .count()
3301 };
3302
3303 let baseline_edges = engine.store().edge_count();
3304 let baseline_refs = count_references(&engine);
3305
3306 let mut sections = IndexMap::new();
3312 sections.insert(
3313 "identity".to_string(),
3314 "See [[foo]] and [[bar]] inline.".to_string(),
3315 );
3316 sections.insert("purpose".to_string(), "probe purpose".to_string());
3317 let probe = engine
3318 .create_entity(
3319 CreateEntityArgs {
3320 anchors: Vec::new(),
3321 mem: "specs".to_string(),
3322 title: "Probe".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_eq!(count_references(&engine), baseline_refs + 2);
3335
3336 let relate1 = engine
3341 .relate_entity(
3342 RelateEntityArgs {
3343 source: probe.id.clone(),
3344 expected_hash: Some(probe.content_hash.clone()),
3345 rel_type: "INFORMED_BY".to_string(),
3346 target: foo.id.clone(),
3347 remove: false,
3348 description: None,
3349 dry_run: false,
3350 },
3351 actor,
3352 Some(&client),
3353 None,
3354 )
3355 .unwrap();
3356 assert_eq!(
3357 count_references(&engine),
3358 baseline_refs + 2,
3359 "set-membership aliasing — adding INFORMED_BY does not \
3360 absorb the REFERENCES relation"
3361 );
3362
3363 let mut sections = IndexMap::new();
3367 sections.insert("identity".to_string(), "See [[foo]] inline.".to_string());
3368 let updated = engine
3369 .update_entity(
3370 UpdateEntityArgs {
3371 anchors: Vec::new(),
3372 id: probe.id.clone(),
3373 expected_hash: Some(relate1.content_hash.clone()),
3374 sections,
3375 append_sections: IndexMap::new(),
3376 patch_sections: IndexMap::new(),
3377 metadata: IndexMap::new(),
3378 metadata_unset: Vec::new(),
3379 declare_relations: Vec::new(),
3380 dry_run: false,
3381 relations_unset: Vec::new(),
3382 anchors_unset: Vec::new(),
3383 },
3384 actor,
3385 Some(&client),
3386 None,
3387 )
3388 .unwrap();
3389 assert_eq!(
3390 count_references(&engine),
3391 baseline_refs + 1,
3392 "REFERENCES → bar must be auto-GC'd when its body link drops"
3393 );
3394
3395 let renamed = engine
3397 .rename_entity(
3398 crate::engine::RenameEntityArgs {
3399 id: probe.id.clone(),
3400 expected_hash: Some(updated.content_hash.clone()),
3401 new_title: "Probe Renamed".to_string(),
3402 },
3403 actor,
3404 Some(&client),
3405 None,
3406 )
3407 .unwrap();
3408 assert_eq!(count_references(&engine), baseline_refs + 1);
3409
3410 engine
3413 .delete_entity(
3414 crate::engine::DeleteEntityArgs {
3415 id: renamed.new_id.clone(),
3416 expected_hash: Some(renamed.content_hash.clone()),
3417 },
3418 actor,
3419 Some(&client),
3420 None,
3421 )
3422 .unwrap();
3423
3424 assert_eq!(
3426 engine.store().edge_count(),
3427 baseline_edges,
3428 "total edges must round-trip to baseline"
3429 );
3430 assert_eq!(
3431 count_references(&engine),
3432 baseline_refs,
3433 "REFERENCES counter must round-trip to baseline"
3434 );
3435
3436 engine.reload_one_mem("specs").unwrap();
3440 assert_eq!(
3441 engine.store().edge_count(),
3442 baseline_edges,
3443 "total edges must match disk after reload"
3444 );
3445 assert_eq!(
3446 count_references(&engine),
3447 baseline_refs,
3448 "REFERENCES must match disk after reload"
3449 );
3450 assert!(engine.store().contains(&foo.id));
3452 assert!(engine.store().contains(&bar.id));
3453 }
3454
3455 #[test]
3456 fn update_entity_returns_commit_sha_title_modified_date_warnings_shape() {
3457 let tmp = TempDir::new().unwrap();
3458 let (mut engine, seeded) = engine_with_seed(&tmp, "Subject");
3459 let (actor, client) = cli_actor();
3460
3461 let mut sections = IndexMap::new();
3462 sections.insert("identity".to_string(), "edited body".to_string());
3463
3464 let outcome = engine
3465 .update_entity(
3466 UpdateEntityArgs {
3467 anchors: Vec::new(),
3468 id: seeded.id.clone(),
3469 expected_hash: Some(seeded.content_hash.clone()),
3470 sections,
3471 append_sections: IndexMap::new(),
3472 patch_sections: IndexMap::new(),
3473 metadata: IndexMap::new(),
3474 metadata_unset: Vec::new(),
3475 declare_relations: Vec::new(),
3476 dry_run: false,
3477 relations_unset: Vec::new(),
3478 anchors_unset: Vec::new(),
3479 },
3480 actor,
3481 Some(&client),
3482 None,
3483 )
3484 .unwrap();
3485
3486 assert!(
3488 !outcome.commit_sha.is_empty(),
3489 "commit_sha must be populated on a real update"
3490 );
3491 assert_eq!(outcome.title, "Subject");
3493 assert!(
3498 !outcome.modified_date.is_empty(),
3499 "modified_date must be auto-stamped on update for the default spec schema",
3500 );
3501 assert!(outcome.warnings.is_empty());
3505 assert_eq!(
3507 outcome.modified_sections.replaced,
3508 vec!["identity".to_string()]
3509 );
3510 }
3511
3512 #[test]
3521 fn update_entity_noop_resetting_section_to_current_value_preserves_last_modified() {
3522 let tmp = TempDir::new().unwrap();
3523 let (mut engine, seeded) = engine_with_seed(&tmp, "Section Resetter");
3524 let (actor, client) = cli_actor();
3525
3526 let pre_last_modified = engine
3529 .get_entity(&seeded.id)
3530 .and_then(|e| e.metadata.get("last_modified"))
3531 .map(|v| v.to_frontmatter_string())
3532 .expect("seeded entity has last_modified");
3533
3534 let mut sections = IndexMap::new();
3538 sections.insert("identity".to_string(), "fixture identity body".to_string());
3539 let outcome = engine
3540 .update_entity(
3541 UpdateEntityArgs {
3542 anchors: Vec::new(),
3543 id: seeded.id.clone(),
3544 expected_hash: Some(seeded.content_hash.clone()),
3545 sections,
3546 append_sections: IndexMap::new(),
3547 patch_sections: IndexMap::new(),
3548 metadata: IndexMap::new(),
3549 metadata_unset: Vec::new(),
3550 declare_relations: Vec::new(),
3551 dry_run: false,
3552 relations_unset: Vec::new(),
3553 anchors_unset: Vec::new(),
3554 },
3555 actor,
3556 Some(&client),
3557 None,
3558 )
3559 .unwrap();
3560
3561 assert_eq!(outcome.commit_sha, "", "no-op must not commit");
3562 assert_eq!(
3563 outcome.content_hash, seeded.content_hash,
3564 "no-op must not advance content_hash",
3565 );
3566 assert!(
3567 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3568 "UPDATE_NOOP must fire on bytes-identical re-set",
3569 );
3570 assert_eq!(
3571 outcome.modified_date, pre_last_modified,
3572 "no-op must preserve last_modified at the pre-call value",
3573 );
3574 assert!(
3579 outcome.modified_sections.replaced.is_empty()
3580 && outcome.modified_sections.appended.is_empty()
3581 && outcome.modified_sections.patched.is_empty(),
3582 "no-op must report an empty section delta, got {:?}",
3583 outcome.modified_sections,
3584 );
3585
3586 let post_last_modified = engine
3590 .get_entity(&seeded.id)
3591 .and_then(|e| e.metadata.get("last_modified"))
3592 .map(|v| v.to_frontmatter_string())
3593 .expect("entity still in store");
3594 assert_eq!(post_last_modified, pre_last_modified);
3595 }
3596
3597 #[test]
3609 fn update_entity_empty_payload_refuses_with_typed_code() {
3610 let tmp = TempDir::new().unwrap();
3611 let (mut engine, seeded) = engine_with_seed(&tmp, "Empty Payload");
3612 let (actor, client) = cli_actor();
3613
3614 let err = engine
3615 .update_entity(
3616 UpdateEntityArgs {
3617 anchors: Vec::new(),
3618 id: seeded.id.clone(),
3619 expected_hash: Some(seeded.content_hash.clone()),
3620 sections: IndexMap::new(),
3621 append_sections: IndexMap::new(),
3622 patch_sections: IndexMap::new(),
3623 metadata: IndexMap::new(),
3624 metadata_unset: Vec::new(),
3625 declare_relations: Vec::new(),
3626 dry_run: false,
3627 relations_unset: Vec::new(),
3628 anchors_unset: Vec::new(),
3629 },
3630 actor,
3631 Some(&client),
3632 None,
3633 )
3634 .unwrap_err();
3635 match err {
3636 EngineError::EmptyUpdate { id } => {
3637 assert_eq!(id, seeded.id.to_string());
3638 }
3639 other => panic!("expected EMPTY_UPDATE, got {other:?}"),
3640 }
3641 let log_path = tmp.path().join(".memstead/changes.jsonl");
3643 if let Ok(log) = std::fs::read_to_string(&log_path) {
3644 let updates = log.matches("\"kind\":\"update\"").count();
3645 assert_eq!(updates, 0, "EMPTY_UPDATE refusal must not log an update");
3646 }
3647 }
3648
3649 #[test]
3655 fn update_entity_noop_same_content_surfaces_warning() {
3656 let tmp = TempDir::new().unwrap();
3657 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Content Noop");
3658 let (actor, client) = cli_actor();
3659
3660 let mut sections = IndexMap::new();
3662 sections.insert("identity".to_string(), "fixture identity body".to_string());
3663
3664 let outcome = engine
3665 .update_entity(
3666 UpdateEntityArgs {
3667 anchors: Vec::new(),
3668 id: seeded.id.clone(),
3669 expected_hash: Some(seeded.content_hash.clone()),
3670 sections,
3671 append_sections: IndexMap::new(),
3672 patch_sections: IndexMap::new(),
3673 metadata: IndexMap::new(),
3674 metadata_unset: Vec::new(),
3675 declare_relations: Vec::new(),
3676 dry_run: false,
3677 relations_unset: Vec::new(),
3678 anchors_unset: Vec::new(),
3679 },
3680 actor,
3681 Some(&client),
3682 None,
3683 )
3684 .unwrap();
3685
3686 assert_eq!(outcome.commit_sha, "");
3687 assert_eq!(outcome.content_hash, seeded.content_hash);
3688 let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code()).collect();
3689 assert!(
3690 codes.contains(&"UPDATE_NOOP"),
3691 "same-content update must surface UPDATE_NOOP; got {codes:?}",
3692 );
3693 }
3694
3695 #[test]
3696 fn update_entity_noop_metadata_unset_on_absent_key() {
3697 let tmp = TempDir::new().unwrap();
3702 let (mut engine, seeded) = engine_with_seed(&tmp, "Absent Key Noop");
3703 let (actor, client) = cli_actor();
3704
3705 let outcome = engine
3706 .update_entity(
3707 UpdateEntityArgs {
3708 anchors: Vec::new(),
3709 id: seeded.id.clone(),
3710 expected_hash: Some(seeded.content_hash.clone()),
3711 sections: IndexMap::new(),
3712 append_sections: IndexMap::new(),
3713 patch_sections: IndexMap::new(),
3714 metadata: IndexMap::new(),
3715 metadata_unset: vec!["tags".to_string()],
3719 declare_relations: Vec::new(),
3720 dry_run: false,
3721 relations_unset: Vec::new(),
3722 anchors_unset: Vec::new(),
3723 },
3724 actor,
3725 Some(&client),
3726 None,
3727 )
3728 .unwrap();
3729
3730 assert_eq!(outcome.commit_sha, "");
3731 assert_eq!(outcome.content_hash, seeded.content_hash);
3732 assert!(
3733 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3734 "absent-key metadata_unset must surface UPDATE_NOOP",
3735 );
3736 assert!(
3739 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
3740 "no-op must report an empty metadata delta, got {:?}",
3741 outcome.modified_metadata,
3742 );
3743
3744 let mut sections = IndexMap::new();
3747 sections.insert("identity".to_string(), "real change".to_string());
3748 let real = engine
3749 .update_entity(
3750 UpdateEntityArgs {
3751 anchors: Vec::new(),
3752 id: seeded.id.clone(),
3753 expected_hash: Some(seeded.content_hash.clone()),
3754 sections,
3755 append_sections: IndexMap::new(),
3756 patch_sections: IndexMap::new(),
3757 metadata: IndexMap::new(),
3758 metadata_unset: Vec::new(),
3759 declare_relations: Vec::new(),
3760 dry_run: false,
3761 relations_unset: Vec::new(),
3762 anchors_unset: Vec::new(),
3763 },
3764 actor,
3765 Some(&client),
3766 None,
3767 )
3768 .unwrap();
3769 assert!(!real.commit_sha.is_empty());
3770 assert_ne!(real.content_hash, seeded.content_hash);
3771 }
3772
3773 #[test]
3780 fn update_entity_noop_setting_metadata_to_current_value_reports_empty_delta() {
3781 let tmp = TempDir::new().unwrap();
3782 let (mut engine, seeded) = engine_with_seed(&tmp, "Stability Resetter");
3783 let (actor, client) = cli_actor();
3784
3785 let mut metadata = IndexMap::new();
3788 metadata.insert("level".to_string(), "M0".to_string());
3789 let outcome = engine
3790 .update_entity(
3791 UpdateEntityArgs {
3792 anchors: Vec::new(),
3793 id: seeded.id.clone(),
3794 expected_hash: Some(seeded.content_hash.clone()),
3795 sections: IndexMap::new(),
3796 append_sections: IndexMap::new(),
3797 patch_sections: IndexMap::new(),
3798 metadata,
3799 metadata_unset: Vec::new(),
3800 declare_relations: Vec::new(),
3801 dry_run: false,
3802 relations_unset: Vec::new(),
3803 anchors_unset: Vec::new(),
3804 },
3805 actor,
3806 Some(&client),
3807 None,
3808 )
3809 .unwrap();
3810
3811 assert_eq!(outcome.commit_sha, "", "no-op must not commit");
3812 assert_eq!(
3813 outcome.content_hash, seeded.content_hash,
3814 "no-op must not advance hash"
3815 );
3816 assert!(
3817 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3818 "re-set to current value must surface UPDATE_NOOP",
3819 );
3820 assert!(
3821 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
3822 "no-op must not claim `level` was set — applied delta is empty, got {:?}",
3823 outcome.modified_metadata,
3824 );
3825 }
3826
3827 #[test]
3828 fn update_entity_noop_declare_already_related_edge() {
3829 use crate::ops::RelateArg;
3834 let tmp = TempDir::new().unwrap();
3835 let mem_dir = tmp.path().to_path_buf();
3836 let writer = FilesystemMemWriter::new(mem_dir.clone());
3837 let mut engine = Engine::from_mounts(vec![(
3838 folder_mount("specs", mem_dir),
3839 Box::new(writer) as Box<dyn MemBackend>,
3840 )])
3841 .unwrap();
3842 let (actor, client) = cli_actor();
3843 let target = engine
3844 .create_entity(
3845 empty_create_args("specs", "Target Already Related"),
3846 actor,
3847 Some(&client),
3848 None,
3849 )
3850 .unwrap();
3851 let source = engine
3852 .create_entity(
3853 empty_create_args("specs", "Source Already Related"),
3854 actor,
3855 Some(&client),
3856 None,
3857 )
3858 .unwrap();
3859 let after_relate = engine
3860 .relate_entity(
3861 RelateEntityArgs {
3862 source: source.id.clone(),
3863 expected_hash: Some(source.content_hash.clone()),
3864 rel_type: "USES".to_string(),
3865 target: target.id.clone(),
3866 remove: false,
3867 description: None,
3868 dry_run: false,
3869 },
3870 actor,
3871 Some(&client),
3872 None,
3873 )
3874 .unwrap();
3875 let outcome = engine
3877 .update_entity(
3878 UpdateEntityArgs {
3879 anchors: Vec::new(),
3880 relations_unset: Vec::new(),
3881 anchors_unset: Vec::new(),
3882 id: source.id.clone(),
3883 expected_hash: Some(after_relate.content_hash.clone()),
3884 sections: IndexMap::new(),
3885 append_sections: IndexMap::new(),
3886 patch_sections: IndexMap::new(),
3887 metadata: IndexMap::new(),
3888 metadata_unset: Vec::new(),
3889 declare_relations: vec![RelateArg {
3890 rel_type: "USES".to_string(),
3891 to: target.id.clone(),
3892 description: None,
3893 }],
3894 dry_run: false,
3895 },
3896 actor,
3897 Some(&client),
3898 None,
3899 )
3900 .unwrap();
3901
3902 assert_eq!(outcome.commit_sha, "");
3903 assert_eq!(outcome.content_hash, after_relate.content_hash);
3904 assert!(
3905 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3906 "duplicate declare must surface UPDATE_NOOP",
3907 );
3908 assert_eq!(outcome.relations_declared.len(), 1);
3911 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
3912 assert_eq!(outcome.relations_declared[0].target, target.id);
3913 assert!(!outcome.relations_declared[0].target_was_stubbed);
3914 }
3915
3916 #[test]
3917 fn update_entity_real_change_still_commits_and_advances_hash() {
3918 let tmp = TempDir::new().unwrap();
3923 let (mut engine, seeded) = engine_with_seed(&tmp, "Real Change Subject");
3924 let (actor, client) = cli_actor();
3925
3926 let mut sections = IndexMap::new();
3927 sections.insert("identity".to_string(), "definitely new body".to_string());
3928
3929 let outcome = engine
3930 .update_entity(
3931 UpdateEntityArgs {
3932 anchors: Vec::new(),
3933 id: seeded.id.clone(),
3934 expected_hash: Some(seeded.content_hash.clone()),
3935 sections,
3936 append_sections: IndexMap::new(),
3937 patch_sections: IndexMap::new(),
3938 metadata: IndexMap::new(),
3939 metadata_unset: Vec::new(),
3940 declare_relations: Vec::new(),
3941 dry_run: false,
3942 relations_unset: Vec::new(),
3943 anchors_unset: Vec::new(),
3944 },
3945 actor,
3946 Some(&client),
3947 None,
3948 )
3949 .unwrap();
3950
3951 assert!(!outcome.commit_sha.is_empty(), "real change must commit");
3952 assert_ne!(
3953 outcome.content_hash, seeded.content_hash,
3954 "real change must advance content_hash",
3955 );
3956 assert!(
3957 !outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3958 "real change must not surface UPDATE_NOOP",
3959 );
3960 }
3961
3962 #[test]
3963 fn update_entity_noop_preserves_expected_hash_across_chain() {
3964 let tmp = TempDir::new().unwrap();
3969 let (mut engine, seeded) = engine_with_seed(&tmp, "Chained Noops Subject");
3970 let (actor, client) = cli_actor();
3971
3972 let mut noop_sections = IndexMap::new();
3977 noop_sections.insert("identity".to_string(), "fixture identity body".to_string());
3978 for _ in 0..2 {
3979 let outcome = engine
3980 .update_entity(
3981 UpdateEntityArgs {
3982 anchors: Vec::new(),
3983 id: seeded.id.clone(),
3984 expected_hash: Some(seeded.content_hash.clone()),
3985 sections: noop_sections.clone(),
3986 append_sections: IndexMap::new(),
3987 patch_sections: IndexMap::new(),
3988 metadata: IndexMap::new(),
3989 metadata_unset: Vec::new(),
3990 declare_relations: Vec::new(),
3991 dry_run: false,
3992 relations_unset: Vec::new(),
3993 anchors_unset: Vec::new(),
3994 },
3995 actor,
3996 Some(&client),
3997 None,
3998 )
3999 .unwrap();
4000 assert_eq!(outcome.commit_sha, "");
4001 assert_eq!(outcome.content_hash, seeded.content_hash);
4002 }
4003
4004 let mut sections = IndexMap::new();
4007 sections.insert(
4008 "identity".to_string(),
4009 "third call: real change".to_string(),
4010 );
4011 let real = engine
4012 .update_entity(
4013 UpdateEntityArgs {
4014 anchors: Vec::new(),
4015 id: seeded.id.clone(),
4016 expected_hash: Some(seeded.content_hash.clone()),
4017 sections,
4018 append_sections: IndexMap::new(),
4019 patch_sections: IndexMap::new(),
4020 metadata: IndexMap::new(),
4021 metadata_unset: Vec::new(),
4022 declare_relations: Vec::new(),
4023 dry_run: false,
4024 relations_unset: Vec::new(),
4025 anchors_unset: Vec::new(),
4026 },
4027 actor,
4028 Some(&client),
4029 None,
4030 )
4031 .unwrap();
4032 assert!(!real.commit_sha.is_empty());
4033 assert_ne!(real.content_hash, seeded.content_hash);
4034 }
4035
4036 #[test]
4045 fn synthesis_gc_drops_auto_emitted_reference_when_body_link_removed() {
4046 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4050 use indexmap::IndexMap;
4051 use tempfile::TempDir;
4052
4053 let tmp = TempDir::new().unwrap();
4054 let mem_dir = tmp.path().to_path_buf();
4055 let writer = FilesystemMemWriter::new(mem_dir.clone());
4056 let mut engine = Engine::from_mounts(vec![(
4057 folder_mount("specs", mem_dir.clone()),
4058 Box::new(writer) as Box<dyn MemBackend>,
4059 )])
4060 .unwrap();
4061 engine.set_workspace_root(mem_dir.clone());
4062 let (actor, client) = cli_actor();
4063
4064 let target = engine
4065 .create_entity(
4066 empty_create_args("specs", "Target"),
4067 actor,
4068 Some(&client),
4069 None,
4070 )
4071 .unwrap();
4072 let mut sections: IndexMap<String, String> = IndexMap::new();
4075 sections.insert("identity".to_string(), "source identity".to_string());
4076 sections.insert(
4077 "purpose".to_string(),
4078 "see [[target]] for context".to_string(),
4079 );
4080 let source = engine
4081 .create_entity(
4082 CreateEntityArgs {
4083 anchors: Vec::new(),
4084 mem: "specs".to_string(),
4085 title: "Source".to_string(),
4086 entity_type: "spec".to_string(),
4087 sections,
4088 metadata: IndexMap::new(),
4089 relations: Vec::new(),
4090 dry_run: false,
4091 },
4092 actor,
4093 Some(&client),
4094 None,
4095 )
4096 .unwrap();
4097 assert!(
4098 engine
4099 .get_entity(&source.id)
4100 .unwrap()
4101 .relationships
4102 .iter()
4103 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4104 "create-time synthesis must emit REFERENCES → target",
4105 );
4106
4107 let mut new_sections: IndexMap<String, String> = IndexMap::new();
4110 new_sections.insert("purpose".to_string(), "no link any more".to_string());
4111 engine
4112 .update_entity(
4113 UpdateEntityArgs {
4114 anchors: Vec::new(),
4115 id: source.id.clone(),
4116 expected_hash: Some(source.content_hash.clone()),
4117 sections: new_sections,
4118 append_sections: IndexMap::new(),
4119 patch_sections: IndexMap::new(),
4120 metadata: IndexMap::new(),
4121 metadata_unset: Vec::new(),
4122 declare_relations: Vec::new(),
4123 dry_run: false,
4124 relations_unset: Vec::new(),
4125 anchors_unset: Vec::new(),
4126 },
4127 actor,
4128 Some(&client),
4129 None,
4130 )
4131 .expect("update must succeed; GC drops the now-orphan REFERENCES");
4132 let in_mem = engine.get_entity(&source.id).unwrap();
4133 assert!(
4134 !in_mem
4135 .relationships
4136 .iter()
4137 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4138 "GC must drop the auto-emitted REFERENCES after body link removal; got {:?}",
4139 in_mem.relationships,
4140 );
4141 }
4142
4143 #[test]
4144 fn update_gc_removes_orphan_stub_when_last_body_link_dropped() {
4145 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4153 use indexmap::IndexMap;
4154 use tempfile::TempDir;
4155
4156 let tmp = TempDir::new().unwrap();
4157 let mem_dir = tmp.path().to_path_buf();
4158 let writer = FilesystemMemWriter::new(mem_dir.clone());
4159 let mut engine = Engine::from_mounts(vec![(
4160 folder_mount("specs", mem_dir.clone()),
4161 Box::new(writer) as Box<dyn MemBackend>,
4162 )])
4163 .unwrap();
4164 engine.set_workspace_root(mem_dir.clone());
4165 let (actor, client) = cli_actor();
4166
4167 let ghost = crate::EntityId::new("specs", "ghost");
4168 let mut sections: IndexMap<String, String> = IndexMap::new();
4169 sections.insert("identity".to_string(), "source identity".to_string());
4170 sections.insert(
4171 "purpose".to_string(),
4172 "see [[ghost]] for context".to_string(),
4173 );
4174 let source = engine
4175 .create_entity(
4176 CreateEntityArgs {
4177 anchors: Vec::new(),
4178 mem: "specs".to_string(),
4179 title: "Source".to_string(),
4180 entity_type: "spec".to_string(),
4181 sections,
4182 metadata: IndexMap::new(),
4183 relations: Vec::new(),
4184 dry_run: false,
4185 },
4186 actor,
4187 Some(&client),
4188 None,
4189 )
4190 .unwrap();
4191 assert!(
4192 engine.store().contains(&ghost) && engine.get_entity(&ghost).unwrap().stub,
4193 "body wiki-link to an absent target must auto-stub it",
4194 );
4195 assert_eq!(
4196 engine.health().stub_count,
4197 1,
4198 "one stub before the link drop"
4199 );
4200
4201 let mut new_sections: IndexMap<String, String> = IndexMap::new();
4202 new_sections.insert("purpose".to_string(), "no link any more".to_string());
4203 let outcome = engine
4204 .update_entity(
4205 UpdateEntityArgs {
4206 anchors: Vec::new(),
4207 id: source.id.clone(),
4208 expected_hash: Some(source.content_hash.clone()),
4209 sections: new_sections,
4210 append_sections: IndexMap::new(),
4211 patch_sections: IndexMap::new(),
4212 metadata: IndexMap::new(),
4213 metadata_unset: Vec::new(),
4214 declare_relations: Vec::new(),
4215 dry_run: false,
4216 relations_unset: Vec::new(),
4217 anchors_unset: Vec::new(),
4218 },
4219 actor,
4220 Some(&client),
4221 None,
4222 )
4223 .expect("update must succeed and GC the now-orphan stub");
4224
4225 assert_eq!(
4226 outcome.orphan_stubs_removed,
4227 vec![ghost.clone()],
4228 "the update that dropped the last body link must report the GC'd stub",
4229 );
4230 assert!(
4231 !engine.store().contains(&ghost),
4232 "orphan stub must be gone from the in-memory store",
4233 );
4234 assert_eq!(
4235 engine.health().stub_count,
4236 0,
4237 "stub count decremented in-session"
4238 );
4239
4240 engine.reload_each_writable_mem().unwrap();
4244 assert!(
4245 !engine.store().contains(&ghost),
4246 "stub stays gone after reload-from-disk",
4247 );
4248 assert_eq!(
4249 engine.health().stub_count,
4250 0,
4251 "reloaded-from-disk store carries the same stub count as the in-session post-update state",
4252 );
4253 }
4254
4255 #[test]
4256 fn update_gc_noop_when_section_edit_changes_no_body_link() {
4257 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4262 use indexmap::IndexMap;
4263 use tempfile::TempDir;
4264
4265 let tmp = TempDir::new().unwrap();
4266 let mem_dir = tmp.path().to_path_buf();
4267 let writer = FilesystemMemWriter::new(mem_dir.clone());
4268 let mut engine = Engine::from_mounts(vec![(
4269 folder_mount("specs", mem_dir.clone()),
4270 Box::new(writer) as Box<dyn MemBackend>,
4271 )])
4272 .unwrap();
4273 engine.set_workspace_root(mem_dir.clone());
4274 let (actor, client) = cli_actor();
4275
4276 let ghost = crate::EntityId::new("specs", "ghost");
4277 let mut sections: IndexMap<String, String> = IndexMap::new();
4278 sections.insert("identity".to_string(), "original identity".to_string());
4279 sections.insert(
4280 "purpose".to_string(),
4281 "see [[ghost]] for context".to_string(),
4282 );
4283 let source = engine
4284 .create_entity(
4285 CreateEntityArgs {
4286 anchors: Vec::new(),
4287 mem: "specs".to_string(),
4288 title: "Source".to_string(),
4289 entity_type: "spec".to_string(),
4290 sections,
4291 metadata: IndexMap::new(),
4292 relations: Vec::new(),
4293 dry_run: false,
4294 },
4295 actor,
4296 Some(&client),
4297 None,
4298 )
4299 .unwrap();
4300 assert!(engine.store().contains(&ghost), "ghost stub materialised");
4301
4302 let mut edit: IndexMap<String, String> = IndexMap::new();
4305 edit.insert("identity".to_string(), "edited identity".to_string());
4306 let outcome = engine
4307 .update_entity(
4308 UpdateEntityArgs {
4309 anchors: Vec::new(),
4310 id: source.id.clone(),
4311 expected_hash: Some(source.content_hash.clone()),
4312 sections: edit,
4313 append_sections: IndexMap::new(),
4314 patch_sections: IndexMap::new(),
4315 metadata: IndexMap::new(),
4316 metadata_unset: Vec::new(),
4317 declare_relations: Vec::new(),
4318 dry_run: false,
4319 relations_unset: Vec::new(),
4320 anchors_unset: Vec::new(),
4321 },
4322 actor,
4323 Some(&client),
4324 None,
4325 )
4326 .expect("update must succeed");
4327 assert!(
4328 outcome.orphan_stubs_removed.is_empty(),
4329 "an edit that keeps every body wiki-link orphans nothing; got {:?}",
4330 outcome.orphan_stubs_removed,
4331 );
4332 assert!(
4333 engine.store().contains(&ghost),
4334 "the still-referenced stub survives the unrelated section edit",
4335 );
4336 }
4337
4338 #[test]
4339 fn update_gc_preserves_stub_with_surviving_referrer() {
4340 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4344 use indexmap::IndexMap;
4345 use tempfile::TempDir;
4346
4347 let tmp = TempDir::new().unwrap();
4348 let mem_dir = tmp.path().to_path_buf();
4349 let writer = FilesystemMemWriter::new(mem_dir.clone());
4350 let mut engine = Engine::from_mounts(vec![(
4351 folder_mount("specs", mem_dir.clone()),
4352 Box::new(writer) as Box<dyn MemBackend>,
4353 )])
4354 .unwrap();
4355 engine.set_workspace_root(mem_dir.clone());
4356 let (actor, client) = cli_actor();
4357
4358 let ghost = crate::EntityId::new("specs", "ghost");
4359 let make_with_link = |title: &str| {
4360 let mut sections: IndexMap<String, String> = IndexMap::new();
4361 sections.insert("identity".to_string(), format!("{title} identity"));
4362 sections.insert("purpose".to_string(), "see [[ghost]]".to_string());
4363 CreateEntityArgs {
4364 anchors: Vec::new(),
4365 mem: "specs".to_string(),
4366 title: title.to_string(),
4367 entity_type: "spec".to_string(),
4368 sections,
4369 metadata: IndexMap::new(),
4370 relations: Vec::new(),
4371 dry_run: false,
4372 }
4373 };
4374 let source_a = engine
4375 .create_entity(make_with_link("Source A"), actor, Some(&client), None)
4376 .unwrap();
4377 engine
4378 .create_entity(make_with_link("Source B"), actor, Some(&client), None)
4379 .unwrap();
4380 assert!(engine.store().contains(&ghost), "ghost stub materialised");
4381
4382 let mut drop_link: IndexMap<String, String> = IndexMap::new();
4384 drop_link.insert("purpose".to_string(), "no link here".to_string());
4385 let outcome = engine
4386 .update_entity(
4387 UpdateEntityArgs {
4388 anchors: Vec::new(),
4389 id: source_a.id.clone(),
4390 expected_hash: Some(source_a.content_hash.clone()),
4391 sections: drop_link,
4392 append_sections: IndexMap::new(),
4393 patch_sections: IndexMap::new(),
4394 metadata: IndexMap::new(),
4395 metadata_unset: Vec::new(),
4396 declare_relations: Vec::new(),
4397 dry_run: false,
4398 relations_unset: Vec::new(),
4399 anchors_unset: Vec::new(),
4400 },
4401 actor,
4402 Some(&client),
4403 None,
4404 )
4405 .expect("update must succeed");
4406 assert!(
4407 outcome.orphan_stubs_removed.is_empty(),
4408 "the stub keeps a referrer (source B), so nothing is GC'd; got {:?}",
4409 outcome.orphan_stubs_removed,
4410 );
4411 assert!(
4412 engine.store().contains(&ghost),
4413 "stub survives via the surviving referrer",
4414 );
4415 }
4416
4417 #[test]
4418 fn synthesis_gc_preserves_non_pointer_explicit_relation_across_body_update() {
4419 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
4428 use indexmap::IndexMap;
4429 use tempfile::TempDir;
4430
4431 let tmp = TempDir::new().unwrap();
4432 let mem_dir = tmp.path().to_path_buf();
4433 let writer = FilesystemMemWriter::new(mem_dir.clone());
4434 let mut engine = Engine::from_mounts(vec![(
4435 folder_mount("specs", mem_dir.clone()),
4436 Box::new(writer) as Box<dyn MemBackend>,
4437 )])
4438 .unwrap();
4439 engine.set_workspace_root(mem_dir.clone());
4440 let (actor, client) = cli_actor();
4441
4442 let target = engine
4443 .create_entity(
4444 empty_create_args("specs", "Target"),
4445 actor,
4446 Some(&client),
4447 None,
4448 )
4449 .unwrap();
4450 let source = engine
4451 .create_entity(
4452 empty_create_args("specs", "Source"),
4453 actor,
4454 Some(&client),
4455 None,
4456 )
4457 .unwrap();
4458
4459 let relate = engine
4461 .relate_entity(
4462 RelateEntityArgs {
4463 source: source.id.clone(),
4464 expected_hash: Some(source.content_hash.clone()),
4465 rel_type: "USES".to_string(),
4466 target: target.id.clone(),
4467 remove: false,
4468 description: None,
4469 dry_run: false,
4470 },
4471 actor,
4472 Some(&client),
4473 None,
4474 )
4475 .unwrap();
4476
4477 let mut sections: IndexMap<String, String> = IndexMap::new();
4480 sections.insert("purpose".to_string(), "unrelated edit".to_string());
4481 engine
4482 .update_entity(
4483 UpdateEntityArgs {
4484 anchors: Vec::new(),
4485 id: source.id.clone(),
4486 expected_hash: Some(relate.content_hash.clone()),
4487 sections,
4488 append_sections: IndexMap::new(),
4489 patch_sections: IndexMap::new(),
4490 metadata: IndexMap::new(),
4491 metadata_unset: Vec::new(),
4492 declare_relations: Vec::new(),
4493 dry_run: false,
4494 relations_unset: Vec::new(),
4495 anchors_unset: Vec::new(),
4496 },
4497 actor,
4498 Some(&client),
4499 None,
4500 )
4501 .expect("update must succeed");
4502 let in_mem = engine.get_entity(&source.id).unwrap();
4503 assert!(
4504 in_mem
4505 .relationships
4506 .iter()
4507 .any(|r| r.rel_type == "USES" && r.target == target.id),
4508 "explicit USES must survive an unrelated body update; got {:?}",
4509 in_mem.relationships,
4510 );
4511 }
4512
4513 #[test]
4514 fn synthesis_dedupes_repeated_body_links_to_same_target() {
4515 use crate::engine::UpdateEntityArgs;
4518 use indexmap::IndexMap;
4519 use tempfile::TempDir;
4520
4521 let tmp = TempDir::new().unwrap();
4522 let mem_dir = tmp.path().to_path_buf();
4523 let writer = FilesystemMemWriter::new(mem_dir.clone());
4524 let mut engine = Engine::from_mounts(vec![(
4525 folder_mount("specs", mem_dir.clone()),
4526 Box::new(writer) as Box<dyn MemBackend>,
4527 )])
4528 .unwrap();
4529 engine.set_workspace_root(mem_dir.clone());
4530 let (actor, client) = cli_actor();
4531
4532 let target = engine
4533 .create_entity(
4534 empty_create_args("specs", "Target"),
4535 actor,
4536 Some(&client),
4537 None,
4538 )
4539 .unwrap();
4540 let source = engine
4541 .create_entity(
4542 empty_create_args("specs", "Source"),
4543 actor,
4544 Some(&client),
4545 None,
4546 )
4547 .unwrap();
4548
4549 let mut sections: IndexMap<String, String> = IndexMap::new();
4550 sections.insert(
4551 "purpose".to_string(),
4552 "see [[target]] and again [[target]]".to_string(),
4553 );
4554 engine
4555 .update_entity(
4556 UpdateEntityArgs {
4557 anchors: Vec::new(),
4558 id: source.id.clone(),
4559 expected_hash: Some(source.content_hash.clone()),
4560 sections,
4561 append_sections: IndexMap::new(),
4562 patch_sections: IndexMap::new(),
4563 metadata: IndexMap::new(),
4564 metadata_unset: Vec::new(),
4565 declare_relations: Vec::new(),
4566 dry_run: false,
4567 relations_unset: Vec::new(),
4568 anchors_unset: Vec::new(),
4569 },
4570 actor,
4571 Some(&client),
4572 None,
4573 )
4574 .unwrap();
4575 let in_mem = engine.get_entity(&source.id).unwrap();
4576 let count = in_mem
4577 .relationships
4578 .iter()
4579 .filter(|r| r.rel_type == "REFERENCES" && r.target == target.id)
4580 .count();
4581 assert_eq!(
4582 count, 1,
4583 "dedupe must leave exactly one REFERENCES → target; got {:?}",
4584 in_mem.relationships,
4585 );
4586 }
4587
4588 #[test]
4589 fn synthesis_coexists_with_explicit_uses_to_same_target() {
4590 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
4595 use indexmap::IndexMap;
4596 use tempfile::TempDir;
4597
4598 let tmp = TempDir::new().unwrap();
4599 let mem_dir = tmp.path().to_path_buf();
4600 let writer = FilesystemMemWriter::new(mem_dir.clone());
4601 let mut engine = Engine::from_mounts(vec![(
4602 folder_mount("specs", mem_dir.clone()),
4603 Box::new(writer) as Box<dyn MemBackend>,
4604 )])
4605 .unwrap();
4606 engine.set_workspace_root(mem_dir.clone());
4607 let (actor, client) = cli_actor();
4608
4609 let target = engine
4610 .create_entity(
4611 empty_create_args("specs", "Target"),
4612 actor,
4613 Some(&client),
4614 None,
4615 )
4616 .unwrap();
4617 let source = engine
4618 .create_entity(
4619 empty_create_args("specs", "Source"),
4620 actor,
4621 Some(&client),
4622 None,
4623 )
4624 .unwrap();
4625 let relate = engine
4627 .relate_entity(
4628 RelateEntityArgs {
4629 source: source.id.clone(),
4630 expected_hash: Some(source.content_hash.clone()),
4631 rel_type: "USES".to_string(),
4632 target: target.id.clone(),
4633 remove: false,
4634 description: None,
4635 dry_run: false,
4636 },
4637 actor,
4638 Some(&client),
4639 None,
4640 )
4641 .unwrap();
4642 let mut sections: IndexMap<String, String> = IndexMap::new();
4644 sections.insert(
4645 "purpose".to_string(),
4646 "we also reference [[target]]".to_string(),
4647 );
4648 engine
4649 .update_entity(
4650 UpdateEntityArgs {
4651 anchors: Vec::new(),
4652 id: source.id.clone(),
4653 expected_hash: Some(relate.content_hash.clone()),
4654 sections,
4655 append_sections: IndexMap::new(),
4656 patch_sections: IndexMap::new(),
4657 metadata: IndexMap::new(),
4658 metadata_unset: Vec::new(),
4659 declare_relations: Vec::new(),
4660 dry_run: false,
4661 relations_unset: Vec::new(),
4662 anchors_unset: Vec::new(),
4663 },
4664 actor,
4665 Some(&client),
4666 None,
4667 )
4668 .unwrap();
4669 let in_mem = engine.get_entity(&source.id).unwrap();
4670 assert!(
4671 in_mem
4672 .relationships
4673 .iter()
4674 .any(|r| r.rel_type == "USES" && r.target == target.id),
4675 "USES must survive — synthesis dedupes on (rel_type, target)",
4676 );
4677 assert!(
4678 in_mem
4679 .relationships
4680 .iter()
4681 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4682 "REFERENCES must be synthesised even though USES already targets the same entity",
4683 );
4684 }
4685
4686 mod alias_synthesis_custom_schema {
4698 use std::path::Path;
4699
4700 use indexmap::IndexMap;
4701 use memstead_schema::SchemaRef;
4702 use tempfile::TempDir;
4703
4704 use crate::backend::MemBackend;
4705 use crate::engine::test_helpers::*;
4706 use crate::engine::{CreateEntityArgs, Engine, EngineError, UpdateEntityArgs};
4707 use crate::storage::FilesystemMemWriter;
4708 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4709
4710 const TYPE_BODY: &str = r#"description: t
4711when_to_use: tests
4712sections:
4713 - key: body
4714 heading: Body
4715 required: true
4716 search_weight: 10.0
4717 catch_all: true
4718 write_rules: []
4719metadata_fields: []
4720title_weight: 100.0
4721text_fields:
4722 - body
4723hierarchy_relationship: _default
4724no_self_loop_relationships: []
4725updatable_fields:
4726 - title
4727 - body
4728health_required_fields:
4729 - body
4730staleness_threshold_days: 90
4731write_rules: []
4732"#;
4733
4734 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
4735 let dir = root.join(name);
4736 std::fs::create_dir_all(dir.join("types")).unwrap();
4737 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
4738 for (type_name, body) in types {
4739 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
4740 }
4741 }
4742
4743 fn make_type_yaml(name: &str) -> String {
4744 format!("name: {name}\n{TYPE_BODY}")
4745 }
4746
4747 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
4748 Mount {
4749 mem: mem.to_string(),
4750 schema: Some(pin),
4751 storage: MountStorage::Folder { path },
4752 capability: MountCapability::Write,
4753 lifecycle: MountLifecycle::Eager,
4754 cross_linkable: true,
4755 migration_target: None,
4756 }
4757 }
4758
4759 fn engine_with_schema(
4760 manifest: &str,
4761 type_yaml_name: &str,
4762 schema_name: &str,
4763 schema_version: semver::Version,
4764 ) -> (Engine, TempDir) {
4765 let tmp = TempDir::new().unwrap();
4766 let schemas_dir = tmp.path().join("schemas");
4767 std::fs::create_dir_all(&schemas_dir).unwrap();
4768 write_schema_files(
4769 &schemas_dir,
4770 schema_name,
4771 manifest,
4772 &[(type_yaml_name, &make_type_yaml(type_yaml_name))],
4773 );
4774 let mem_dir = tmp.path().join("mem");
4775 std::fs::create_dir_all(&mem_dir).unwrap();
4776 let writer = FilesystemMemWriter::new(mem_dir.clone());
4777 let pin = SchemaRef::new(schema_name, schema_version);
4778 let mount = folder_mount_with_pin("v", mem_dir, pin);
4779 let mut engine = Engine::from_mounts_with_schemas_dir(
4780 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
4781 Some(&schemas_dir),
4782 )
4783 .expect("engine with custom schema constructs");
4784 engine.set_workspace_root(tmp.path().to_path_buf());
4785 (engine, tmp)
4786 }
4787
4788 #[test]
4789 fn non_references_alias_pointer_emits_named_rel_type_from_body_link() {
4790 let manifest = r#"name: aliased
4795version: 0.1.0
4796description: alias-synthesis fixture using a non-REFERENCES pointer
4797when_to_use: tests prove the engine does not hard-code REFERENCES
4798types:
4799 - doc
4800relationships:
4801 mode: strict
4802 definitions:
4803 - name: CITES
4804 description: Citation — auto-emitted from body wiki-links
4805 default_weight: 0.5
4806 - name: PART_OF
4807 description: Hierarchy
4808 default_weight: 3.0
4809 acyclic: true
4810 - name: _default
4811 description: Fallback
4812 default_weight: 1.0
4813alias_target_rel_type: CITES
4814community:
4815 resolution: 1.0
4816 seed: 42
4817"#;
4818 let (mut engine, _tmp) =
4819 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4820 let (actor, client) = cli_actor();
4821
4822 let target = engine
4823 .create_entity(
4824 CreateEntityArgs {
4825 anchors: Vec::new(),
4826 mem: "v".to_string(),
4827 title: "Target".to_string(),
4828 entity_type: "doc".to_string(),
4829 sections: IndexMap::from_iter([(
4830 "body".to_string(),
4831 "target body".to_string(),
4832 )]),
4833 metadata: IndexMap::new(),
4834 relations: Vec::new(),
4835 dry_run: false,
4836 },
4837 actor,
4838 Some(&client),
4839 None,
4840 )
4841 .unwrap();
4842
4843 let mut sections: IndexMap<String, String> = IndexMap::new();
4844 sections.insert("body".to_string(), "see [[target]]".to_string());
4845 let source = engine
4846 .create_entity(
4847 CreateEntityArgs {
4848 anchors: Vec::new(),
4849 mem: "v".to_string(),
4850 title: "Source".to_string(),
4851 entity_type: "doc".to_string(),
4852 sections,
4853 metadata: IndexMap::new(),
4854 relations: Vec::new(),
4855 dry_run: false,
4856 },
4857 actor,
4858 Some(&client),
4859 None,
4860 )
4861 .expect("create must succeed; CITES is auto-emitted by synthesis");
4862
4863 let in_mem = engine.get_entity(&source.id).unwrap();
4864 assert!(
4865 in_mem
4866 .relationships
4867 .iter()
4868 .any(|r| r.rel_type == "CITES" && r.target == target.id),
4869 "synthesis must emit CITES (the pointer rel-type), not REFERENCES; got {:?}",
4870 in_mem.relationships,
4871 );
4872 assert!(
4873 !in_mem
4874 .relationships
4875 .iter()
4876 .any(|r| r.rel_type == "REFERENCES"),
4877 "engine must not hard-code REFERENCES — pointer rel-type is CITES; got {:?}",
4878 in_mem.relationships,
4879 );
4880 }
4881
4882 #[test]
4883 fn no_pointer_schema_refuses_unbacked_body_wiki_link() {
4884 let manifest = r#"name: no-alias
4889version: 0.1.0
4890description: schema without alias_target_rel_type pointer
4891when_to_use: tests prove strict validator still fires for opt-out schemas
4892types:
4893 - doc
4894relationships:
4895 mode: strict
4896 definitions:
4897 - name: USES
4898 description: Use
4899 default_weight: 1.0
4900 - name: PART_OF
4901 description: Hierarchy
4902 default_weight: 3.0
4903 acyclic: true
4904 - name: _default
4905 description: Fallback
4906 default_weight: 1.0
4907community:
4908 resolution: 1.0
4909 seed: 42
4910"#;
4911 let (mut engine, _tmp) =
4912 engine_with_schema(manifest, "doc", "no-alias", semver::Version::new(0, 1, 0));
4913 let (actor, client) = cli_actor();
4914
4915 let target = engine
4916 .create_entity(
4917 CreateEntityArgs {
4918 anchors: Vec::new(),
4919 mem: "v".to_string(),
4920 title: "Target".to_string(),
4921 entity_type: "doc".to_string(),
4922 sections: IndexMap::from_iter([(
4923 "body".to_string(),
4924 "target body".to_string(),
4925 )]),
4926 metadata: IndexMap::new(),
4927 relations: Vec::new(),
4928 dry_run: false,
4929 },
4930 actor,
4931 Some(&client),
4932 None,
4933 )
4934 .unwrap();
4935 let source = engine
4936 .create_entity(
4937 CreateEntityArgs {
4938 anchors: Vec::new(),
4939 mem: "v".to_string(),
4940 title: "Source".to_string(),
4941 entity_type: "doc".to_string(),
4942 sections: IndexMap::from_iter([(
4943 "body".to_string(),
4944 "source body".to_string(),
4945 )]),
4946 metadata: IndexMap::new(),
4947 relations: Vec::new(),
4948 dry_run: false,
4949 },
4950 actor,
4951 Some(&client),
4952 None,
4953 )
4954 .unwrap();
4955
4956 let mut sections: IndexMap<String, String> = IndexMap::new();
4960 sections.insert("body".to_string(), "see [[target]]".to_string());
4961 let err = engine
4962 .update_entity(
4963 UpdateEntityArgs {
4964 anchors: Vec::new(),
4965 id: source.id.clone(),
4966 expected_hash: Some(source.content_hash.clone()),
4967 sections,
4968 append_sections: IndexMap::new(),
4969 patch_sections: IndexMap::new(),
4970 metadata: IndexMap::new(),
4971 metadata_unset: Vec::new(),
4972 declare_relations: Vec::new(),
4973 dry_run: false,
4974 relations_unset: Vec::new(),
4975 anchors_unset: Vec::new(),
4976 },
4977 actor,
4978 Some(&client),
4979 None,
4980 )
4981 .unwrap_err();
4982 match err {
4983 EngineError::WikiLinkWithoutRelation { from_id, missing } => {
4984 assert_eq!(from_id, source.id.to_string());
4985 assert_eq!(missing.len(), 1);
4986 assert_eq!(missing[0].section_key, "body");
4987 assert_eq!(missing[0].target_id, target.id.to_string());
4988 }
4989 other => panic!(
4990 "no-pointer schema must refuse with WikiLinkWithoutRelation; got {other:?}"
4991 ),
4992 }
4993 }
4994
4995 #[test]
5004 fn natural_form_body_wiki_link_refuses_with_typed_envelope() {
5005 let manifest = r#"name: aliased
5006version: 0.1.0
5007description: alias-synthesis fixture
5008when_to_use: tests prove strict wiki-link grammar at mutation entry
5009types:
5010 - doc
5011relationships:
5012 mode: strict
5013 definitions:
5014 - name: REFERENCES
5015 description: Reference — auto-emitted from body wiki-links
5016 default_weight: 0.5
5017 - name: PART_OF
5018 description: Hierarchy
5019 default_weight: 3.0
5020 acyclic: true
5021 - name: _default
5022 description: Fallback
5023 default_weight: 1.0
5024alias_target_rel_type: REFERENCES
5025community:
5026 resolution: 1.0
5027 seed: 42
5028"#;
5029 let (mut engine, _tmp) =
5030 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5031 let (actor, client) = cli_actor();
5032
5033 let mut sections: IndexMap<String, String> = IndexMap::new();
5034 sections.insert("body".to_string(), "see [[Knowledge Graph]]".to_string());
5035 let err = engine
5036 .create_entity(
5037 CreateEntityArgs {
5038 anchors: Vec::new(),
5039 mem: "v".to_string(),
5040 title: "Source".to_string(),
5041 entity_type: "doc".to_string(),
5042 sections,
5043 metadata: IndexMap::new(),
5044 relations: Vec::new(),
5045 dry_run: false,
5046 },
5047 actor,
5048 Some(&client),
5049 None,
5050 )
5051 .unwrap_err();
5052 match err {
5053 EngineError::InvalidWikiLinkTarget {
5054 raw,
5055 suggested,
5056 section,
5057 link_source,
5058 ..
5059 } => {
5060 assert_eq!(raw, "Knowledge Graph");
5061 assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
5062 assert_eq!(section, "body");
5063 assert_eq!(link_source, "body_link");
5064 }
5065 other => panic!(
5066 "natural-form body wiki-link must refuse with InvalidWikiLinkTarget; got {other:?}"
5067 ),
5068 }
5069 }
5070
5071 #[test]
5077 fn tier_two_bad_mem_prefix_refuses_with_distinct_envelope() {
5078 let manifest = r#"name: aliased
5079version: 0.1.0
5080description: alias-synthesis fixture
5081when_to_use: tests prove strict mem-prefix grammar at mutation entry
5082types:
5083 - doc
5084relationships:
5085 mode: strict
5086 definitions:
5087 - name: REFERENCES
5088 description: Reference
5089 default_weight: 0.5
5090 - name: PART_OF
5091 description: Hierarchy
5092 default_weight: 3.0
5093 acyclic: true
5094 - name: _default
5095 description: Fallback
5096 default_weight: 1.0
5097alias_target_rel_type: REFERENCES
5098community:
5099 resolution: 1.0
5100 seed: 42
5101"#;
5102 let (mut engine, _tmp) =
5103 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5104 let (actor, client) = cli_actor();
5105
5106 let mut sections: IndexMap<String, String> = IndexMap::new();
5107 sections.insert("body".to_string(), "see [[Other Mem:foo]]".to_string());
5108 let err = engine
5109 .create_entity(
5110 CreateEntityArgs {
5111 anchors: Vec::new(),
5112 mem: "v".to_string(),
5113 title: "Source".to_string(),
5114 entity_type: "doc".to_string(),
5115 sections,
5116 metadata: IndexMap::new(),
5117 relations: Vec::new(),
5118 dry_run: false,
5119 },
5120 actor,
5121 Some(&client),
5122 None,
5123 )
5124 .unwrap_err();
5125 match err {
5126 EngineError::InvalidWikiLinkMem { raw, section, .. } => {
5127 assert_eq!(raw, "Other Mem");
5128 assert_eq!(section, "body");
5129 }
5130 other => panic!(
5131 "Tier-2 bad mem prefix must refuse with InvalidWikiLinkMem; got {other:?}"
5132 ),
5133 }
5134 }
5135
5136 #[test]
5143 fn hierarchical_dash_form_body_link_refuses_with_colon_suggestion() {
5144 let manifest = r#"name: aliased
5145version: 0.1.0
5146description: alias-synthesis fixture
5147when_to_use: tests prove hierarchical dash-form refusal at mutation entry
5148types:
5149 - doc
5150relationships:
5151 mode: strict
5152 definitions:
5153 - name: REFERENCES
5154 description: Reference — auto-emitted from body wiki-links
5155 default_weight: 0.5
5156 - name: PART_OF
5157 description: Hierarchy
5158 default_weight: 3.0
5159 acyclic: true
5160 - name: _default
5161 description: Fallback
5162 default_weight: 1.0
5163alias_target_rel_type: REFERENCES
5164community:
5165 resolution: 1.0
5166 seed: 42
5167"#;
5168 let (mut engine, _tmp) =
5169 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5170 let (actor, client) = cli_actor();
5171
5172 let mut sections: IndexMap<String, String> = IndexMap::new();
5173 sections.insert(
5174 "body".to_string(),
5175 "see [[team/sub-mem--target]]".to_string(),
5176 );
5177 let err = engine
5178 .create_entity(
5179 CreateEntityArgs {
5180 anchors: Vec::new(),
5181 mem: "v".to_string(),
5182 title: "Source".to_string(),
5183 entity_type: "doc".to_string(),
5184 sections,
5185 metadata: IndexMap::new(),
5186 relations: Vec::new(),
5187 dry_run: false,
5188 },
5189 actor,
5190 Some(&client),
5191 None,
5192 )
5193 .unwrap_err();
5194 match err {
5195 EngineError::InvalidWikiLinkTarget {
5196 raw,
5197 suggested,
5198 section,
5199 link_source,
5200 ..
5201 } => {
5202 assert_eq!(raw, "team/sub-mem--target");
5203 assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
5204 assert_eq!(section, "body");
5205 assert_eq!(link_source, "body_link");
5206 }
5207 other => panic!(
5208 "hierarchical dash-form body link must refuse with InvalidWikiLinkTarget; got {other:?}"
5209 ),
5210 }
5211
5212 let listed = engine.store().all_entities().collect::<Vec<_>>();
5215 assert!(
5216 listed.is_empty(),
5217 "refused create must not leave any entity behind, got: {listed:?}"
5218 );
5219 }
5220 }
5221
5222 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";
5231
5232 fn repair_engine() -> (TempDir, Engine) {
5233 let tmp = TempDir::new().unwrap();
5234 let mem_dir = tmp.path().to_path_buf();
5235 std::fs::write(
5236 mem_dir.join("anchor.md"),
5237 "---\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",
5238 )
5239 .unwrap();
5240 std::fs::write(mem_dir.join("drifted.md"), DRIFTED_MD).unwrap();
5241 let writer = FilesystemMemWriter::new(mem_dir.clone());
5242 let engine = Engine::from_mounts(vec![(
5243 folder_mount("specs", mem_dir),
5244 Box::new(writer) as Box<dyn MemBackend>,
5245 )])
5246 .unwrap();
5247 (tmp, engine)
5248 }
5249
5250 fn repair_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5251 UpdateEntityArgs {
5252 anchors: Vec::new(),
5253 id,
5254 expected_hash: hash,
5255 sections: IndexMap::new(),
5256 append_sections: IndexMap::new(),
5257 patch_sections: IndexMap::new(),
5258 metadata: IndexMap::new(),
5259 metadata_unset: Vec::new(),
5260 declare_relations: Vec::new(),
5261 dry_run: false,
5262 relations_unset: vec![crate::ops::RelationUnsetArg {
5263 rel_type: "USES".to_string(),
5264 target: EntityId::new("specs", "anchor"),
5265 }],
5266 anchors_unset: Vec::new(),
5267 }
5268 }
5269
5270 #[test]
5275 fn relations_unset_on_conformant_entity_refuses_repair_not_needed() {
5276 let (_tmp, mut engine) = repair_engine();
5277 let anchor = EntityId::new("specs", "anchor");
5280 let drifted = EntityId::new("specs", "drifted");
5281 engine
5282 .relate_entity(
5283 RelateEntityArgs {
5284 source: anchor.clone(),
5285 expected_hash: None,
5286 rel_type: "USES".to_string(),
5287 target: drifted.clone(),
5288 remove: false,
5289 description: None,
5290 dry_run: false,
5291 },
5292 Actor::Cli,
5293 None,
5294 None,
5295 )
5296 .expect("relate on conformant entity works");
5297 let mut args = repair_args(anchor.clone(), None);
5298 args.relations_unset[0].target = drifted.clone();
5299 let err = engine
5300 .update_entity(args, Actor::Cli, None, None)
5301 .unwrap_err();
5302 match err {
5303 EngineError::RepairNotNeeded { id, recovery } => {
5304 assert_eq!(id, anchor.to_string());
5305 assert!(
5306 recovery.contains("memstead_relate"),
5307 "recovery must point at the focused tool; got {recovery}"
5308 );
5309 }
5310 other => panic!("expected RepairNotNeeded, got {other:?}"),
5311 }
5312 let entity = engine.store().get(&anchor).unwrap();
5314 assert!(
5315 entity.relationships.iter().any(|r| r.target == drifted),
5316 "gate must not modify the entity"
5317 );
5318 }
5319
5320 #[test]
5325 fn relations_unset_repairs_non_conformant_entity_atomically() {
5326 let (_tmp, mut engine) = repair_engine();
5327 let drifted = EntityId::new("specs", "drifted");
5328 let pre = engine.conformance_findings("specs", None).unwrap();
5330 assert!(
5331 pre.iter().any(|f| f.id == drifted.to_string()),
5332 "fixture must lint non-conformant; got {pre:?}"
5333 );
5334 let mut args = repair_args(drifted.clone(), None);
5335 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
5336 engine
5337 .update_entity(args, Actor::Cli, None, None)
5338 .expect("repair update lands");
5339 let entity = engine.store().get(&drifted).unwrap();
5340 assert!(
5341 entity.relationships.is_empty(),
5342 "relation must be removed; got {:?}",
5343 entity.relationships
5344 );
5345 assert!(
5346 !entity.metadata.contains_key("zzz_bogus_field"),
5347 "conformance break must be repaired in the same update"
5348 );
5349 let post = engine.conformance_findings("specs", None).unwrap();
5350 assert!(
5351 post.iter().all(|f| f.id != drifted.to_string()),
5352 "post-repair entity must be conformant; got {post:?}"
5353 );
5354 }
5355
5356 #[test]
5360 fn relations_unset_post_state_must_still_validate() {
5361 let (_tmp, mut engine) = repair_engine();
5362 let drifted = EntityId::new("specs", "drifted");
5363 let mut args = repair_args(drifted.clone(), None);
5364 args.sections = IndexMap::from_iter([("nonexistent_section".to_string(), "x".to_string())]);
5367 let err = engine
5368 .update_entity(args, Actor::Cli, None, None)
5369 .unwrap_err();
5370 assert_eq!(
5371 err.code(),
5372 "UNKNOWN_SECTION",
5373 "strict-write post-condition must hold during repair; got {err:?}"
5374 );
5375 let entity = engine.store().get(&drifted).unwrap();
5377 assert!(
5378 !entity.relationships.is_empty(),
5379 "refused repair must not partially apply"
5380 );
5381 }
5382
5383 #[test]
5386 fn relations_unset_absent_pair_is_silent_noop() {
5387 let (_tmp, mut engine) = repair_engine();
5388 let drifted = EntityId::new("specs", "drifted");
5389 let mut args = repair_args(drifted.clone(), None);
5390 args.relations_unset[0].rel_type = "NEVER_DECLARED".to_string();
5391 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
5393 engine
5394 .update_entity(args, Actor::Cli, None, None)
5395 .expect("absent pair no-ops, update lands");
5396 let entity = engine.store().get(&drifted).unwrap();
5397 assert_eq!(
5398 entity.relationships.len(),
5399 1,
5400 "the USES relation must survive an unmatched unset"
5401 );
5402 }
5403
5404 fn anchor_input(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
5407 crate::anchor::AnchorInput {
5408 artifact: Some(artifact.to_string()),
5409 grain: Some("file".to_string()),
5410 class: Some("anchored".to_string()),
5411 hash: Some(hash.to_string()),
5412 hash_stability: Some("stable".to_string()),
5413 ..Default::default()
5414 }
5415 }
5416
5417 fn anchor_unset(artifact: &str) -> crate::anchor::AnchorUnsetInput {
5418 crate::anchor::AnchorUnsetInput {
5419 artifact: Some(artifact.to_string()),
5420 grain: None,
5421 class: None,
5422 }
5423 }
5424
5425 fn anchor_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5427 UpdateEntityArgs {
5428 anchors: Vec::new(),
5429 anchors_unset: Vec::new(),
5430 id,
5431 expected_hash: hash,
5432 sections: IndexMap::new(),
5433 append_sections: IndexMap::new(),
5434 patch_sections: IndexMap::new(),
5435 metadata: IndexMap::new(),
5436 metadata_unset: Vec::new(),
5437 declare_relations: Vec::new(),
5438 dry_run: false,
5439 relations_unset: Vec::new(),
5440 }
5441 }
5442
5443 fn anchored_engine() -> (Engine, TempDir, EntityId, String) {
5446 let tmp = TempDir::new().unwrap();
5447 let mem_dir = tmp.path().to_path_buf();
5448 let writer = FilesystemMemWriter::new(mem_dir.clone());
5449 let mut engine = Engine::from_mounts(vec![(
5450 folder_mount("specs", mem_dir),
5451 Box::new(writer) as Box<dyn MemBackend>,
5452 )])
5453 .unwrap();
5454 let (actor, client) = cli_actor();
5455 let mut args = empty_create_args("specs", "Anchored");
5456 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
5457 let created = engine
5458 .create_entity(args, actor, Some(&client), None)
5459 .unwrap();
5460 let id = EntityId::new("specs", "anchored");
5461 assert_eq!(engine.entity_anchors(&id).len(), 2);
5462 (engine, tmp, id, created.content_hash)
5463 }
5464
5465 #[test]
5470 fn update_anchors_merge_appends_and_replaces_by_triple() {
5471 let (mut engine, _tmp, id, hash) = anchored_engine();
5472 let (actor, client) = cli_actor();
5473
5474 let mut args = anchor_args(id.clone(), Some(hash));
5476 args.anchors = vec![anchor_input("c.rs", "h-c")];
5477 let out = engine
5478 .update_entity(args, actor, Some(&client), None)
5479 .unwrap();
5480 let anchors = engine.entity_anchors(&id);
5481 assert_eq!(anchors.len(), 3, "N existing + 1 new = N+1");
5482 assert_eq!(anchors[0].artifact, "a.rs");
5483 assert_eq!(anchors[0].hash.as_deref(), Some("h-a"));
5484 assert_eq!(anchors[1].artifact, "b.rs");
5485 assert_eq!(anchors[2].artifact, "c.rs");
5486 assert!(!engine.anchors_referencing_artifact("a.rs").is_empty());
5487 assert!(!engine.anchors_referencing_artifact("c.rs").is_empty());
5488
5489 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5491 args.anchors = vec![anchor_input("a.rs", "h-a2")];
5492 engine
5493 .update_entity(args, actor, Some(&client), None)
5494 .unwrap();
5495 let anchors = engine.entity_anchors(&id);
5496 assert_eq!(anchors.len(), 3);
5497 assert_eq!(anchors[0].artifact, "a.rs");
5498 assert_eq!(anchors[0].hash.as_deref(), Some("h-a2"));
5499 assert_eq!(anchors[1].hash.as_deref(), Some("h-b"), "b untouched");
5500 assert_eq!(anchors[2].hash.as_deref(), Some("h-c"), "c untouched");
5501 }
5502
5503 #[test]
5507 fn update_anchors_full_resend_and_absent_are_noops_on_stored_set() {
5508 let (mut engine, tmp, id, hash) = anchored_engine();
5509 let (actor, client) = cli_actor();
5510 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
5511 let before = std::fs::read(&sidecar_path).unwrap();
5512
5513 let mut args = anchor_args(id.clone(), Some(hash));
5515 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
5516 let out = engine
5517 .update_entity(args, actor, Some(&client), None)
5518 .unwrap();
5519 assert_eq!(
5520 std::fs::read(&sidecar_path).unwrap(),
5521 before,
5522 "full re-send keeps the stored bytes"
5523 );
5524
5525 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5527 args.sections
5528 .insert("identity".to_string(), "changed body".to_string());
5529 engine
5530 .update_entity(args, actor, Some(&client), None)
5531 .unwrap();
5532 assert_eq!(
5533 std::fs::read(&sidecar_path).unwrap(),
5534 before,
5535 "an anchorless update never touches the stored set"
5536 );
5537 }
5538
5539 #[test]
5544 fn update_anchors_unset_bare_narrowed_idempotent_and_unset_first() {
5545 let (mut engine, _tmp, id, hash) = anchored_engine();
5546 let (actor, client) = cli_actor();
5547
5548 let mut span = anchor_input("a.rs", "h-span");
5550 span.grain = Some("span".to_string());
5551 let mut args = anchor_args(id.clone(), Some(hash));
5552 args.anchors = vec![span];
5553 let out = engine
5554 .update_entity(args, actor, Some(&client), None)
5555 .unwrap();
5556 assert_eq!(engine.entity_anchors(&id).len(), 3);
5557
5558 let mut narrowed = anchor_unset("a.rs");
5560 narrowed.grain = Some("span".to_string());
5561 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5562 args.anchors_unset = vec![narrowed];
5563 let out = engine
5564 .update_entity(args, actor, Some(&client), None)
5565 .unwrap();
5566 let anchors = engine.entity_anchors(&id);
5567 assert_eq!(anchors.len(), 2);
5568 assert!(
5569 anchors
5570 .iter()
5571 .all(|a| a.grain == crate::anchor::AnchorGrain::File)
5572 );
5573
5574 let mut args = anchor_args(id.clone(), Some(out.content_hash.clone()));
5576 args.anchors_unset = vec![anchor_unset("never-there.rs")];
5577 engine
5578 .update_entity(args, actor, Some(&client), None)
5579 .expect("unset of a nonexistent target is a no-op, not an error");
5580 assert_eq!(engine.entity_anchors(&id).len(), 2);
5581
5582 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5585 args.anchors_unset = vec![anchor_unset("a.rs")];
5586 args.anchors = vec![anchor_input("a.rs", "h-a-fresh")];
5587 engine
5588 .update_entity(args, actor, Some(&client), None)
5589 .unwrap();
5590 let anchors = engine.entity_anchors(&id);
5591 assert_eq!(anchors.len(), 2);
5592 assert_eq!(anchors[0].artifact, "b.rs", "b.rs untouched throughout");
5593 assert_eq!(anchors[1].hash.as_deref(), Some("h-a-fresh"));
5594 }
5595
5596 #[test]
5600 fn update_anchor_only_and_unset_only_commit_without_hash_movement() {
5601 let (mut engine, _tmp, id, hash) = anchored_engine();
5602 let (actor, client) = cli_actor();
5603
5604 let mut args = anchor_args(id.clone(), Some(hash.clone()));
5605 args.anchors_unset = vec![anchor_unset("b.rs")];
5606 let out = engine
5607 .update_entity(args, actor, Some(&client), None)
5608 .unwrap();
5609 assert!(
5610 !out.commit_sha.is_empty(),
5611 "unset-only update commits the sidecar"
5612 );
5613 assert_eq!(out.content_hash, hash, "anchors never move `_hash`");
5614 assert_eq!(engine.entity_anchors(&id).len(), 1);
5615
5616 let err = engine
5619 .update_entity(
5620 anchor_args(id.clone(), Some(hash)),
5621 actor,
5622 Some(&client),
5623 None,
5624 )
5625 .unwrap_err();
5626 assert!(matches!(err, EngineError::EmptyUpdate { .. }));
5627 }
5628
5629 #[test]
5637 fn anchor_only_update_across_second_boundary_never_moves_hash() {
5638 let (mut engine, _tmp, id, hash) = anchored_engine();
5639 let (actor, client) = cli_actor();
5640
5641 let t0 = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_778_243_696);
5642 engine.set_mutation_clock(std::sync::Arc::new(move || t0));
5643 let mut args = anchor_args(id.clone(), Some(hash));
5646 args.metadata = [("level".to_string(), "M1".to_string())]
5647 .into_iter()
5648 .collect();
5649 let restamped = engine
5650 .update_entity(args, actor, Some(&client), None)
5651 .unwrap();
5652
5653 let t1 = t0 + std::time::Duration::from_secs(1);
5655 engine.set_mutation_clock(std::sync::Arc::new(move || t1));
5656 let mut args = anchor_args(id.clone(), Some(restamped.content_hash.clone()));
5657 args.anchors = vec![anchor_input("c.rs", "h-c")];
5658 let out = engine
5659 .update_entity(args, actor, Some(&client), None)
5660 .unwrap();
5661 assert!(!out.commit_sha.is_empty(), "anchor-only update commits");
5662 assert_eq!(
5663 out.content_hash, restamped.content_hash,
5664 "anchors never move `_hash`, even across a second boundary"
5665 );
5666 let entity = engine.store().get(&id).unwrap();
5668 assert_eq!(
5669 entity
5670 .metadata
5671 .get("last_modified")
5672 .and_then(|v| v.as_str()),
5673 Some("2026-05-08T12:34:56Z"),
5674 "anchor-only update must not restamp last_modified"
5675 );
5676 }
5677
5678 #[test]
5682 fn malformed_anchor_unset_refuses_and_nothing_is_written() {
5683 let (mut engine, tmp, id, hash) = anchored_engine();
5684 let (actor, client) = cli_actor();
5685 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
5686 let before = std::fs::read(&sidecar_path).unwrap();
5687
5688 let mut bad = anchor_unset("a.rs");
5689 bad.grain = Some("paragraph".to_string()); let mut args = anchor_args(id.clone(), Some(hash));
5691 args.anchors_unset = vec![bad];
5692 args.anchors = vec![anchor_input("c.rs", "h-c")];
5694 let err = engine
5695 .update_entity(args, actor, Some(&client), None)
5696 .unwrap_err();
5697 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
5698 assert_eq!(engine.entity_anchors(&id).len(), 2, "no partial apply");
5699 assert_eq!(std::fs::read(&sidecar_path).unwrap(), before);
5700 }
5701
5702 fn bare_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5706 UpdateEntityArgs {
5707 anchors: Vec::new(),
5708 anchors_unset: Vec::new(),
5709 id,
5710 expected_hash: hash,
5711 sections: IndexMap::new(),
5712 append_sections: IndexMap::new(),
5713 patch_sections: IndexMap::new(),
5714 metadata: IndexMap::new(),
5715 metadata_unset: Vec::new(),
5716 declare_relations: Vec::new(),
5717 dry_run: false,
5718 relations_unset: Vec::new(),
5719 }
5720 }
5721
5722 #[test]
5730 fn reserved_key_unset_repairs_smuggled_entity_and_set_stays_refused() {
5731 let tmp = TempDir::new().unwrap();
5732 let mem_dir = tmp.path().to_path_buf();
5733 std::fs::write(
5735 mem_dir.join("smuggled.md"),
5736 "---\ntype: spec\nmem: wrong-mem\nid: bogus-id\n---\n# Smuggled\n\n## Identity\n\nsmuggled identity.\n\n## Purpose\n\nsmuggled purpose.\n",
5737 )
5738 .unwrap();
5739 let writer = FilesystemMemWriter::new(mem_dir.clone());
5740 let mut engine = Engine::from_mounts(vec![(
5741 folder_mount("specs", mem_dir.clone()),
5742 Box::new(writer) as Box<dyn MemBackend>,
5743 )])
5744 .unwrap();
5745 let (actor, client) = cli_actor();
5746 let id = EntityId::new("specs", "smuggled");
5747 let entity = engine.get_entity(&id).expect("fixture boots");
5748 assert!(
5749 entity.metadata.contains_key("mem") && entity.metadata.contains_key("id"),
5750 "fixture must carry the smuggled keys after boot"
5751 );
5752 let hash = entity.content_hash.clone();
5753
5754 for reserved in ["type", "mem", "id"] {
5756 let mut args = bare_args(id.clone(), Some(hash.clone()));
5757 args.metadata
5758 .insert(reserved.to_string(), "resmuggled".to_string());
5759 let err = engine
5760 .update_entity(args, actor, Some(&client), None)
5761 .expect_err("reserved-key set must refuse on update");
5762 assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
5763 }
5764 let mut batch_item = bare_args(id.clone(), Some(hash.clone()));
5767 batch_item
5768 .metadata
5769 .insert("id".to_string(), "resmuggled".to_string());
5770 let batch = engine
5771 .batch_update(vec![(batch_item, None)], actor, Some(&client), false)
5772 .expect("batch returns a result envelope");
5773 assert!(
5774 !batch.applied,
5775 "batch with a reserved-key set must not apply"
5776 );
5777 assert_eq!(batch.failed, 1);
5778
5779 let mut args = bare_args(id.clone(), Some(hash));
5781 args.metadata_unset = vec!["mem".to_string(), "id".to_string()];
5782 let out = engine
5783 .update_entity(args, actor, Some(&client), None)
5784 .expect("reserved-key unset is the sanctioned repair");
5785 assert!(!out.commit_sha.is_empty(), "repair is a real commit");
5786 assert_eq!(
5787 out.modified_metadata.unset,
5788 vec!["mem".to_string(), "id".to_string()]
5789 );
5790
5791 let entity = engine.get_entity(&id).expect("entity survives repair");
5794 assert!(
5795 !entity.metadata.contains_key("mem") && !entity.metadata.contains_key("id"),
5796 "smuggled keys must be gone from the store"
5797 );
5798 let on_disk = std::fs::read_to_string(mem_dir.join("smuggled.md")).unwrap();
5799 assert!(
5800 !on_disk.contains("wrong-mem") && !on_disk.contains("bogus-id"),
5801 "smuggled keys must be gone from the file: {on_disk}"
5802 );
5803 let mut args = bare_args(id.clone(), Some(entity.content_hash.clone()));
5804 args.sections
5805 .insert("identity".to_string(), "repaired identity".to_string());
5806 engine
5807 .update_entity(args, actor, Some(&client), None)
5808 .expect("post-repair entity round-trips cleanly");
5809 }
5810
5811 #[test]
5819 fn reserved_type_unset_reseeds_and_is_a_noop_on_healthy_entities() {
5820 let tmp = TempDir::new().unwrap();
5821 let mem_dir = tmp.path().to_path_buf();
5822 let writer = FilesystemMemWriter::new(mem_dir.clone());
5823 let mut engine = Engine::from_mounts(vec![(
5824 folder_mount("specs", mem_dir.clone()),
5825 Box::new(writer) as Box<dyn MemBackend>,
5826 )])
5827 .unwrap();
5828 let (actor, client) = cli_actor();
5829 let created = engine
5830 .create_entity(
5831 empty_create_args("specs", "Healthy"),
5832 actor,
5833 Some(&client),
5834 None,
5835 )
5836 .unwrap();
5837 let id = EntityId::new("specs", "healthy");
5838
5839 for key in ["type", "mem", "id"] {
5840 let mut args = bare_args(id.clone(), Some(created.content_hash.clone()));
5841 args.metadata_unset = vec![key.to_string()];
5842 let out = engine
5843 .update_entity(args, actor, Some(&client), None)
5844 .unwrap_or_else(|e| panic!("unset '{key}' on a healthy entity must no-op: {e:?}"));
5845 assert!(
5846 out.commit_sha.is_empty(),
5847 "unset '{key}' on a healthy entity is a no-op, not a commit"
5848 );
5849 assert!(
5850 out.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
5851 "no-op must carry the UPDATE_NOOP warning for '{key}'"
5852 );
5853 }
5854 let entity = engine.get_entity(&id).unwrap();
5855 assert_eq!(entity.entity_type, "spec");
5856 assert_eq!(
5857 entity.metadata.get("type").and_then(|v| v.as_str()),
5858 Some("spec"),
5859 "the discriminator survives a type unset"
5860 );
5861 }
5862
5863 #[test]
5872 fn declare_relations_refuses_cycle_and_self_loop_like_relate() {
5873 let tmp = TempDir::new().unwrap();
5874 let mem_dir = tmp.path().to_path_buf();
5875 let writer = FilesystemMemWriter::new(mem_dir.clone());
5876 let mut engine = Engine::from_mounts(vec![(
5877 folder_mount("specs", mem_dir),
5878 Box::new(writer) as Box<dyn MemBackend>,
5879 )])
5880 .unwrap();
5881 let (actor, client) = cli_actor();
5882
5883 let alpha = engine
5885 .create_entity(
5886 empty_create_args("specs", "Alpha"),
5887 actor,
5888 Some(&client),
5889 None,
5890 )
5891 .unwrap();
5892 let beta = engine
5893 .create_entity(
5894 empty_create_args("specs", "Beta"),
5895 actor,
5896 Some(&client),
5897 None,
5898 )
5899 .unwrap();
5900 engine
5901 .relate_entity(
5902 crate::engine::RelateEntityArgs {
5903 source: alpha.id.clone(),
5904 target: beta.id.clone(),
5905 rel_type: "PART_OF".to_string(),
5906 remove: false,
5907 expected_hash: None,
5908 description: None,
5909 dry_run: false,
5910 },
5911 actor,
5912 Some(&client),
5913 None,
5914 )
5915 .unwrap();
5916
5917 let declare = |rel_type: &str, from: &EntityId, to: &EntityId, hash: String| {
5918 let mut args = bare_args(from.clone(), Some(hash));
5919 args.declare_relations = vec![crate::ops::RelateArg {
5920 to: to.clone(),
5921 rel_type: rel_type.to_string(),
5922 description: None,
5923 }];
5924 args
5925 };
5926
5927 let err = engine
5929 .update_entity(
5930 declare("PART_OF", &beta.id, &alpha.id, beta.content_hash.clone()),
5931 actor,
5932 Some(&client),
5933 None,
5934 )
5935 .expect_err("cycle-closing declare_relations must refuse");
5936 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5937 let details = err.details();
5938 assert_eq!(details["rel_type"], "PART_OF");
5939 assert!(details["existing_path"].is_array());
5940 assert!(
5941 engine
5942 .get_entity(&beta.id)
5943 .unwrap()
5944 .relationships
5945 .is_empty(),
5946 "the refused edge must not land"
5947 );
5948
5949 let alpha_hash = engine.get_entity(&alpha.id).unwrap().content_hash.clone();
5952 let err = engine
5953 .update_entity(
5954 declare("USES", &alpha.id, &alpha.id, alpha_hash),
5955 actor,
5956 Some(&client),
5957 None,
5958 )
5959 .expect_err("self-loop declare_relations must refuse");
5960 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5961
5962 engine
5964 .update_entity(
5965 declare(
5966 "PART_OF",
5967 &beta.id,
5968 &EntityId::new("specs", "gamma"),
5969 beta.content_hash.clone(),
5970 ),
5971 actor,
5972 Some(&client),
5973 None,
5974 )
5975 .expect("a non-cycle PART_OF declare must land as today");
5976 }
5977}