1use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::EntityId;
8use crate::entity::parser::parse_markdown;
9use crate::entity::store_builder::push_entities_into_store;
10use crate::ops::{ModifiedMetadata, ModifiedSections, WarningHint};
11use crate::provenance::{Provenance, ProvenanceKind};
12use crate::runtime_validator::{
13 parse_metadata_value, validate_section_content, validate_section_keys,
14 validate_unsettable_metadata_key, validate_updatable_section, validate_writable_metadata_key,
15};
16use crate::vcs::{Actor, ClientId, CommitContext};
17use crate::workspace::MountCapability;
18
19use super::super::{Engine, EngineError, UpdateEntityArgs, UpdateEntityOutcome};
20use super::{
21 PATCH_OLD_NOT_FOUND_CONTENT_CAP, make_stub, unknown_type_error,
22 validate_relation_target_grammar,
23};
24use crate::engine::outcomes::RelationDeclared;
25use crate::entity::{Entity, Relationship};
26
27use std::sync::Arc;
28
29enum PrepareOutcome {
33 Done(UpdateEntityOutcome),
36 Prepared(PreparedUpdate),
39}
40
41struct PreparedUpdate {
45 mount_idx: usize,
46 id: EntityId,
47 mem: String,
48 type_def: Arc<memstead_schema::TypeDefinition>,
49 file_path: String,
50 markdown: String,
51 prev_body_targets: std::collections::HashSet<EntityId>,
54 modified_date: String,
55 modified_sections: ModifiedSections,
56 modified_metadata: ModifiedMetadata,
57 warnings: Vec<WarningHint>,
58 relations_declared: Vec<RelationDeclared>,
59 anchors: Vec<crate::anchor::Anchor>,
63 anchor_unsets: Vec<crate::anchor::AnchorUnset>,
67 anchor_only: bool,
78}
79
80struct AppliedWrite {
83 content_hash: String,
84 title: String,
85 orphan_stubs_removed: Vec<EntityId>,
86}
87
88impl Engine {
89 pub fn update_entity(
105 &mut self,
106 args: UpdateEntityArgs,
107 actor: Actor,
108 client: Option<&ClientId>,
109 note: Option<&str>,
110 ) -> Result<UpdateEntityOutcome, EngineError> {
111 let mut drift_warnings = self.reload_if_stale(Some(args.id.mem()));
118 if args.declare_relations.iter().any(|r| {
128 self.schemas.get(args.id.mem()).is_some_and(|s| {
129 s.relationship_acyclic(&r.rel_type)
130 || s.acyclic_set_containing(&r.rel_type).is_some()
131 }) || self
132 .schemas
133 .get(r.target.mem())
134 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
135 }) || self
136 .schemas
137 .get(args.id.mem())
138 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
139 {
140 self.ensure_mems_loaded(None);
141 }
142 let mut outcome = match self.prepare_update(args)? {
143 PrepareOutcome::Done(outcome) => outcome,
144 PrepareOutcome::Prepared(prepared) => {
145 self.commit_prepared_update(prepared, actor, client, note)?
146 }
147 };
148 drift_warnings.append(&mut outcome.warnings);
149 outcome.warnings = drift_warnings;
150 Ok(outcome)
151 }
152
153 fn commit_prepared_update(
158 &mut self,
159 prepared: PreparedUpdate,
160 actor: Actor,
161 client: Option<&ClientId>,
162 note: Option<&str>,
163 ) -> Result<UpdateEntityOutcome, EngineError> {
164 let signal_snapshot = {
173 let mut candidates: Vec<EntityId> = vec![prepared.id.clone()];
174 candidates.extend(
175 self.store
176 .outgoing(&prepared.id)
177 .iter()
178 .map(|e| e.target.clone()),
179 );
180 candidates.extend(
181 self.store
182 .incoming(&prepared.id)
183 .iter()
184 .map(|e| e.from.clone()),
185 );
186 if let Ok(parsed) = parse_markdown(
187 &prepared.markdown,
188 &prepared.file_path,
189 prepared.type_def.as_ref(),
190 &prepared.mem,
191 ) {
192 candidates.extend(parsed.entity.relationships.iter().map(|r| r.target.clone()));
193 }
194 crate::ops::signals::snapshot_levels(&self.store, &self.schemas, candidates.iter())
195 };
196 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
197 backend.write_entity(Path::new(&prepared.file_path), prepared.markdown.as_bytes())?;
198 if !prepared.anchors.is_empty() || !prepared.anchor_unsets.is_empty() {
201 super::stage_anchors_sidecar(
202 backend,
203 &prepared.id,
204 &prepared.anchor_unsets,
205 prepared.anchors.clone(),
206 )?;
207 }
208 if let Some(schema) = self.schemas.get(prepared.id.mem()) {
212 for r in prepared
213 .relations_declared
214 .iter()
215 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
216 {
217 let hash = self
218 .store
219 .get(&r.target)
220 .map(|e| e.content_hash.clone())
221 .unwrap_or_default();
222 let (from, rel, to) = (
223 prepared.id.to_string(),
224 r.rel_type.clone(),
225 r.target.to_string(),
226 );
227 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
228 }
229 }
230 let commit_subject = if prepared.anchor_only {
236 format!("memstead: anchor {}", prepared.id)
237 } else {
238 format!("memstead: update {}", prepared.id)
239 };
240 let ctx = CommitContext {
241 actor,
242 client: client.cloned(),
243 tool: Some("update_entity"),
244 note: note.map(String::from),
245 role: self.current_role,
246 identity: self.current_identity.clone(),
247 logical_operation_id: None,
248 entity_ids: None,
249 };
250 let write_id = 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 .with_identity(self.current_identity.clone()),
262 )?;
263 self.record_self_write(prepared.mount_idx, &write_id);
264 let stamp_warnings = self.stamp_mutation_versions(prepared.mount_idx);
265
266 let applied = self.apply_prepared_to_store(&prepared)?;
267
268 self.invalidate_communities();
269 self.maintain_search_indexes(std::slice::from_ref(&prepared.id));
273
274 let mut warnings = prepared.warnings;
278 warnings.extend(stamp_warnings);
279 warnings.extend(crate::ops::signals::crossing_warnings(
282 &self.store,
283 &self.schemas,
284 &signal_snapshot,
285 ));
286 if let Some(w) = self.note_missing_warning("update_entity", note) {
287 warnings.push(w);
288 }
289
290 Ok(UpdateEntityOutcome {
291 id: prepared.id.clone(),
292 title: applied.title,
293 file_path: prepared.file_path,
294 content_hash: applied.content_hash,
295 write_id,
296 modified_date: prepared.modified_date,
297 orphan_stubs_removed: applied.orphan_stubs_removed,
298 modified_sections: prepared.modified_sections,
299 modified_metadata: prepared.modified_metadata,
300 prospective_hash: None,
301 warnings,
302 relations_declared: prepared.relations_declared,
303 })
304 }
305
306 fn apply_prepared_to_store(
313 &mut self,
314 prepared: &PreparedUpdate,
315 ) -> Result<AppliedWrite, EngineError> {
316 let parse_result = parse_markdown(
317 &prepared.markdown,
318 &prepared.file_path,
319 prepared.type_def.as_ref(),
320 &prepared.mem,
321 )
322 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
323 let content_hash = parse_result.entity.content_hash.clone();
324 let title = parse_result.entity.title.clone();
325 let fallback = engine_fallback_type();
326 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
327 crate::entity::store_builder::remap_alias_target_edge_sources(
328 &mut self.store,
329 &self.schemas,
330 );
331 let orphan_stubs_removed =
332 super::gc_orphan_stubs_among(&mut self.store, &prepared.prev_body_targets);
333 Ok(AppliedWrite {
334 content_hash,
335 title,
336 orphan_stubs_removed,
337 })
338 }
339
340 fn prepare_update(&mut self, args: UpdateEntityArgs) -> Result<PrepareOutcome, EngineError> {
348 let id = &args.id;
349 let mem = id.mem().to_string();
350
351 let mount_idx = self
352 .mounts
353 .iter()
354 .position(|m| m.mount.mem == mem)
355 .ok_or_else(|| self.unknown_mem_error(&mem))?;
356 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
357 return Err(EngineError::ReadOnlyMount(mem));
358 }
359
360 let entity = self
361 .store
362 .get(id)
363 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
364
365 let prev_body_targets = super::collect_body_link_targets(entity);
371
372 if entity.stub {
380 return Err(EngineError::StubNotUpdatable { id: id.to_string() });
381 }
382
383 if !args.dry_run
389 && let Some(expected) = args.expected_hash.as_deref()
390 && entity.content_hash != expected
391 {
392 return Err(EngineError::HashMismatch {
393 id: id.to_string(),
394 current: entity.content_hash.clone(),
395 is_stub: entity.stub,
396 });
397 }
398
399 if args.sections.is_empty()
411 && args.append_sections.is_empty()
412 && args.patch_sections.is_empty()
413 && args.sections_unset.is_empty()
414 && args.metadata.is_empty()
415 && args.metadata_unset.is_empty()
416 && args.declare_relations.is_empty()
417 && args.relations_unset.is_empty()
418 && args.anchors.is_empty()
419 && args.anchors_unset.is_empty()
420 {
421 return Err(EngineError::EmptyUpdate { id: id.to_string() });
422 }
423
424 let validated_anchors = self.validate_anchor_inputs(&mem, &args.anchors)?;
430 let validated_anchor_unsets = Self::validate_anchor_unsets(&args.anchors_unset)?;
431
432 let schema = self
433 .schemas
434 .get(&mem)
435 .expect("schema present for every registered mount")
436 .clone();
437 let type_def = schema
438 .get_type(&entity.entity_type)
439 .ok_or_else(|| unknown_type_error(schema.as_ref(), &entity.entity_type))?;
440
441 for key in args.sections.keys() {
448 let mut modes = vec!["sections".to_string()];
449 if args.append_sections.contains_key(key) {
450 modes.push("append_sections".to_string());
451 }
452 if args.patch_sections.contains_key(key) {
453 modes.push("patch_sections".to_string());
454 }
455 if modes.len() > 1 {
456 return Err(EngineError::ConflictingSectionModes {
457 section: key.clone(),
458 modes,
459 });
460 }
461 }
462 for key in args.append_sections.keys() {
463 if args.patch_sections.contains_key(key) {
464 return Err(EngineError::ConflictingSectionModes {
465 section: key.clone(),
466 modes: vec!["append_sections".to_string(), "patch_sections".to_string()],
467 });
468 }
469 }
470 for key in &args.sections_unset {
473 let mut modes = vec!["sections_unset".to_string()];
474 if args.sections.contains_key(key) {
475 modes.push("sections".to_string());
476 }
477 if args.append_sections.contains_key(key) {
478 modes.push("append_sections".to_string());
479 }
480 if args.patch_sections.contains_key(key) {
481 modes.push("patch_sections".to_string());
482 }
483 if modes.len() > 1 {
484 return Err(EngineError::ConflictingSectionModes {
485 section: key.clone(),
486 modes,
487 });
488 }
489 }
490 let unset_required: Vec<crate::runtime_validator::MissingRequiredSection> = type_def
495 .required_sections()
496 .filter(|sec| args.sections_unset.contains(&sec.key))
497 .map(|sec| crate::runtime_validator::MissingRequiredSection {
498 entity_type: type_def.name.clone(),
499 key: sec.key.clone(),
500 heading: sec.heading.clone(),
501 write_rules: sec.write_rules.clone(),
502 })
503 .collect();
504 if !unset_required.is_empty() {
505 let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> =
506 std::collections::BTreeMap::new();
507 type_guidance.insert(type_def.name.clone(), type_def.write_rules.clone());
508 return Err(EngineError::MissingRequiredSection {
509 entity_type: type_def.name.clone(),
510 missing_count: unset_required.len(),
511 sections: unset_required,
512 type_guidance,
513 pre_announced_missing_fields: Vec::new(),
514 });
515 }
516 for key in &args.sections_unset {
522 if entity.sections.contains_key(key) || key == "relationships" {
523 validate_updatable_section(key.as_str(), type_def.as_ref())?;
524 }
525 }
526
527 validate_section_keys(
528 args.sections
529 .keys()
530 .chain(args.append_sections.keys())
531 .chain(args.patch_sections.keys())
532 .map(String::as_str),
533 type_def.as_ref(),
534 )?;
535 let mut heading_buf: Vec<&str> = Vec::new();
536 #[allow(unused_assignments)]
537 let mut catch_all = None;
538 validate_section_content(
544 args.sections
545 .iter()
546 .map(|(k, v)| (k.as_str(), v.as_str()))
547 .chain(
548 args.append_sections
549 .iter()
550 .map(|(k, v)| (k.as_str(), v.as_str())),
551 )
552 .chain(
553 args.patch_sections
554 .iter()
555 .flat_map(|(k, ps)| ps.iter().map(move |p| (k.as_str(), p.new.as_str()))),
556 ),
557 {
558 let t: &memstead_schema::TypeDefinition = type_def.as_ref();
559 catch_all = crate::runtime_validator::catch_all_context(t, &mut heading_buf);
560 catch_all
561 },
562 )?;
563 for key in args.sections.keys() {
564 validate_updatable_section(key.as_str(), type_def.as_ref())?;
565 }
566 for key in args.append_sections.keys() {
567 validate_updatable_section(key.as_str(), type_def.as_ref())?;
568 }
569 for key in args.patch_sections.keys() {
570 validate_updatable_section(key.as_str(), type_def.as_ref())?;
571 }
572 for key in args.metadata.keys() {
573 validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
574 }
575 for key in &args.metadata_unset {
581 validate_unsettable_metadata_key(key.as_str(), type_def.as_ref())?;
582 }
583
584 let mut overlap: Vec<String> = args
591 .metadata
592 .keys()
593 .filter(|k| args.metadata_unset.iter().any(|u| u == k.as_str()))
594 .cloned()
595 .collect();
596 if !overlap.is_empty() {
597 overlap.sort();
598 overlap.dedup();
599 return Err(EngineError::SetAndUnsetConflict { keys: overlap });
600 }
601
602 if !args.relations_unset.is_empty() {
611 let findings = crate::ops::integrity::entity_conformance_findings(
612 &self.store,
613 entity,
614 schema.as_ref(),
615 &self.schemas,
616 );
617 if findings.is_empty() {
618 return Err(EngineError::RepairNotNeeded {
619 id: id.to_string(),
620 recovery: "use memstead_relate(remove=true) to detach an edge from a conformant entity, or the additive memstead_update params to evolve it"
621 .to_string(),
622 });
623 }
624 }
625
626 let mut next = entity.clone();
627
628 for unset in &args.relations_unset {
635 let canonical = crate::entity::id::validate_rel_type(&unset.rel_type)
636 .unwrap_or_else(|_| unset.rel_type.clone());
637 next.relationships
638 .retain(|r| !(r.rel_type == canonical && r.target == unset.target));
639 }
640
641 let relations_declared = apply_declare_relations(
651 self,
652 &mut next,
653 &args.declare_relations,
654 &mem,
655 mount_idx,
656 type_def.as_ref(),
657 schema.as_ref(),
658 )?;
659
660 let format_touched: std::collections::HashSet<String> = args
664 .sections
665 .keys()
666 .chain(args.append_sections.keys())
667 .chain(args.patch_sections.keys())
668 .cloned()
669 .collect();
670
671 let mut modified_sections: Vec<String> = Vec::new();
672 for (key, body) in args.sections {
673 modified_sections.push(key.clone());
674 next.sections.insert(key, body);
675 }
676
677 let mut modified_sections_appended: Vec<String> = Vec::new();
681 for (key, value) in args.append_sections {
682 let existing = next.sections.get(&key).cloned().unwrap_or_default();
683 let new_content = if existing.trim().is_empty() {
684 value
685 } else {
686 format!("{existing}\n{value}")
687 };
688 next.sections.insert(key.clone(), new_content);
689 modified_sections_appended.push(key);
690 }
691
692 let mut modified_sections_patched: Vec<String> = Vec::new();
700 for (key, patches) in args.patch_sections {
701 for patch in patches {
706 let existing = next
707 .sections
708 .get(&key)
709 .ok_or_else(|| EngineError::PatchSectionEmpty {
710 section: key.clone(),
711 })?
712 .clone();
713 if !existing.contains(&patch.old) {
714 let cap = PATCH_OLD_NOT_FOUND_CONTENT_CAP;
715 let truncated = existing.len() > cap;
716 let mut cut = cap.min(existing.len());
719 while cut > 0 && !existing.is_char_boundary(cut) {
720 cut -= 1;
721 }
722 let current_content = if truncated {
723 existing[..cut].to_string()
724 } else {
725 existing.clone()
726 };
727 let found_in_sections: Vec<String> = next
731 .sections
732 .iter()
733 .filter(|(k, body)| k.as_str() != key && body.contains(&patch.old))
734 .map(|(k, _)| k.clone())
735 .collect();
736 return Err(EngineError::PatchOldNotFound {
737 section: key,
738 current_content,
739 truncated,
740 found_in_sections,
741 });
742 }
743 let patched = if patch.all {
744 existing.replace(&patch.old, &patch.new)
745 } else {
746 existing.replacen(&patch.old, &patch.new, 1)
747 };
748 next.sections.insert(key.clone(), patched);
749 }
750 modified_sections_patched.push(key);
751 }
752
753 let mut modified_sections_unset: Vec<String> = Vec::new();
758 for key in &args.sections_unset {
759 if next.sections.shift_remove(key).is_some() {
760 modified_sections_unset.push(key.clone());
761 }
762 }
763
764 let mut modified_metadata_set: Vec<String> = Vec::new();
765 for (key, value) in &args.metadata {
766 let parsed = parse_metadata_value(key.as_str(), value.as_str(), type_def.as_ref())?;
767 modified_metadata_set.push(key.clone());
768 next.metadata.insert(key.clone(), parsed);
769 }
770
771 let mut modified_metadata_unset: Vec<String> = Vec::new();
772 for key in args.metadata_unset {
773 if crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str()) {
785 if key == "type" {
786 let authoritative =
787 crate::entity::MetadataValue::String(next.entity_type.clone());
788 if next
789 .metadata
790 .shift_remove("type")
791 .is_some_and(|removed| removed != authoritative)
792 {
793 modified_metadata_unset.push(key);
794 }
795 next.metadata.insert("type".to_string(), authoritative);
796 } else if next.metadata.shift_remove(&key).is_some() {
797 modified_metadata_unset.push(key);
798 }
799 continue;
800 }
801 let field_def = type_def.metadata_field(&key);
806 let is_required = field_def.map(|f| f.is_required()).unwrap_or(false);
807 if is_required {
808 let (field_description, enum_values) = match field_def {
809 Some(f) => (
810 Some(f.description.clone()),
811 f.enum_values.clone().unwrap_or_default(),
812 ),
813 None => (None, Vec::new()),
814 };
815 return Err(EngineError::RequiredFieldUnset {
816 field: key,
817 entity_type: type_def.name.clone(),
818 field_description,
819 enum_values,
820 type_write_rules: type_def.write_rules.clone(),
821 on_create: false,
827 missing: Vec::new(),
832 });
833 }
834 if next.metadata.shift_remove(&key).is_some() {
835 modified_metadata_unset.push(key);
836 }
837 }
838
839 let today = self.now_iso();
848
849 let alias_outcome = super::synthesise_alias_relations(self, &prev_body_targets, &mut next)?;
860 let synthesised_relations = alias_outcome.emitted;
861 let self_link_ignored = alias_outcome.self_link_ignored;
862 let undeclared_targets: std::collections::HashSet<crate::entity::EntityId> = alias_outcome
863 .undeclared_dropped
864 .iter()
865 .map(|d| d.target.clone())
866 .collect();
867 let undeclared_dropped = alias_outcome.undeclared_dropped;
868
869 let missing = super::scan_wikilinks_without_relation(&next, &undeclared_targets)?;
875 if !missing.is_empty() {
876 return Err(EngineError::WikiLinkWithoutRelation {
877 from_id: id.to_string(),
878 missing: missing
879 .into_iter()
880 .map(|(section_key, target)| crate::engine::MissingWikiLink {
881 section_key,
882 target_id: target.to_string(),
883 })
884 .collect(),
885 });
886 }
887
888 let file_path = next.file_path.clone();
889
890 let markdown_pre_stamp = super::render_for_write(&next, type_def.as_ref())?;
899
900 let content_unchanged =
911 crate::entity::parser::compute_hash(&markdown_pre_stamp) == next.content_hash;
912
913 if !args.dry_run {
928 if content_unchanged
933 && validated_anchors.is_empty()
934 && validated_anchor_unsets.is_empty()
935 {
936 let modified_date = next
941 .metadata
942 .get("last_modified")
943 .and_then(|v| v.as_str().map(str::to_string))
944 .unwrap_or_default();
945 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
946 id: id.clone(),
947 title: next.title.clone(),
948 file_path,
949 content_hash: next.content_hash.clone(),
950 write_id: String::new(),
951 modified_date,
952 modified_sections: ModifiedSections::default(),
961 modified_metadata: ModifiedMetadata::default(),
962 prospective_hash: None,
963 orphan_stubs_removed: Vec::new(),
966 warnings: vec![WarningHint::UpdateNoop { id: id.clone() }],
967 relations_declared,
968 }));
969 }
970 }
971
972 if !content_unchanged {
982 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
983 }
984 let markdown = super::render_for_write(&next, type_def.as_ref())?;
985
986 let mut warnings: Vec<WarningHint> = Vec::new();
987
988 for key in modified_sections
997 .iter()
998 .chain(modified_sections_appended.iter())
999 .chain(modified_sections_patched.iter())
1000 {
1001 let Some(def) = type_def.section(key) else {
1002 continue;
1003 };
1004 if let Some(existing) = next.raw_section_headings.iter().find(|h| {
1005 h.as_str() != def.heading && memstead_schema::derive_section_key(h) == *key
1006 }) {
1007 warnings.push(WarningHint::SectionHeadingDivergence {
1008 entity_id: id.clone(),
1009 section_key: key.clone(),
1010 writing_heading: def.heading.clone(),
1011 existing_heading: existing.clone(),
1012 });
1013 }
1014 }
1015
1016 for def in &type_def.sections {
1030 if def.format_severity != memstead_schema::ConstraintSeverity::Block {
1031 continue;
1032 }
1033 if !format_touched.contains(def.key.as_str()) {
1034 continue;
1035 }
1036 let Some(body) = next.sections.get(def.key.as_str()) else {
1037 continue;
1038 };
1039 if let Some(first) = crate::section_format::check_section_format(def, body)
1040 .into_iter()
1041 .next()
1042 {
1043 return Err(EngineError::SectionFormatRefused {
1044 entity_type: next.entity_type.clone(),
1045 entity_id: id.to_string(),
1046 violation: first,
1047 });
1048 }
1049 }
1050
1051 let unsatisfied =
1052 crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref());
1053 if !unsatisfied.is_empty() {
1054 let blocked: Vec<_> = unsatisfied
1058 .iter()
1059 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
1060 .cloned()
1061 .collect();
1062 if !blocked.is_empty() {
1063 return Err(EngineError::RequiredOutgoingUnsatisfied {
1064 entity_type: next.entity_type.clone(),
1065 entity_id: id.to_string(),
1066 missing: blocked,
1067 });
1068 }
1069 warnings.push(WarningHint::MissingRequiredOutgoing {
1070 entity_type: next.entity_type.clone(),
1071 entity_id: id.clone(),
1072 missing: unsatisfied,
1073 });
1074 }
1075
1076 let check_provider = self.check_state_provider();
1080 let violated = crate::ops::health::unsatisfied_constraints(
1081 &self.store,
1082 &next,
1083 type_def.as_ref(),
1084 Some(id),
1085 Some(&check_provider),
1086 );
1087 if !violated.is_empty() {
1088 let blocked: Vec<_> = violated
1089 .iter()
1090 .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
1091 .cloned()
1092 .collect();
1093 if !blocked.is_empty() {
1094 return Err(EngineError::ConstraintUnsatisfied {
1095 entity_type: next.entity_type.clone(),
1096 entity_id: id.to_string(),
1097 violations: blocked,
1098 });
1099 }
1100 warnings.push(WarningHint::ConstraintUnsatisfied {
1101 entity_type: next.entity_type.clone(),
1102 entity_id: id.clone(),
1103 violations: violated,
1104 });
1105 }
1106
1107 let auto_stubbed: Vec<EntityId> = synthesised_relations
1115 .iter()
1116 .filter_map(|rel| {
1117 if !self.store.contains(&rel.target) {
1118 Some(rel.target.clone())
1119 } else {
1120 None
1121 }
1122 })
1123 .collect();
1124 if !auto_stubbed.is_empty() {
1125 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
1126 from: id.clone(),
1127 stubs: auto_stubbed,
1128 });
1129 }
1130 if self_link_ignored {
1133 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
1134 }
1135 for dropped in undeclared_dropped {
1139 warnings.push(WarningHint::CrossSchemaLinkUndeclared {
1140 from: id.clone(),
1141 target: dropped.target,
1142 source_schema: dropped.source_schema,
1143 target_schema: dropped.target_schema,
1144 });
1145 }
1146
1147 if args.dry_run {
1154 let prospective = crate::entity::parser::compute_hash(&markdown);
1155 let current_hash = next.content_hash.clone();
1159 let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1160 today.clone()
1161 } else {
1162 String::new()
1163 };
1164 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
1165 id: id.clone(),
1166 title: next.title.clone(),
1167 file_path,
1168 content_hash: current_hash,
1169 write_id: String::new(),
1170 modified_date,
1171 modified_sections: ModifiedSections {
1172 replaced: modified_sections,
1173 appended: modified_sections_appended,
1174 patched: modified_sections_patched,
1175 unset: modified_sections_unset,
1176 },
1177 modified_metadata: ModifiedMetadata {
1178 set: modified_metadata_set,
1179 unset: modified_metadata_unset,
1180 },
1181 prospective_hash: Some(prospective),
1182 orphan_stubs_removed: Vec::new(),
1185 warnings,
1186 relations_declared: relations_declared.clone(),
1187 }));
1188 }
1189
1190 let modified_date = if content_unchanged {
1197 next.metadata
1200 .get("last_modified")
1201 .and_then(|v| v.as_str().map(str::to_string))
1202 .unwrap_or_default()
1203 } else if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1204 today.clone()
1205 } else {
1206 String::new()
1207 };
1208
1209 Ok(PrepareOutcome::Prepared(PreparedUpdate {
1210 mount_idx,
1211 id: id.clone(),
1212 mem,
1213 type_def,
1214 file_path,
1215 markdown,
1216 prev_body_targets,
1217 modified_date,
1218 modified_sections: ModifiedSections {
1219 replaced: modified_sections,
1220 appended: modified_sections_appended,
1221 patched: modified_sections_patched,
1222 unset: modified_sections_unset,
1223 },
1224 modified_metadata: ModifiedMetadata {
1225 set: modified_metadata_set,
1226 unset: modified_metadata_unset,
1227 },
1228 warnings,
1231 relations_declared,
1232 anchor_only: content_unchanged
1240 && (!validated_anchors.is_empty() || !validated_anchor_unsets.is_empty()),
1241 anchors: validated_anchors,
1242 anchor_unsets: validated_anchor_unsets,
1243 }))
1244 }
1245
1246 pub fn batch_update(
1288 &mut self,
1289 updates: Vec<(UpdateEntityArgs, Option<String>)>,
1290 actor: Actor,
1291 client: Option<&ClientId>,
1292 dry_run: bool,
1293 ) -> Result<crate::ops::BatchResult, EngineError> {
1294 if updates.is_empty() {
1295 return Ok(crate::ops::BatchResult {
1296 warnings: Vec::new(),
1297 orphan_stubs_removed: Vec::new(),
1298 errors_suppressed: 0,
1299 applied: true,
1300 results: Vec::new(),
1301 succeeded: 0,
1302 failed: 0,
1303 write_id: String::new(),
1304 });
1305 }
1306
1307 let mut touched_mems: Vec<String> = updates
1314 .iter()
1315 .map(|(a, _)| a.id.mem().to_string())
1316 .collect();
1317 touched_mems.sort();
1318 touched_mems.dedup();
1319 for v in &touched_mems {
1320 self.reload_if_stale(Some(v));
1321 }
1322 if updates.iter().any(|(a, _)| {
1329 a.declare_relations.iter().any(|r| {
1330 self.schemas.get(a.id.mem()).is_some_and(|s| {
1331 s.relationship_acyclic(&r.rel_type)
1332 || s.acyclic_set_containing(&r.rel_type).is_some()
1333 }) || self
1334 .schemas
1335 .get(r.target.mem())
1336 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1337 }) || self
1338 .schemas
1339 .get(a.id.mem())
1340 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1341 }) {
1342 self.ensure_mems_loaded(None);
1343 }
1344
1345 let store_snapshot = self.store.clone();
1351
1352 enum Item {
1358 Prepared,
1359 Noop,
1360 Error,
1361 }
1362 let mut items: Vec<(EntityId, Item)> = Vec::with_capacity(updates.len());
1363 let mut prepared: Vec<PreparedUpdate> = Vec::new();
1364 let mut notes: Vec<Option<String>> = Vec::new();
1365 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1366
1367 for (i, (args, note)) in updates.into_iter().enumerate() {
1372 let id = args.id.clone();
1373 let mut args = args;
1378 args.dry_run = false;
1379 match self.prepare_update(args) {
1380 Ok(PrepareOutcome::Done(_)) => {
1381 items.push((id, Item::Noop));
1383 }
1384 Ok(PrepareOutcome::Prepared(p)) => {
1385 prepared.push(p);
1386 notes.push(note);
1387 items.push((id, Item::Prepared));
1388 }
1389 Err(e) => {
1390 items.push((id, Item::Error));
1391 errors.push((i, e));
1392 }
1393 }
1394 }
1395
1396 if !errors.is_empty() {
1397 self.store = store_snapshot;
1402 self.discard_all_pending();
1403 let failed = errors.len();
1404 let mut error_map: std::collections::HashMap<usize, EngineError> =
1405 errors.into_iter().collect();
1406 let mut reported = 0usize;
1407 let mut suppressed = 0usize;
1408 let results: Vec<crate::ops::BatchEntry> = items
1409 .into_iter()
1410 .enumerate()
1411 .map(|(i, (id, _))| match error_map.remove(&i) {
1412 Some(e) => {
1413 if reported < Self::BATCH_ERROR_REPORT_CAP {
1414 reported += 1;
1415 crate::ops::BatchEntry {
1416 id,
1417 action: "error".to_string(),
1418 error: Some(batch_error_envelope(&e)),
1419 }
1420 } else {
1421 suppressed += 1;
1422 crate::ops::BatchEntry {
1423 id,
1424 action: "error".to_string(),
1425 error: None,
1426 }
1427 }
1428 }
1429 None => crate::ops::BatchEntry {
1430 id,
1431 action: "not_applied".to_string(),
1432 error: None,
1433 },
1434 })
1435 .collect();
1436 return Ok(crate::ops::BatchResult {
1437 warnings: Vec::new(),
1438 orphan_stubs_removed: Vec::new(),
1439 errors_suppressed: suppressed,
1440 applied: false,
1441 results,
1442 succeeded: 0,
1443 failed,
1444 write_id: String::new(),
1445 });
1446 }
1447
1448 if dry_run {
1454 self.store = store_snapshot;
1455 self.discard_all_pending();
1456 let succeeded = items.len();
1457 let results: Vec<crate::ops::BatchEntry> = items
1458 .into_iter()
1459 .map(|(id, item)| crate::ops::BatchEntry {
1460 id,
1461 action: match item {
1462 Item::Prepared => "updated".to_string(),
1463 Item::Noop => "noop".to_string(),
1464 Item::Error => unreachable!("refusal path returned above"),
1465 },
1466 error: None,
1467 })
1468 .collect();
1469 return Ok(crate::ops::BatchResult {
1470 warnings: Vec::new(),
1471 orphan_stubs_removed: Vec::new(),
1472 errors_suppressed: 0,
1473 applied: true,
1474 results,
1475 succeeded,
1476 failed: 0,
1477 write_id: String::new(),
1478 });
1479 }
1480
1481 for p in &prepared {
1484 if let Err(e) = self.mounts[p.mount_idx]
1485 .backend
1486 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1487 {
1488 self.store = store_snapshot;
1489 self.discard_all_pending();
1490 return Err(e.into());
1491 }
1492 if (!p.anchors.is_empty() || !p.anchor_unsets.is_empty())
1495 && let Err(e) = super::stage_anchors_sidecar(
1496 self.mounts[p.mount_idx].backend.as_ref(),
1497 &p.id,
1498 &p.anchor_unsets,
1499 p.anchors.clone(),
1500 )
1501 {
1502 self.store = store_snapshot;
1503 self.discard_all_pending();
1504 return Err(e);
1505 }
1506 if let Some(schema) = self.schemas.get(p.id.mem()) {
1509 for r in p
1510 .relations_declared
1511 .iter()
1512 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1513 {
1514 let hash = self
1515 .store
1516 .get(&r.target)
1517 .map(|e| e.content_hash.clone())
1518 .unwrap_or_default();
1519 let (from, rel, to) =
1520 (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1521 if let Err(e) = super::stage_derivation_sidecar(
1522 self.mounts[p.mount_idx].backend.as_ref(),
1523 |s| s.set(&from, &rel, &to, &hash),
1524 ) {
1525 self.store = store_snapshot;
1526 self.discard_all_pending();
1527 return Err(e);
1528 }
1529 }
1530 }
1531 }
1532
1533 let mut distinct_mounts: Vec<usize> = Vec::new();
1535 for p in &prepared {
1536 if !distinct_mounts.contains(&p.mount_idx) {
1537 distinct_mounts.push(p.mount_idx);
1538 }
1539 }
1540 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1541 for &m in &distinct_mounts {
1542 let entity_ids: Vec<String> = prepared
1543 .iter()
1544 .filter(|p| p.mount_idx == m)
1545 .map(|p| p.id.to_string())
1546 .collect();
1547 let count = entity_ids.len();
1548 let subject = format!("memstead: batch-update ({count} entities)");
1549 let note_lines: Vec<String> = prepared
1554 .iter()
1555 .zip(notes.iter())
1556 .filter(|(p, _)| p.mount_idx == m)
1557 .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.id)))
1558 .collect();
1559 let ctx = CommitContext {
1560 actor,
1561 client: client.cloned(),
1562 tool: Some("batch_update"),
1563 note: if note_lines.is_empty() {
1564 None
1565 } else {
1566 Some(note_lines.join("\n"))
1567 },
1568 role: self.current_role,
1569 identity: self.current_identity.clone(),
1570 logical_operation_id: None,
1571 entity_ids: Some(entity_ids),
1575 };
1576 match self.mounts[m].backend.commit(&subject, &ctx) {
1577 Ok(sha) => mount_commits.push((m, sha)),
1578 Err(e) => {
1579 self.store = store_snapshot;
1583 self.discard_all_pending();
1584 return Err(e.into());
1585 }
1586 }
1587 }
1588
1589 let mut batch_warnings: Vec<WarningHint> = Vec::new();
1593 for (p, note) in prepared.iter().zip(notes.iter()) {
1594 let write_id = mount_commits
1595 .iter()
1596 .find(|(m, _)| *m == p.mount_idx)
1597 .map(|(_, s)| s.clone())
1598 .unwrap_or_default();
1599 self.mounts[p.mount_idx].backend.append_provenance(
1600 &Provenance::new(
1601 std::time::SystemTime::now(),
1602 ProvenanceKind::Update,
1603 Some(p.id.to_string()),
1604 actor,
1605 client.cloned(),
1606 note.clone(),
1607 )
1608 .with_role(self.current_role)
1609 .with_identity(self.current_identity.clone()),
1610 )?;
1611 self.record_self_write(p.mount_idx, &write_id);
1612 batch_warnings.extend(self.stamp_mutation_versions(p.mount_idx));
1613 self.apply_prepared_to_store(p)?;
1614 }
1615
1616 self.invalidate_communities();
1617 self.invalidate_search_indexes();
1618
1619 let write_id = mount_commits
1622 .last()
1623 .map(|(_, s)| s.clone())
1624 .unwrap_or_default();
1625 let succeeded = items.len();
1626 let results: Vec<crate::ops::BatchEntry> = items
1627 .into_iter()
1628 .map(|(id, item)| crate::ops::BatchEntry {
1629 id,
1630 action: match item {
1631 Item::Prepared => "updated".to_string(),
1632 Item::Noop => "noop".to_string(),
1633 Item::Error => unreachable!("refusal path returned above"),
1634 },
1635 error: None,
1636 })
1637 .collect();
1638
1639 Ok(crate::ops::BatchResult {
1640 warnings: batch_warnings,
1641 orphan_stubs_removed: Vec::new(),
1642 errors_suppressed: 0,
1643 applied: true,
1644 results,
1645 succeeded,
1646 failed: 0,
1647 write_id,
1648 })
1649 }
1650
1651 pub(super) fn discard_all_pending(&self) {
1656 for mount in &self.mounts {
1657 let _ = mount.backend.discard_pending();
1658 }
1659 }
1660
1661 pub fn update_entity_with_ctx(
1664 &mut self,
1665 args: UpdateEntityArgs,
1666 ctx: &CommitContext<'_>,
1667 ) -> Result<UpdateEntityOutcome, EngineError> {
1668 self.update_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1669 }
1670}
1671
1672pub(super) fn batch_error_envelope(err: &EngineError) -> crate::ops::BatchError {
1679 let code = err.code().to_string();
1685 let message = err.to_string();
1686 let details = err.details();
1687 crate::ops::BatchError {
1688 code,
1689 message,
1690 details,
1691 }
1692}
1693
1694fn apply_declare_relations(
1709 engine: &mut Engine,
1710 next: &mut Entity,
1711 declarations: &[crate::ops::RelateArg],
1712 source_mem: &str,
1713 source_mount_idx: usize,
1714 type_def: &memstead_schema::TypeDefinition,
1715 schema: &memstead_schema::Schema,
1716) -> Result<Vec<RelationDeclared>, EngineError> {
1717 let _ = type_def; let _ = source_mount_idx; let mut declared: Vec<RelationDeclared> = Vec::with_capacity(declarations.len());
1720 for rel in declarations {
1721 let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
1724 .unwrap_or_else(|_| rel.rel_type.clone());
1725
1726 validate_relation_target_grammar(&rel.target)?;
1727
1728 let target_mem = rel.target.mem().to_string();
1729 super::validate_cross_mem_add_policy(engine, source_mem, &rel.target)?;
1732
1733 let target_type = engine
1742 .store
1743 .get(&rel.target)
1744 .map(|e| e.entity_type.clone())
1745 .filter(|t| !t.is_empty());
1746 let target_type = match target_type {
1749 Some(t) => Some(t),
1750 None => super::peek_deferred_target_type(engine, &rel.target)?,
1751 };
1752 let _ = super::route_edge_validation(
1753 engine,
1754 &canonical,
1755 next.entity_type.as_str(),
1756 target_type.as_deref(),
1757 source_mem,
1758 &target_mem,
1759 &next.id,
1760 &rel.target,
1761 true,
1762 )?;
1763
1764 let normalised_description =
1769 crate::entity::normalise_description(rel.description.as_deref());
1770 super::validate_description_posture(
1771 engine,
1772 &canonical,
1773 normalised_description.as_deref(),
1774 source_mem,
1775 &target_mem,
1776 &next.id,
1777 &rel.target,
1778 )?;
1779 super::validate_manual_authoring_posture(
1782 engine,
1783 &canonical,
1784 source_mem,
1785 &next.id,
1786 &rel.target,
1787 )?;
1788
1789 super::validate_edge_acyclicity(
1793 &engine.store,
1794 schema,
1795 &next.id,
1796 next.entity_type.as_str(),
1797 &rel.target,
1798 &canonical,
1799 )?;
1800
1801 let exists = next
1806 .relationships
1807 .iter()
1808 .any(|r| r.rel_type == canonical && r.target == rel.target);
1809 if !exists {
1810 next.relationships.push(Relationship {
1811 rel_type: canonical.clone(),
1812 target: rel.target.clone(),
1813 description: normalised_description,
1814 });
1815 }
1816
1817 let target_was_stubbed = !engine.store.contains(&rel.target);
1822 if target_was_stubbed && !exists {
1823 let kind = super::deferred_verified_stub_kind(engine, &rel.target)?;
1824 engine
1825 .store
1826 .upsert(rel.target.clone(), make_stub(&rel.target, kind));
1827 }
1828
1829 declared.push(RelationDeclared {
1830 rel_type: canonical,
1831 target: rel.target.clone(),
1832 target_was_stubbed,
1833 });
1834 }
1835 Ok(declared)
1836}
1837
1838#[cfg(test)]
1839mod tests {
1840
1841 use indexmap::IndexMap;
1842 use tempfile::TempDir;
1843
1844 use crate::backend::MemBackend;
1845 use crate::engine::test_helpers::*;
1846 use crate::engine::{
1847 CreateEntityArgs, Engine, EngineError, RelateEntityArgs, UpdateEntityArgs,
1848 };
1849 use crate::entity::EntityId;
1850
1851 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1852 use crate::vcs::Actor;
1853
1854 #[test]
1860 fn update_warns_on_section_heading_divergence_and_still_commits() {
1861 let tmp = TempDir::new().unwrap();
1862 let mem_dir = tmp.path().to_path_buf();
1863 std::fs::write(
1866 mem_dir.join("diverged.md"),
1867 "---\ntype: spec\n---\n# Diverged\n\n## IDENTITY\n\nold text.\n",
1868 )
1869 .unwrap();
1870 let writer = FilesystemMemWriter::new(mem_dir.clone());
1871 let mut engine = Engine::from_mounts(vec![(
1872 folder_mount("specs", mem_dir),
1873 Box::new(writer) as Box<dyn MemBackend>,
1874 )])
1875 .unwrap();
1876 let (actor, client) = cli_actor();
1877 let id = EntityId::new("specs", "diverged");
1878
1879 let update_identity = |engine: &mut Engine, body: &str| {
1880 let current = engine.get_entity(&id).unwrap().content_hash.clone();
1881 let mut sections = IndexMap::new();
1882 sections.insert("identity".to_string(), body.to_string());
1883 engine
1884 .update_entity(
1885 UpdateEntityArgs {
1886 anchors: Vec::new(),
1887 id: id.clone(),
1888 expected_hash: Some(current),
1889 sections,
1890 append_sections: IndexMap::new(),
1891 patch_sections: IndexMap::new(),
1892 sections_unset: Vec::new(),
1893 metadata: IndexMap::new(),
1894 metadata_unset: Vec::new(),
1895 declare_relations: Vec::new(),
1896 dry_run: false,
1897 relations_unset: Vec::new(),
1898 anchors_unset: Vec::new(),
1899 },
1900 actor,
1901 Some(&client),
1902 None,
1903 )
1904 .unwrap()
1905 };
1906
1907 let outcome = update_identity(&mut engine, "new text.");
1908 assert!(!outcome.write_id.is_empty(), "the mutation still commits");
1909 let divergences: Vec<_> = outcome
1910 .warnings
1911 .iter()
1912 .filter_map(|w| match w {
1913 crate::ops::WarningHint::SectionHeadingDivergence {
1914 section_key,
1915 writing_heading,
1916 existing_heading,
1917 ..
1918 } => Some((
1919 section_key.clone(),
1920 writing_heading.clone(),
1921 existing_heading.clone(),
1922 )),
1923 _ => None,
1924 })
1925 .collect();
1926 assert_eq!(
1927 divergences,
1928 vec![(
1929 "identity".to_string(),
1930 "Identity".to_string(),
1931 "IDENTITY".to_string()
1932 )],
1933 "warning names both headings; all warnings = {:?}",
1934 outcome.warnings
1935 );
1936
1937 let outcome2 = update_identity(&mut engine, "third text.");
1940 assert!(
1941 !outcome2
1942 .warnings
1943 .iter()
1944 .any(|w| matches!(w, crate::ops::WarningHint::SectionHeadingDivergence { .. })),
1945 "matching heading emits no divergence warning: {:?}",
1946 outcome2.warnings
1947 );
1948 }
1949
1950 #[test]
1951 fn batch_update_empty_batch_returns_zero_counts() {
1952 let tmp = TempDir::new().unwrap();
1955 let mem_dir = tmp.path().to_path_buf();
1956 let writer = FilesystemMemWriter::new(mem_dir.clone());
1957 let mut engine = Engine::from_mounts(vec![(
1958 folder_mount("specs", mem_dir),
1959 Box::new(writer) as Box<dyn MemBackend>,
1960 )])
1961 .unwrap();
1962
1963 let result = engine
1964 .batch_update(Vec::new(), Actor::Cli, None, false)
1965 .unwrap();
1966 assert!(result.applied, "empty batch is a vacuous success");
1967 assert_eq!(result.results.len(), 0);
1968 assert_eq!(result.succeeded, 0);
1969 assert_eq!(result.failed, 0);
1970 assert_eq!(result.write_id, "");
1971 }
1972
1973 #[test]
1974 fn batch_update_refuses_whole_batch_when_one_item_fails() {
1975 let tmp = TempDir::new().unwrap();
1982 let mem_dir = tmp.path().to_path_buf();
1983 let writer = FilesystemMemWriter::new(mem_dir.clone());
1984 let mut engine = Engine::from_mounts(vec![(
1985 folder_mount("specs", mem_dir),
1986 Box::new(writer) as Box<dyn MemBackend>,
1987 )])
1988 .unwrap();
1989
1990 let create_args = CreateEntityArgs {
1992 anchors: Vec::new(),
1993 mem: "specs".to_string(),
1994 title: "Seed".to_string(),
1995 entity_type: "spec".to_string(),
1996 sections: IndexMap::from_iter([
1997 ("identity".to_string(), "seed identity".to_string()),
1998 ("purpose".to_string(), "seed purpose".to_string()),
1999 ]),
2000 metadata: IndexMap::new(),
2001 relations: Vec::new(),
2002 dry_run: false,
2003 };
2004 let created = engine
2005 .create_entity(create_args, Actor::Cli, None, None)
2006 .unwrap();
2007
2008 let valid_update = UpdateEntityArgs {
2010 anchors: Vec::new(),
2011 id: created.id.clone(),
2012 expected_hash: Some(created.content_hash.clone()),
2013 sections: IndexMap::from_iter([("identity".to_string(), "updated body".to_string())]),
2014 append_sections: IndexMap::new(),
2015 patch_sections: IndexMap::new(),
2016 sections_unset: Vec::new(),
2017 metadata: IndexMap::new(),
2018 metadata_unset: Vec::new(),
2019 declare_relations: Vec::new(),
2020 dry_run: false,
2021 relations_unset: Vec::new(),
2022 anchors_unset: Vec::new(),
2023 };
2024 let missing_update = UpdateEntityArgs {
2025 anchors: Vec::new(),
2026 id: EntityId("specs--nonexistent".to_string()),
2027 expected_hash: None,
2028 sections: IndexMap::new(),
2029 append_sections: IndexMap::new(),
2030 patch_sections: IndexMap::new(),
2031 sections_unset: Vec::new(),
2032 metadata: IndexMap::new(),
2033 metadata_unset: Vec::new(),
2034 declare_relations: Vec::new(),
2035 dry_run: false,
2036 relations_unset: Vec::new(),
2037 anchors_unset: Vec::new(),
2038 };
2039
2040 let result = engine
2041 .batch_update(
2042 vec![(valid_update, None), (missing_update, None)],
2043 Actor::Cli,
2044 None,
2045 false,
2046 )
2047 .unwrap();
2048 assert!(!result.applied, "a failing item must refuse the batch");
2050 assert_eq!(result.results.len(), 2);
2051 assert_eq!(result.succeeded, 0);
2052 assert_eq!(result.failed, 1);
2053 assert_eq!(result.write_id, "", "refused batch must not commit");
2054 assert_eq!(result.results[0].action, "not_applied");
2057 assert!(result.results[0].error.is_none());
2058 assert_eq!(result.results[1].action, "error");
2060 let err = result.results[1]
2061 .error
2062 .as_ref()
2063 .expect("failed entry must carry a structured error envelope");
2064 assert_eq!(err.code, "ENTITY_NOT_FOUND");
2065 assert!(err.message.contains("not found"), "got: {}", err.message);
2066
2067 let seed = engine.get_entity(&created.id).unwrap();
2070 assert_eq!(
2071 seed.sections.get("identity").map(String::as_str),
2072 Some("seed identity"),
2073 "refused batch must leave the in-memory store untouched",
2074 );
2075 assert_eq!(
2076 seed.content_hash, created.content_hash,
2077 "refused batch must not change the entity's content hash",
2078 );
2079 }
2080
2081 #[test]
2082 fn batch_update_applies_all_valid_items_as_one_commit() {
2083 let tmp = TempDir::new().unwrap();
2087 let mem_dir = tmp.path().to_path_buf();
2088 let writer = FilesystemMemWriter::new(mem_dir.clone());
2089 let mut engine = Engine::from_mounts(vec![(
2090 folder_mount("specs", mem_dir),
2091 Box::new(writer) as Box<dyn MemBackend>,
2092 )])
2093 .unwrap();
2094
2095 let mk = |title: &str| CreateEntityArgs {
2096 anchors: Vec::new(),
2097 mem: "specs".to_string(),
2098 title: title.to_string(),
2099 entity_type: "spec".to_string(),
2100 sections: IndexMap::from_iter([
2101 ("identity".to_string(), "id".to_string()),
2102 ("purpose".to_string(), "purp".to_string()),
2103 ]),
2104 metadata: IndexMap::new(),
2105 relations: Vec::new(),
2106 dry_run: false,
2107 };
2108 let a = engine
2109 .create_entity(mk("A"), Actor::Cli, None, None)
2110 .unwrap();
2111 let b = engine
2112 .create_entity(mk("B"), Actor::Cli, None, None)
2113 .unwrap();
2114
2115 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
2116 anchors: Vec::new(),
2117 id,
2118 expected_hash: Some(hash),
2119 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
2120 append_sections: IndexMap::new(),
2121 patch_sections: IndexMap::new(),
2122 sections_unset: Vec::new(),
2123 metadata: IndexMap::new(),
2124 metadata_unset: Vec::new(),
2125 declare_relations: Vec::new(),
2126 dry_run: false,
2127 relations_unset: Vec::new(),
2128 anchors_unset: Vec::new(),
2129 };
2130
2131 let result = engine
2132 .batch_update(
2133 vec![
2134 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
2135 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
2136 ],
2137 Actor::Cli,
2138 None,
2139 false,
2140 )
2141 .unwrap();
2142 assert!(result.applied);
2143 assert_eq!(result.succeeded, 2);
2144 assert_eq!(result.failed, 0);
2145 assert!(
2146 !result.write_id.is_empty(),
2147 "applied batch carries the commit"
2148 );
2149 assert!(result.results.iter().all(|e| e.action == "updated"));
2150 assert_eq!(
2152 engine
2153 .get_entity(&a.id)
2154 .unwrap()
2155 .sections
2156 .get("identity")
2157 .map(String::as_str),
2158 Some("A body"),
2159 );
2160 assert_eq!(
2161 engine
2162 .get_entity(&b.id)
2163 .unwrap()
2164 .sections
2165 .get("identity")
2166 .map(String::as_str),
2167 Some("B body"),
2168 );
2169 }
2170
2171 #[test]
2180 fn batch_update_dry_run_reports_receipt_and_writes_nothing() {
2181 let tmp = TempDir::new().unwrap();
2182 let mem_dir = tmp.path().to_path_buf();
2183 let writer = FilesystemMemWriter::new(mem_dir.clone());
2184 let mut engine = Engine::from_mounts(vec![(
2185 folder_mount("specs", mem_dir),
2186 Box::new(writer) as Box<dyn MemBackend>,
2187 )])
2188 .unwrap();
2189
2190 let mk = |title: &str| CreateEntityArgs {
2191 anchors: Vec::new(),
2192 mem: "specs".to_string(),
2193 title: title.to_string(),
2194 entity_type: "spec".to_string(),
2195 sections: IndexMap::from_iter([
2196 ("identity".to_string(), "id".to_string()),
2197 ("purpose".to_string(), "purp".to_string()),
2198 ]),
2199 metadata: IndexMap::new(),
2200 relations: Vec::new(),
2201 dry_run: false,
2202 };
2203 let a = engine
2204 .create_entity(mk("A"), Actor::Cli, None, None)
2205 .unwrap();
2206 let b = engine
2207 .create_entity(mk("B"), Actor::Cli, None, None)
2208 .unwrap();
2209
2210 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
2211 anchors: Vec::new(),
2212 id,
2213 expected_hash: Some(hash),
2214 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
2215 append_sections: IndexMap::new(),
2216 patch_sections: IndexMap::new(),
2217 sections_unset: Vec::new(),
2218 metadata: IndexMap::new(),
2219 metadata_unset: Vec::new(),
2220 declare_relations: Vec::new(),
2221 dry_run: false,
2222 relations_unset: Vec::new(),
2223 anchors_unset: Vec::new(),
2224 };
2225 let batch = || {
2226 vec![
2227 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
2228 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
2229 ]
2230 };
2231
2232 let rehearsed = engine
2233 .batch_update(batch(), Actor::Cli, None, true)
2234 .unwrap();
2235 assert!(rehearsed.applied, "{rehearsed:?}");
2236 assert_eq!(rehearsed.succeeded, 2);
2237 assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
2238 assert!(rehearsed.results.iter().all(|e| e.action == "updated"));
2239 let a_now = engine.get_entity(&a.id).unwrap();
2241 assert_eq!(
2242 a_now.sections.get("identity").map(String::as_str),
2243 Some("id")
2244 );
2245 assert_eq!(a_now.content_hash, a.content_hash);
2246
2247 let real = engine
2249 .batch_update(batch(), Actor::Cli, None, false)
2250 .unwrap();
2251 assert!(real.applied, "{real:?}");
2252 assert!(!real.write_id.is_empty());
2253 assert_eq!(
2254 engine
2255 .get_entity(&a.id)
2256 .unwrap()
2257 .sections
2258 .get("identity")
2259 .map(String::as_str),
2260 Some("A body"),
2261 );
2262 }
2263
2264 #[test]
2268 fn batch_update_dry_run_refuses_identically_to_real() {
2269 let tmp = TempDir::new().unwrap();
2270 let mem_dir = tmp.path().to_path_buf();
2271 let writer = FilesystemMemWriter::new(mem_dir.clone());
2272 let mut engine = Engine::from_mounts(vec![(
2273 folder_mount("specs", mem_dir),
2274 Box::new(writer) as Box<dyn MemBackend>,
2275 )])
2276 .unwrap();
2277 let created = engine
2278 .create_entity(
2279 CreateEntityArgs {
2280 anchors: Vec::new(),
2281 mem: "specs".to_string(),
2282 title: "Valid".to_string(),
2283 entity_type: "spec".to_string(),
2284 sections: IndexMap::from_iter([
2285 ("identity".to_string(), "x".to_string()),
2286 ("purpose".to_string(), "p".to_string()),
2287 ]),
2288 metadata: IndexMap::new(),
2289 relations: Vec::new(),
2290 dry_run: false,
2291 },
2292 Actor::Cli,
2293 None,
2294 None,
2295 )
2296 .unwrap();
2297 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2298 anchors: Vec::new(),
2299 id,
2300 expected_hash: hash,
2301 sections: IndexMap::from_iter([("identity".to_string(), "new".to_string())]),
2302 append_sections: IndexMap::new(),
2303 patch_sections: IndexMap::new(),
2304 sections_unset: Vec::new(),
2305 metadata: IndexMap::new(),
2306 metadata_unset: Vec::new(),
2307 declare_relations: Vec::new(),
2308 dry_run: false,
2309 relations_unset: Vec::new(),
2310 anchors_unset: Vec::new(),
2311 };
2312 let batch = || {
2313 vec![
2314 (
2315 upd(created.id.clone(), Some("wrong-hash".to_string())),
2316 None,
2317 ),
2318 (upd(EntityId("specs--missing".to_string()), None), None),
2319 ]
2320 };
2321
2322 let rehearsed = engine
2323 .batch_update(batch(), Actor::Cli, None, true)
2324 .unwrap();
2325 let real = engine
2326 .batch_update(batch(), Actor::Cli, None, false)
2327 .unwrap();
2328 assert!(!rehearsed.applied && !real.applied);
2329 let envelope = |r: &crate::ops::BatchResult| {
2330 r.results
2331 .iter()
2332 .map(|e| {
2333 (
2334 e.id.to_string(),
2335 e.action.clone(),
2336 e.error.as_ref().map(|err| {
2337 (err.code.clone(), err.message.clone(), err.details.clone())
2338 }),
2339 )
2340 })
2341 .collect::<Vec<_>>()
2342 };
2343 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
2344 assert_eq!(
2346 engine
2347 .get_entity(&created.id)
2348 .unwrap()
2349 .sections
2350 .get("identity")
2351 .map(String::as_str),
2352 Some("x"),
2353 );
2354 }
2355
2356 #[test]
2360 fn batch_update_reports_every_failing_item() {
2361 let tmp = TempDir::new().unwrap();
2362 let mem_dir = tmp.path().to_path_buf();
2363 let writer = FilesystemMemWriter::new(mem_dir.clone());
2364 let mut engine = Engine::from_mounts(vec![(
2365 folder_mount("specs", mem_dir),
2366 Box::new(writer) as Box<dyn MemBackend>,
2367 )])
2368 .unwrap();
2369 let created = engine
2370 .create_entity(
2371 CreateEntityArgs {
2372 anchors: Vec::new(),
2373 mem: "specs".to_string(),
2374 title: "Seed".to_string(),
2375 entity_type: "spec".to_string(),
2376 sections: IndexMap::from_iter([
2377 ("identity".to_string(), "seed identity".to_string()),
2378 ("purpose".to_string(), "seed purpose".to_string()),
2379 ]),
2380 metadata: IndexMap::new(),
2381 relations: Vec::new(),
2382 dry_run: false,
2383 },
2384 Actor::Cli,
2385 None,
2386 None,
2387 )
2388 .unwrap();
2389
2390 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2391 anchors: Vec::new(),
2392 id,
2393 expected_hash: hash,
2394 sections: IndexMap::from_iter([("identity".to_string(), "new body".to_string())]),
2395 append_sections: IndexMap::new(),
2396 patch_sections: IndexMap::new(),
2397 sections_unset: Vec::new(),
2398 metadata: IndexMap::new(),
2399 metadata_unset: Vec::new(),
2400 declare_relations: Vec::new(),
2401 dry_run: false,
2402 relations_unset: Vec::new(),
2403 anchors_unset: Vec::new(),
2404 };
2405 let result = engine
2406 .batch_update(
2407 vec![
2408 (upd(created.id.clone(), None), None),
2409 (upd(EntityId("specs--missing-one".to_string()), None), None),
2410 (upd(EntityId("specs--missing-two".to_string()), None), None),
2411 ],
2412 Actor::Cli,
2413 None,
2414 false,
2415 )
2416 .unwrap();
2417 assert!(!result.applied);
2418 assert_eq!(result.failed, 2, "{result:?}");
2419 assert_eq!(result.write_id, "");
2420 let codes: Vec<(usize, &str)> = result
2421 .results
2422 .iter()
2423 .enumerate()
2424 .filter(|(_, r)| r.action == "error")
2425 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
2426 .collect();
2427 assert_eq!(
2428 codes,
2429 vec![(1, "ENTITY_NOT_FOUND"), (2, "ENTITY_NOT_FOUND")],
2430 "BOTH failing items named, not just the first: {result:?}"
2431 );
2432 assert_eq!(result.results[0].action, "not_applied");
2433 assert_eq!(
2435 engine
2436 .get_entity(&created.id)
2437 .unwrap()
2438 .sections
2439 .get("identity")
2440 .map(String::as_str),
2441 Some("seed identity"),
2442 );
2443 }
2444
2445 #[test]
2446 fn batch_update_rolls_back_in_memory_store_auto_stub_on_refusal() {
2447 let tmp = TempDir::new().unwrap();
2455 let mem_dir = tmp.path().to_path_buf();
2456 let writer = FilesystemMemWriter::new(mem_dir.clone());
2457 let mut engine = Engine::from_mounts(vec![(
2458 folder_mount("specs", mem_dir.clone()),
2459 Box::new(writer) as Box<dyn MemBackend>,
2460 )])
2461 .unwrap();
2462 engine.set_workspace_root(mem_dir);
2463 let (actor, client) = cli_actor();
2464
2465 let a = engine
2466 .create_entity(
2467 empty_create_args("specs", "Anchor"),
2468 actor,
2469 Some(&client),
2470 None,
2471 )
2472 .unwrap();
2473
2474 let stub_target = EntityId::new("specs", "would-be-stub");
2475 let item1 = UpdateEntityArgs {
2476 anchors: Vec::new(),
2477 relations_unset: Vec::new(),
2478 anchors_unset: Vec::new(),
2479 id: a.id.clone(),
2480 expected_hash: Some(a.content_hash.clone()),
2481 sections: IndexMap::new(),
2482 append_sections: IndexMap::new(),
2483 patch_sections: IndexMap::new(),
2484 sections_unset: Vec::new(),
2485 metadata: IndexMap::new(),
2486 metadata_unset: Vec::new(),
2487 declare_relations: vec![crate::ops::RelateArg {
2488 rel_type: "USES".to_string(),
2489 target: stub_target.clone(),
2490 description: None,
2491 }],
2492 dry_run: false,
2493 };
2494 let item2 = UpdateEntityArgs {
2495 anchors: Vec::new(),
2496 id: EntityId::new("specs", "nonexistent"),
2497 expected_hash: None,
2498 sections: IndexMap::from_iter([("identity".to_string(), "x".to_string())]),
2499 append_sections: IndexMap::new(),
2500 patch_sections: IndexMap::new(),
2501 sections_unset: Vec::new(),
2502 metadata: IndexMap::new(),
2503 metadata_unset: Vec::new(),
2504 declare_relations: Vec::new(),
2505 dry_run: false,
2506 relations_unset: Vec::new(),
2507 anchors_unset: Vec::new(),
2508 };
2509
2510 assert!(engine.get_entity(&stub_target).is_none());
2512
2513 let result = engine
2514 .batch_update(
2515 vec![(item1, None), (item2, None)],
2516 actor,
2517 Some(&client),
2518 false,
2519 )
2520 .unwrap();
2521 assert!(!result.applied, "missing item 2 must refuse the batch");
2522
2523 assert!(
2526 engine.get_entity(&stub_target).is_none(),
2527 "refused batch must roll the in-memory auto-stub back out of the store",
2528 );
2529 let anchor = engine.get_entity(&a.id).unwrap();
2531 assert!(
2532 !anchor.relationships.iter().any(|r| r.target == stub_target),
2533 "refused batch must not leave the declared relation on the anchor",
2534 );
2535 }
2536
2537 #[test]
2538 fn update_entity_replaces_a_section_and_logs_provenance() {
2539 let tmp = TempDir::new().unwrap();
2540 let (mut engine, seeded) = engine_with_seed(&tmp, "Updatable");
2541 let (actor, client) = cli_actor();
2542
2543 let mut sections = IndexMap::new();
2544 sections.insert("identity".to_string(), "Updated body.".to_string());
2545
2546 let outcome = engine
2547 .update_entity(
2548 UpdateEntityArgs {
2549 anchors: Vec::new(),
2550 id: seeded.id.clone(),
2551 expected_hash: Some(seeded.content_hash.clone()),
2552 sections,
2553 append_sections: IndexMap::new(),
2554 patch_sections: IndexMap::new(),
2555 sections_unset: Vec::new(),
2556 metadata: IndexMap::new(),
2557 metadata_unset: Vec::new(),
2558 declare_relations: Vec::new(),
2559 dry_run: false,
2560 relations_unset: Vec::new(),
2561 anchors_unset: Vec::new(),
2562 },
2563 actor,
2564 Some(&client),
2565 Some("section update"),
2566 )
2567 .unwrap();
2568
2569 assert_eq!(
2570 outcome.modified_sections.replaced,
2571 vec!["identity".to_string()]
2572 );
2573 assert_ne!(
2574 outcome.content_hash, seeded.content_hash,
2575 "hash must change"
2576 );
2577 let entity = engine.get_entity(&seeded.id).unwrap();
2579 assert!(
2580 entity
2581 .sections
2582 .get("identity")
2583 .unwrap()
2584 .contains("Updated body.")
2585 );
2586 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
2588 assert!(log.contains("\"kind\":\"update\""));
2589 assert!(log.contains("\"note\":\"section update\""));
2590 }
2591
2592 #[test]
2593 fn update_entity_rejects_hash_mismatch() {
2594 let tmp = TempDir::new().unwrap();
2595 let (mut engine, seeded) = engine_with_seed(&tmp, "Hash Guarded");
2596 let (actor, client) = cli_actor();
2597 let err = engine
2598 .update_entity(
2599 UpdateEntityArgs {
2600 anchors: Vec::new(),
2601 id: seeded.id.clone(),
2602 expected_hash: Some("wrong-hash".to_string()),
2603 sections: IndexMap::new(),
2604 append_sections: IndexMap::new(),
2605 patch_sections: IndexMap::new(),
2606 sections_unset: Vec::new(),
2607 metadata: IndexMap::new(),
2608 metadata_unset: Vec::new(),
2609 declare_relations: Vec::new(),
2610 dry_run: false,
2611 relations_unset: Vec::new(),
2612 anchors_unset: Vec::new(),
2613 },
2614 actor,
2615 Some(&client),
2616 None,
2617 )
2618 .unwrap_err();
2619 match err {
2620 EngineError::HashMismatch {
2621 id,
2622 current,
2623 is_stub,
2624 } => {
2625 assert_eq!(id, seeded.id.to_string());
2626 assert_eq!(current, seeded.content_hash);
2627 assert!(!is_stub, "real entity must not flag as stub");
2628 }
2629 other => panic!("expected HashMismatch, got {other:?}"),
2630 }
2631 }
2632
2633 #[test]
2634 fn update_entity_rejects_unknown_id() {
2635 let tmp = TempDir::new().unwrap();
2636 let (mut engine, _) = engine_with_seed(&tmp, "Anchor");
2637 let (actor, client) = cli_actor();
2638 let err = engine
2639 .update_entity(
2640 UpdateEntityArgs {
2641 anchors: Vec::new(),
2642 id: crate::EntityId::new("specs", "ghost"),
2643 expected_hash: None,
2644 sections: IndexMap::new(),
2645 append_sections: IndexMap::new(),
2646 patch_sections: IndexMap::new(),
2647 sections_unset: Vec::new(),
2648 metadata: IndexMap::new(),
2649 metadata_unset: Vec::new(),
2650 declare_relations: Vec::new(),
2651 dry_run: false,
2652 relations_unset: Vec::new(),
2653 anchors_unset: Vec::new(),
2654 },
2655 actor,
2656 Some(&client),
2657 None,
2658 )
2659 .unwrap_err();
2660 assert!(matches!(err, EngineError::NotFound { .. }));
2661 }
2662
2663 #[test]
2664 fn update_entity_rejects_read_only_mount() {
2665 let tmp = TempDir::new().unwrap();
2666 let archive_path = build_archive(
2667 tmp.path(),
2668 "ext",
2669 &[(
2670 "a.md",
2671 b"---\ntype: spec\n---\n# A\n\n## Identity\n\nbody.\n",
2672 )],
2673 );
2674 let mut engine = Engine::from_mounts(vec![(
2675 archive_mount("external", archive_path.clone()),
2676 Box::new(ArchiveBackend::new(archive_path)),
2677 )])
2678 .unwrap();
2679 let (actor, client) = cli_actor();
2680 let id = crate::EntityId::new("external", "a");
2681 let err = engine
2682 .update_entity(
2683 UpdateEntityArgs {
2684 anchors: Vec::new(),
2685 id,
2686 expected_hash: None,
2687 sections: IndexMap::new(),
2688 append_sections: IndexMap::new(),
2689 patch_sections: IndexMap::new(),
2690 sections_unset: Vec::new(),
2691 metadata: IndexMap::new(),
2692 metadata_unset: Vec::new(),
2693 declare_relations: Vec::new(),
2694 dry_run: false,
2695 relations_unset: Vec::new(),
2696 anchors_unset: Vec::new(),
2697 },
2698 actor,
2699 Some(&client),
2700 None,
2701 )
2702 .unwrap_err();
2703 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "external"));
2704 }
2705
2706 #[test]
2707 fn update_entity_patches_section_with_find_and_replace() {
2708 let tmp = TempDir::new().unwrap();
2709 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Subject");
2710 let (actor, client) = cli_actor();
2711
2712 let mut replace = IndexMap::new();
2715 replace.insert("identity".to_string(), "hello world hello".to_string());
2716 let replaced = engine
2717 .update_entity(
2718 UpdateEntityArgs {
2719 anchors: Vec::new(),
2720 id: seeded.id.clone(),
2721 expected_hash: Some(seeded.content_hash.clone()),
2722 sections: replace,
2723 append_sections: IndexMap::new(),
2724 patch_sections: IndexMap::new(),
2725 sections_unset: Vec::new(),
2726 metadata: IndexMap::new(),
2727 metadata_unset: Vec::new(),
2728 declare_relations: Vec::new(),
2729 dry_run: false,
2730 relations_unset: Vec::new(),
2731 anchors_unset: Vec::new(),
2732 },
2733 actor,
2734 Some(&client),
2735 None,
2736 )
2737 .unwrap();
2738
2739 let mut patches = IndexMap::new();
2741 patches.insert(
2742 "identity".to_string(),
2743 vec![crate::ops::PatchArg {
2744 old: "hello".to_string(),
2745 new: "HI".to_string(),
2746 all: false,
2747 }],
2748 );
2749 let outcome = engine
2750 .update_entity(
2751 UpdateEntityArgs {
2752 anchors: Vec::new(),
2753 id: seeded.id.clone(),
2754 expected_hash: Some(replaced.content_hash.clone()),
2755 sections: IndexMap::new(),
2756 append_sections: IndexMap::new(),
2757 patch_sections: patches,
2758 sections_unset: Vec::new(),
2759 metadata: IndexMap::new(),
2760 metadata_unset: Vec::new(),
2761 declare_relations: Vec::new(),
2762 dry_run: false,
2763 relations_unset: Vec::new(),
2764 anchors_unset: Vec::new(),
2765 },
2766 actor,
2767 Some(&client),
2768 None,
2769 )
2770 .unwrap();
2771 assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
2772 let body = engine
2773 .get_entity(&seeded.id)
2774 .unwrap()
2775 .sections
2776 .get("identity")
2777 .unwrap()
2778 .clone();
2779 assert!(body.contains("HI world hello"), "first-only: {body:?}");
2780 }
2781
2782 #[test]
2783 fn update_entity_patch_rejects_missing_old_substring() {
2784 let tmp = TempDir::new().unwrap();
2785 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Miss");
2786 let (actor, client) = cli_actor();
2787 let mut patches = IndexMap::new();
2788 patches.insert(
2789 "identity".to_string(),
2790 vec![crate::ops::PatchArg {
2791 old: "this-substring-does-not-exist".to_string(),
2792 new: "nope".to_string(),
2793 all: false,
2794 }],
2795 );
2796 let err = engine
2797 .update_entity(
2798 UpdateEntityArgs {
2799 anchors: Vec::new(),
2800 id: seeded.id.clone(),
2801 expected_hash: Some(seeded.content_hash.clone()),
2802 sections: IndexMap::new(),
2803 append_sections: IndexMap::new(),
2804 patch_sections: patches,
2805 sections_unset: Vec::new(),
2806 metadata: IndexMap::new(),
2807 metadata_unset: Vec::new(),
2808 declare_relations: Vec::new(),
2809 dry_run: false,
2810 relations_unset: Vec::new(),
2811 anchors_unset: Vec::new(),
2812 },
2813 actor,
2814 Some(&client),
2815 None,
2816 )
2817 .unwrap_err();
2818 match err {
2819 EngineError::PatchOldNotFound { section, .. } => {
2820 assert_eq!(section, "identity");
2821 }
2822 other => panic!("expected PatchOldNotFound, got {other:?}"),
2823 }
2824 }
2825
2826 fn unset_args(id: EntityId, hash: String, unset: &[&str]) -> UpdateEntityArgs {
2829 UpdateEntityArgs {
2830 anchors: Vec::new(),
2831 id,
2832 expected_hash: Some(hash),
2833 sections: IndexMap::new(),
2834 append_sections: IndexMap::new(),
2835 patch_sections: IndexMap::new(),
2836 sections_unset: unset.iter().map(|s| s.to_string()).collect(),
2837 metadata: IndexMap::new(),
2838 metadata_unset: Vec::new(),
2839 declare_relations: Vec::new(),
2840 dry_run: false,
2841 relations_unset: Vec::new(),
2842 anchors_unset: Vec::new(),
2843 }
2844 }
2845
2846 #[test]
2850 fn update_entity_sections_unset_removes_optional_section() {
2851 let tmp = TempDir::new().unwrap();
2852 let (mut engine, seeded) = engine_with_seed(&tmp, "Unset Subject");
2853 let (actor, client) = cli_actor();
2854 let mut sections = IndexMap::new();
2856 sections.insert("specifies".to_string(), "temporary content".to_string());
2857 let with_specifies = engine
2858 .update_entity(
2859 UpdateEntityArgs {
2860 sections,
2861 ..unset_args(seeded.id.clone(), seeded.content_hash.clone(), &[])
2862 },
2863 actor,
2864 Some(&client),
2865 None,
2866 )
2867 .unwrap();
2868
2869 let outcome = engine
2870 .update_entity(
2871 unset_args(
2872 seeded.id.clone(),
2873 with_specifies.content_hash.clone(),
2874 &["specifies", "not-present"],
2875 ),
2876 actor,
2877 Some(&client),
2878 None,
2879 )
2880 .unwrap();
2881 assert_eq!(outcome.modified_sections.unset, vec!["specifies"]);
2882 let entity = engine.store().get(&seeded.id).unwrap();
2883 assert!(
2884 !entity.sections.contains_key("specifies"),
2885 "section removed: {:?}",
2886 entity.sections.keys().collect::<Vec<_>>()
2887 );
2888 }
2889
2890 #[test]
2894 fn update_entity_sections_unset_refuses_required_section() {
2895 let tmp = TempDir::new().unwrap();
2896 let (mut engine, seeded) = engine_with_seed(&tmp, "Unset Required");
2897 let (actor, client) = cli_actor();
2898 let err = engine
2899 .update_entity(
2900 unset_args(
2901 seeded.id.clone(),
2902 seeded.content_hash.clone(),
2903 &["identity"],
2904 ),
2905 actor,
2906 Some(&client),
2907 None,
2908 )
2909 .unwrap_err();
2910 match err {
2911 EngineError::MissingRequiredSection {
2912 entity_type,
2913 sections,
2914 ..
2915 } => {
2916 assert_eq!(entity_type, "spec");
2917 assert_eq!(sections.len(), 1);
2918 assert_eq!(sections[0].key, "identity");
2919 }
2920 other => panic!("expected MissingRequiredSection, got {other:?}"),
2921 }
2922 }
2923
2924 #[test]
2928 fn update_entity_sections_unset_conflicts_and_relationships_refuse() {
2929 let tmp = TempDir::new().unwrap();
2930 let (mut engine, seeded) = engine_with_seed(&tmp, "Unset Conflict");
2931 let (actor, client) = cli_actor();
2932 let mut sections = IndexMap::new();
2933 sections.insert("specifies".to_string(), "body".to_string());
2934 let err = engine
2935 .update_entity(
2936 UpdateEntityArgs {
2937 sections,
2938 ..unset_args(
2939 seeded.id.clone(),
2940 seeded.content_hash.clone(),
2941 &["specifies"],
2942 )
2943 },
2944 actor,
2945 Some(&client),
2946 None,
2947 )
2948 .unwrap_err();
2949 match err {
2950 EngineError::ConflictingSectionModes { section, modes } => {
2951 assert_eq!(section, "specifies");
2952 assert!(modes.contains(&"sections_unset".to_string()), "{modes:?}");
2953 assert!(modes.contains(&"sections".to_string()), "{modes:?}");
2954 }
2955 other => panic!("expected ConflictingSectionModes, got {other:?}"),
2956 }
2957
2958 let err = engine
2959 .update_entity(
2960 unset_args(
2961 seeded.id.clone(),
2962 seeded.content_hash.clone(),
2963 &["relationships"],
2964 ),
2965 actor,
2966 Some(&client),
2967 None,
2968 )
2969 .unwrap_err();
2970 assert_eq!(err.code(), "SECTION_NOT_UPDATABLE", "{err:?}");
2971 }
2972
2973 #[test]
2978 fn update_entity_applies_multiple_patches_per_section_in_order() {
2979 let tmp = TempDir::new().unwrap();
2980 let (mut engine, seeded) = engine_with_seed(&tmp, "Multi Patch");
2981 let (actor, client) = cli_actor();
2982 let mut patches = IndexMap::new();
2983 patches.insert(
2984 "identity".to_string(),
2985 vec![
2986 crate::ops::PatchArg {
2987 old: "fixture".to_string(),
2988 new: "FIRST".to_string(),
2989 all: false,
2990 },
2991 crate::ops::PatchArg {
2994 old: "FIRST identity".to_string(),
2995 new: "SECOND".to_string(),
2996 all: false,
2997 },
2998 ],
2999 );
3000 let outcome = engine
3001 .update_entity(
3002 UpdateEntityArgs {
3003 patch_sections: patches,
3004 ..unset_args(seeded.id.clone(), seeded.content_hash.clone(), &[])
3005 },
3006 actor,
3007 Some(&client),
3008 None,
3009 )
3010 .unwrap();
3011 assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
3012 let entity = engine.store().get(&seeded.id).unwrap();
3013 assert_eq!(entity.sections["identity"], "SECOND body");
3014 }
3015
3016 #[test]
3021 fn update_entity_patch_names_the_sections_that_do_contain_old() {
3022 let tmp = TempDir::new().unwrap();
3023 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Wrong Section");
3024 let (actor, client) = cli_actor();
3025 let mut patches = IndexMap::new();
3026 patches.insert(
3027 "identity".to_string(),
3028 vec![crate::ops::PatchArg {
3029 old: "fixture purpose body".to_string(),
3030 new: "nope".to_string(),
3031 all: false,
3032 }],
3033 );
3034 let err = engine
3035 .update_entity(
3036 UpdateEntityArgs {
3037 anchors: Vec::new(),
3038 id: seeded.id.clone(),
3039 expected_hash: Some(seeded.content_hash.clone()),
3040 sections: IndexMap::new(),
3041 append_sections: IndexMap::new(),
3042 patch_sections: patches,
3043 sections_unset: Vec::new(),
3044 metadata: IndexMap::new(),
3045 metadata_unset: Vec::new(),
3046 declare_relations: Vec::new(),
3047 dry_run: false,
3048 relations_unset: Vec::new(),
3049 anchors_unset: Vec::new(),
3050 },
3051 actor,
3052 Some(&client),
3053 None,
3054 )
3055 .unwrap_err();
3056 match err {
3057 EngineError::PatchOldNotFound {
3058 section,
3059 found_in_sections,
3060 ..
3061 } => {
3062 assert_eq!(section, "identity");
3063 assert_eq!(found_in_sections, vec!["purpose".to_string()]);
3064 }
3065 other => panic!("expected PatchOldNotFound, got {other:?}"),
3066 }
3067 }
3068
3069 #[test]
3070 fn update_entity_appends_to_existing_section_with_newline_separator() {
3071 let tmp = TempDir::new().unwrap();
3072 let (mut engine, seeded) = engine_with_seed(&tmp, "Append Subject");
3073 let (actor, client) = cli_actor();
3074
3075 let mut appends = IndexMap::new();
3076 appends.insert("identity".to_string(), "appended tail.".to_string());
3077
3078 let outcome = engine
3079 .update_entity(
3080 UpdateEntityArgs {
3081 anchors: Vec::new(),
3082 id: seeded.id.clone(),
3083 expected_hash: Some(seeded.content_hash.clone()),
3084 sections: IndexMap::new(),
3085 append_sections: appends,
3086 patch_sections: IndexMap::new(),
3087 sections_unset: Vec::new(),
3088 metadata: IndexMap::new(),
3089 metadata_unset: Vec::new(),
3090 declare_relations: Vec::new(),
3091 dry_run: false,
3092 relations_unset: Vec::new(),
3093 anchors_unset: Vec::new(),
3094 },
3095 actor,
3096 Some(&client),
3097 None,
3098 )
3099 .unwrap();
3100
3101 assert_eq!(outcome.modified_sections.appended, vec!["identity"]);
3104 assert!(outcome.modified_sections.replaced.is_empty());
3105
3106 let updated = engine.get_entity(&seeded.id).unwrap();
3108 let body = updated.sections.get("identity").expect("identity section");
3109 assert!(
3110 body.contains("appended tail."),
3111 "appended body missing: {body:?}"
3112 );
3113 }
3114
3115 fn engine_with_open_fence(tmp: &TempDir) -> (Engine, crate::EntityId) {
3119 let (_engine, seeded) = engine_with_seed(tmp, "Fenced");
3120 let id = seeded.id.clone();
3121 let path = tmp.path().join(&seeded.file_path);
3122 let raw = std::fs::read_to_string(&path).expect("seeded file");
3123 let doctored = raw.replace("fixture identity body", "intro\n\n```rust\nfn main() {}");
3126 assert_ne!(doctored, raw, "the seeded body must be there to doctor");
3127 std::fs::write(&path, doctored).unwrap();
3128 let mem_dir = tmp.path().to_path_buf();
3129 let writer = FilesystemMemWriter::new(mem_dir.clone());
3130 let engine = Engine::from_mounts(vec![(
3131 folder_mount("specs", mem_dir),
3132 Box::new(writer) as Box<dyn MemBackend>,
3133 )])
3134 .unwrap();
3135 drop(seeded);
3136 (engine, id)
3137 }
3138
3139 #[test]
3140 fn a_write_that_does_not_resolve_an_open_fence_is_refused() {
3141 let tmp = TempDir::new().unwrap();
3142 let (mut engine, id) = engine_with_open_fence(&tmp);
3143 let (actor, client) = cli_actor();
3144 let stored = engine.get_entity(&id).expect("entity loads");
3150 assert!(
3154 stored
3155 .sections
3156 .get("purpose")
3157 .is_none_or(|v| v.trim().is_empty()),
3158 "purpose should read as absent or empty: {:?}",
3159 stored.sections.get("purpose")
3160 );
3161 assert!(
3162 stored.sections["identity"].contains("## Purpose"),
3163 "its content is inside identity: {:?}",
3164 stored.sections.get("identity")
3165 );
3166 let hash = stored.content_hash.clone();
3167
3168 let err = engine
3169 .update_entity(
3170 UpdateEntityArgs {
3171 anchors: Vec::new(),
3172 id: id.clone(),
3173 expected_hash: Some(hash),
3174 sections: IndexMap::from_iter([(
3175 "purpose".to_string(),
3176 "a new purpose".to_string(),
3177 )]),
3178 append_sections: IndexMap::new(),
3179 patch_sections: IndexMap::new(),
3180 sections_unset: Vec::new(),
3181 metadata: IndexMap::new(),
3182 metadata_unset: Vec::new(),
3183 declare_relations: Vec::new(),
3184 dry_run: false,
3185 relations_unset: Vec::new(),
3186 anchors_unset: Vec::new(),
3187 },
3188 actor,
3189 Some(&client),
3190 None,
3191 )
3192 .unwrap_err();
3193 match err {
3194 EngineError::UnterminatedFenceInStoredBody {
3195 ref section,
3196 ref fence,
3197 ref swallowed,
3198 ..
3199 } => {
3200 assert_eq!(section, "identity");
3201 assert_eq!(fence, "```");
3202 assert_eq!(swallowed, &vec!["Purpose".to_string()]);
3207 }
3208 other => panic!("expected UnterminatedFenceInStoredBody, got {other:?}"),
3209 }
3210 assert_eq!(err.code(), "UNTERMINATED_FENCE_IN_STORED_BODY");
3211 }
3212
3213 #[test]
3214 fn replacing_the_absorbing_section_is_the_way_out() {
3215 let tmp = TempDir::new().unwrap();
3220 let (mut engine, id) = engine_with_open_fence(&tmp);
3221 let (actor, client) = cli_actor();
3222 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3223 let outcome = engine
3224 .update_entity(
3225 UpdateEntityArgs {
3226 anchors: Vec::new(),
3227 id: id.clone(),
3228 expected_hash: Some(hash),
3229 sections: IndexMap::from_iter([
3230 (
3231 "identity".to_string(),
3232 "intro\n\n```rust\nfn main() {}\n```".to_string(),
3233 ),
3234 ("purpose".to_string(), "the recovered purpose".to_string()),
3235 ]),
3236 append_sections: IndexMap::new(),
3237 patch_sections: IndexMap::new(),
3238 sections_unset: Vec::new(),
3239 metadata: IndexMap::new(),
3240 metadata_unset: Vec::new(),
3241 declare_relations: Vec::new(),
3242 dry_run: false,
3243 relations_unset: Vec::new(),
3244 anchors_unset: Vec::new(),
3245 },
3246 actor,
3247 Some(&client),
3248 None,
3249 )
3250 .expect("a corrected body for the absorbing section is admitted");
3251 assert!(
3252 outcome
3253 .modified_sections
3254 .replaced
3255 .contains(&"identity".to_string())
3256 );
3257 let fixed = engine.get_entity(&id).unwrap();
3258 assert_eq!(
3259 fixed.sections.get("purpose").map(String::as_str),
3260 Some("the recovered purpose"),
3261 "the swallowed section is a section again"
3262 );
3263 assert!(
3264 crate::markdown::closing_fence_if_unterminated(fixed.sections.get("identity").unwrap())
3265 .is_none()
3266 );
3267 }
3268
3269 #[test]
3275 fn every_verb_that_regenerates_the_file_is_gated_not_only_update() {
3276 let tmp = TempDir::new().unwrap();
3277 let (mut engine, id) = engine_with_open_fence(&tmp);
3278 let (actor, client) = cli_actor();
3279 let before =
3280 std::fs::read_to_string(tmp.path().join(&engine.get_entity(&id).unwrap().file_path))
3281 .unwrap();
3282 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3283
3284 let err = engine
3285 .relate_entity(
3286 RelateEntityArgs {
3287 source: id.clone(),
3288 expected_hash: Some(hash),
3289 rel_type: "USES".to_string(),
3290 target: crate::EntityId::new("specs", "some-target"),
3291 remove: false,
3292 description: None,
3293 dry_run: false,
3294 },
3295 actor,
3296 Some(&client),
3297 None,
3298 )
3299 .expect_err("relate must not be able to freeze the absorption");
3300 assert_eq!(err.code(), "UNTERMINATED_FENCE_IN_STORED_BODY");
3301
3302 let err = engine
3303 .rename_entity(
3304 crate::engine::RenameEntityArgs {
3305 id: id.clone(),
3306 new_title: "Renamed Fenced".to_string(),
3307 expected_hash: Some(engine.get_entity(&id).unwrap().content_hash.clone()),
3308 },
3309 actor,
3310 Some(&client),
3311 None,
3312 )
3313 .expect_err("rename must not be able to freeze the absorption either");
3314 assert_eq!(err.code(), "UNTERMINATED_FENCE_IN_STORED_BODY");
3315
3316 let after =
3318 std::fs::read_to_string(tmp.path().join(&engine.get_entity(&id).unwrap().file_path))
3319 .unwrap();
3320 assert_eq!(before, after, "a refused write must not touch the file");
3321 }
3322
3323 #[test]
3324 fn an_entity_with_no_open_fence_updates_exactly_as_before() {
3325 let tmp = TempDir::new().unwrap();
3328 let (mut engine, seeded) = engine_with_seed(&tmp, "Ordinary");
3329 let (actor, client) = cli_actor();
3330 engine
3331 .update_entity(
3332 UpdateEntityArgs {
3333 anchors: Vec::new(),
3334 id: seeded.id.clone(),
3335 expected_hash: Some(seeded.content_hash.clone()),
3336 sections: IndexMap::from_iter([(
3337 "purpose".to_string(),
3338 "a new purpose".to_string(),
3339 )]),
3340 append_sections: IndexMap::new(),
3341 patch_sections: IndexMap::new(),
3342 sections_unset: Vec::new(),
3343 metadata: IndexMap::new(),
3344 metadata_unset: Vec::new(),
3345 declare_relations: Vec::new(),
3346 dry_run: false,
3347 relations_unset: Vec::new(),
3348 anchors_unset: Vec::new(),
3349 },
3350 actor,
3351 Some(&client),
3352 None,
3353 )
3354 .expect("an ordinary update is untouched by the fence gate");
3355 }
3356
3357 #[test]
3364 fn update_entity_against_stub_surfaces_typed_stub_not_updatable() {
3365 let tmp = TempDir::new().unwrap();
3366 let (mut engine, source) = engine_with_seed(&tmp, "Source");
3367 let (actor, client) = cli_actor();
3368 let stub_id = crate::EntityId::new("specs", "stub-update-target");
3371 engine
3372 .relate_entity(
3373 RelateEntityArgs {
3374 source: source.id.clone(),
3375 expected_hash: Some(source.content_hash.clone()),
3376 rel_type: "USES".to_string(),
3377 target: stub_id.clone(),
3378 remove: false,
3379 description: None,
3380 dry_run: false,
3381 },
3382 actor,
3383 Some(&client),
3384 None,
3385 )
3386 .unwrap();
3387
3388 let err = engine
3389 .update_entity(
3390 UpdateEntityArgs {
3391 anchors: Vec::new(),
3392 id: stub_id.clone(),
3393 expected_hash: Some(String::new()),
3394 sections: IndexMap::from_iter([("identity".to_string(), "body".to_string())]),
3395 append_sections: IndexMap::new(),
3396 patch_sections: IndexMap::new(),
3397 sections_unset: Vec::new(),
3398 metadata: IndexMap::new(),
3399 metadata_unset: Vec::new(),
3400 declare_relations: Vec::new(),
3401 dry_run: false,
3402 relations_unset: Vec::new(),
3403 anchors_unset: Vec::new(),
3404 },
3405 actor,
3406 Some(&client),
3407 None,
3408 )
3409 .unwrap_err();
3410 match err {
3411 EngineError::StubNotUpdatable { id } => assert_eq!(id, stub_id.to_string()),
3412 other => panic!("expected StubNotUpdatable, got {other:?}"),
3413 }
3414 }
3415
3416 #[test]
3417 fn update_entity_rejects_conflicting_section_modes() {
3418 let tmp = TempDir::new().unwrap();
3419 let (mut engine, seeded) = engine_with_seed(&tmp, "Conflict");
3420 let (actor, client) = cli_actor();
3421
3422 let mut sections = IndexMap::new();
3423 sections.insert("identity".to_string(), "replace".to_string());
3424 let mut appends = IndexMap::new();
3425 appends.insert("identity".to_string(), "append".to_string());
3426
3427 let err = engine
3428 .update_entity(
3429 UpdateEntityArgs {
3430 anchors: Vec::new(),
3431 id: seeded.id.clone(),
3432 expected_hash: Some(seeded.content_hash.clone()),
3433 sections,
3434 append_sections: appends,
3435 patch_sections: IndexMap::new(),
3436 sections_unset: Vec::new(),
3437 metadata: IndexMap::new(),
3438 metadata_unset: Vec::new(),
3439 declare_relations: Vec::new(),
3440 dry_run: false,
3441 relations_unset: Vec::new(),
3442 anchors_unset: Vec::new(),
3443 },
3444 actor,
3445 Some(&client),
3446 None,
3447 )
3448 .unwrap_err();
3449
3450 match err {
3451 EngineError::ConflictingSectionModes { section, modes } => {
3452 assert_eq!(section, "identity");
3453 assert_eq!(modes, vec!["sections", "append_sections"]);
3454 }
3455 other => panic!("expected ConflictingSectionModes, got {other:?}"),
3456 }
3457 }
3458
3459 #[test]
3460 fn update_entity_rejects_overlapping_metadata_and_metadata_unset_keys() {
3461 let tmp = TempDir::new().unwrap();
3466 let (mut engine, seeded) = engine_with_seed(&tmp, "Overlap Subject");
3467 let (actor, client) = cli_actor();
3468
3469 let mut metadata = IndexMap::new();
3470 metadata.insert("tags".to_string(), "foo".to_string());
3474
3475 let err = engine
3476 .update_entity(
3477 UpdateEntityArgs {
3478 anchors: Vec::new(),
3479 id: seeded.id.clone(),
3480 expected_hash: Some(seeded.content_hash.clone()),
3481 sections: IndexMap::new(),
3482 append_sections: IndexMap::new(),
3483 patch_sections: IndexMap::new(),
3484 sections_unset: Vec::new(),
3485 metadata,
3486 metadata_unset: vec!["tags".to_string()],
3487 declare_relations: Vec::new(),
3488 dry_run: false,
3489 relations_unset: Vec::new(),
3490 anchors_unset: Vec::new(),
3491 },
3492 actor,
3493 Some(&client),
3494 None,
3495 )
3496 .unwrap_err();
3497 match err {
3498 EngineError::SetAndUnsetConflict { keys } => {
3499 assert_eq!(keys, vec!["tags".to_string()]);
3500 }
3501 other => panic!("expected SetAndUnsetConflict, got {other:?}"),
3502 }
3503 }
3504
3505 #[test]
3506 fn update_entity_pointer_schema_auto_synthesises_references_from_body_link() {
3507 use crate::EntityId;
3515 use crate::engine::UpdateEntityArgs;
3516 use indexmap::IndexMap;
3517 use tempfile::TempDir;
3518
3519 let tmp = TempDir::new().unwrap();
3520 let mem_dir = tmp.path().to_path_buf();
3521 let writer = FilesystemMemWriter::new(mem_dir.clone());
3522 let mut engine = Engine::from_mounts(vec![(
3523 folder_mount("specs", mem_dir.clone()),
3524 Box::new(writer) as Box<dyn MemBackend>,
3525 )])
3526 .unwrap();
3527 engine.set_workspace_root(mem_dir.clone());
3528 let (actor, client) = cli_actor();
3529
3530 let target = engine
3531 .create_entity(
3532 empty_create_args("specs", "Target"),
3533 actor,
3534 Some(&client),
3535 None,
3536 )
3537 .unwrap();
3538 let source = engine
3539 .create_entity(
3540 empty_create_args("specs", "Source"),
3541 actor,
3542 Some(&client),
3543 None,
3544 )
3545 .unwrap();
3546
3547 let mut sections: IndexMap<String, String> = IndexMap::new();
3548 sections.insert(
3549 "purpose".to_string(),
3550 "see [[target]] for context".to_string(),
3551 );
3552 let outcome = engine
3553 .update_entity(
3554 UpdateEntityArgs {
3555 anchors: Vec::new(),
3556 id: source.id.clone(),
3557 expected_hash: Some(source.content_hash.clone()),
3558 sections,
3559 append_sections: IndexMap::new(),
3560 patch_sections: IndexMap::new(),
3561 sections_unset: Vec::new(),
3562 metadata: IndexMap::new(),
3563 metadata_unset: Vec::new(),
3564 declare_relations: Vec::new(),
3565 dry_run: false,
3566 relations_unset: Vec::new(),
3567 anchors_unset: Vec::new(),
3568 },
3569 actor,
3570 Some(&client),
3571 None,
3572 )
3573 .expect("auto-synthesis must satisfy the alias-existence invariant");
3574 assert!(
3576 outcome
3577 .modified_sections
3578 .replaced
3579 .iter()
3580 .any(|s| s == "purpose"),
3581 );
3582 let in_mem = engine.get_entity(&source.id).unwrap();
3583 assert_eq!(
3584 in_mem
3585 .sections
3586 .get("purpose")
3587 .map(String::as_str)
3588 .unwrap_or(""),
3589 "see [[target]] for context",
3590 );
3591 assert!(
3593 in_mem
3594 .relationships
3595 .iter()
3596 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3597 "synthesis must emit REFERENCES → target; relationships: {:?}",
3598 in_mem.relationships,
3599 );
3600 let _ = EntityId::new("specs", "x");
3602 }
3603
3604 #[test]
3605 fn update_entity_declare_relations_passes_strict_validator_in_one_call() {
3606 use crate::engine::UpdateEntityArgs;
3613 use crate::ops::RelateArg;
3614 use indexmap::IndexMap;
3615 use tempfile::TempDir;
3616
3617 let tmp = TempDir::new().unwrap();
3618 let mem_dir = tmp.path().to_path_buf();
3619 let writer = FilesystemMemWriter::new(mem_dir.clone());
3620 let mut engine = Engine::from_mounts(vec![(
3621 folder_mount("specs", mem_dir.clone()),
3622 Box::new(writer) as Box<dyn MemBackend>,
3623 )])
3624 .unwrap();
3625 engine.set_workspace_root(mem_dir.clone());
3626 let (actor, client) = cli_actor();
3627
3628 let target = engine
3629 .create_entity(
3630 empty_create_args("specs", "Target"),
3631 actor,
3632 Some(&client),
3633 None,
3634 )
3635 .unwrap();
3636 let source = engine
3637 .create_entity(
3638 empty_create_args("specs", "Source"),
3639 actor,
3640 Some(&client),
3641 None,
3642 )
3643 .unwrap();
3644
3645 let mut sections: IndexMap<String, String> = IndexMap::new();
3653 sections.insert(
3654 "purpose".to_string(),
3655 "see [[target]] for context".to_string(),
3656 );
3657 let outcome = engine
3658 .update_entity(
3659 UpdateEntityArgs {
3660 anchors: Vec::new(),
3661 relations_unset: Vec::new(),
3662 anchors_unset: Vec::new(),
3663 id: source.id.clone(),
3664 expected_hash: Some(source.content_hash.clone()),
3665 sections,
3666 append_sections: IndexMap::new(),
3667 patch_sections: IndexMap::new(),
3668 sections_unset: Vec::new(),
3669 metadata: IndexMap::new(),
3670 metadata_unset: Vec::new(),
3671 dry_run: false,
3672 declare_relations: vec![RelateArg {
3673 rel_type: "USES".to_string(),
3674 target: target.id.clone(),
3675 description: None,
3676 }],
3677 },
3678 actor,
3679 Some(&client),
3680 None,
3681 )
3682 .expect("declare_relations + body update must succeed in one call");
3683
3684 assert_eq!(outcome.relations_declared.len(), 1);
3685 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
3686 assert_eq!(outcome.relations_declared[0].target, target.id);
3687 assert!(
3688 !outcome.relations_declared[0].target_was_stubbed,
3689 "target was already present in store; target_was_stubbed must be false"
3690 );
3691
3692 let in_mem = engine.get_entity(&source.id).unwrap();
3693 assert!(
3694 in_mem.relationships.iter().any(|r| r.target == target.id),
3695 "declared relation must land in entity.relationships; got {:?}",
3696 in_mem.relationships
3697 );
3698 }
3699
3700 #[test]
3701 fn update_entity_declare_relations_auto_stubs_absent_target() {
3702 use crate::EntityId;
3706 use crate::engine::UpdateEntityArgs;
3707 use crate::ops::RelateArg;
3708 use indexmap::IndexMap;
3709
3710 let tmp = TempDir::new().unwrap();
3711 let (mut engine, source) = engine_with_seed(&tmp, "Source");
3712 let (actor, client) = cli_actor();
3713 let absent_target = EntityId::new("specs", "not-yet-existing");
3714 assert!(!engine.store().contains(&absent_target));
3715
3716 let outcome = engine
3717 .update_entity(
3718 UpdateEntityArgs {
3719 anchors: Vec::new(),
3720 relations_unset: Vec::new(),
3721 anchors_unset: Vec::new(),
3722 id: source.id.clone(),
3723 expected_hash: Some(source.content_hash.clone()),
3724 sections: IndexMap::new(),
3725 append_sections: IndexMap::new(),
3726 patch_sections: IndexMap::new(),
3727 sections_unset: Vec::new(),
3728 metadata: IndexMap::new(),
3729 metadata_unset: Vec::new(),
3730 dry_run: false,
3731 declare_relations: vec![RelateArg {
3732 rel_type: "USES".to_string(),
3733 target: absent_target.clone(),
3734 description: None,
3735 }],
3736 },
3737 actor,
3738 Some(&client),
3739 None,
3740 )
3741 .unwrap();
3742
3743 assert_eq!(outcome.relations_declared.len(), 1);
3744 assert!(
3745 outcome.relations_declared[0].target_was_stubbed,
3746 "absent target must be auto-stubbed; got target_was_stubbed=false"
3747 );
3748 assert!(engine.store().contains(&absent_target));
3750 let stub = engine.get_entity(&absent_target).unwrap();
3751 assert!(stub.stub);
3752 }
3753
3754 #[test]
3755 fn update_entity_alias_synthesis_runs_unconditionally_for_pointer_schemas() {
3756 use crate::engine::UpdateEntityArgs;
3762 use indexmap::IndexMap;
3763 use tempfile::TempDir;
3764
3765 let tmp = TempDir::new().unwrap();
3766 let mem_dir = tmp.path().to_path_buf();
3767 let writer = FilesystemMemWriter::new(mem_dir.clone());
3768 let mut engine = Engine::from_mounts(vec![(
3769 folder_mount("specs", mem_dir.clone()),
3770 Box::new(writer) as Box<dyn MemBackend>,
3771 )])
3772 .unwrap();
3773 engine.set_workspace_root(mem_dir.clone());
3774 let (actor, client) = cli_actor();
3775 let target = engine
3776 .create_entity(
3777 empty_create_args("specs", "Target"),
3778 actor,
3779 Some(&client),
3780 None,
3781 )
3782 .unwrap();
3783 let source = engine
3784 .create_entity(
3785 empty_create_args("specs", "Source"),
3786 actor,
3787 Some(&client),
3788 None,
3789 )
3790 .unwrap();
3791
3792 let mut sections: IndexMap<String, String> = IndexMap::new();
3793 sections.insert(
3794 "purpose".to_string(),
3795 "see [[target]] for context".to_string(),
3796 );
3797 engine
3798 .update_entity(
3799 UpdateEntityArgs {
3800 anchors: Vec::new(),
3801 id: source.id.clone(),
3802 expected_hash: Some(source.content_hash.clone()),
3803 sections,
3804 append_sections: IndexMap::new(),
3805 patch_sections: IndexMap::new(),
3806 sections_unset: Vec::new(),
3807 metadata: IndexMap::new(),
3808 metadata_unset: Vec::new(),
3809 declare_relations: Vec::new(),
3810 dry_run: false,
3811 relations_unset: Vec::new(),
3812 anchors_unset: Vec::new(),
3813 },
3814 actor,
3815 Some(&client),
3816 None,
3817 )
3818 .expect("synthesis must back the wiki-link and let the body land");
3819 let in_mem = engine.get_entity(&source.id).unwrap();
3820 assert!(
3821 in_mem
3822 .relationships
3823 .iter()
3824 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3825 "synthesis must emit REFERENCES → target; relationships: {:?}",
3826 in_mem.relationships,
3827 );
3828 }
3829
3830 #[test]
3831 fn update_entity_dry_run_returns_prospective_hash_without_writing() {
3832 let tmp = TempDir::new().unwrap();
3833 let (mut engine, seeded) = engine_with_seed(&tmp, "Preview Subject");
3834 let (actor, client) = cli_actor();
3835 let original_hash = seeded.content_hash.clone();
3836
3837 let mut sections = IndexMap::new();
3838 sections.insert("identity".to_string(), "preview body".to_string());
3839
3840 let outcome = engine
3841 .update_entity(
3842 UpdateEntityArgs {
3843 anchors: Vec::new(),
3844 id: seeded.id.clone(),
3845 expected_hash: Some("wrong-hash".to_string()),
3848 sections,
3849 append_sections: IndexMap::new(),
3850 patch_sections: IndexMap::new(),
3851 sections_unset: Vec::new(),
3852 metadata: IndexMap::new(),
3853 metadata_unset: Vec::new(),
3854 declare_relations: Vec::new(),
3855 dry_run: true,
3856 relations_unset: Vec::new(),
3857 anchors_unset: Vec::new(),
3858 },
3859 actor,
3860 Some(&client),
3861 None,
3862 )
3863 .unwrap();
3864
3865 assert_eq!(outcome.content_hash, original_hash);
3868 let prospective = outcome
3869 .prospective_hash
3870 .expect("prospective_hash populated on dry_run");
3871 assert_ne!(prospective, original_hash);
3872 assert!(outcome.write_id.is_empty());
3873 let store_entity = engine.get_entity(&seeded.id).unwrap();
3875 assert_eq!(store_entity.content_hash, original_hash);
3876 }
3877
3878 #[test]
3897 fn references_edges_round_trip_across_full_crud_cycle() {
3898 let tmp = TempDir::new().unwrap();
3899 let mem_dir = tmp.path().to_path_buf();
3900 let writer = FilesystemMemWriter::new(mem_dir.clone());
3901 let mut engine = Engine::from_mounts(vec![(
3902 folder_mount("specs", mem_dir),
3903 Box::new(writer) as Box<dyn MemBackend>,
3904 )])
3905 .unwrap();
3906 let (actor, client) = cli_actor();
3907
3908 let foo = engine
3912 .create_entity(
3913 empty_create_args("specs", "Foo"),
3914 actor,
3915 Some(&client),
3916 None,
3917 )
3918 .unwrap();
3919 let bar = engine
3920 .create_entity(
3921 empty_create_args("specs", "Bar"),
3922 actor,
3923 Some(&client),
3924 None,
3925 )
3926 .unwrap();
3927
3928 let count_references = |engine: &Engine| -> usize {
3929 engine
3930 .store()
3931 .all_ids()
3932 .flat_map(|id| engine.store().outgoing(id))
3933 .filter(|e| e.rel_type == "REFERENCES")
3934 .count()
3935 };
3936
3937 let baseline_edges = engine.store().edge_count();
3938 let baseline_refs = count_references(&engine);
3939
3940 let mut sections = IndexMap::new();
3946 sections.insert(
3947 "identity".to_string(),
3948 "See [[foo]] and [[bar]] inline.".to_string(),
3949 );
3950 sections.insert("purpose".to_string(), "probe purpose".to_string());
3951 let probe = engine
3952 .create_entity(
3953 CreateEntityArgs {
3954 anchors: Vec::new(),
3955 mem: "specs".to_string(),
3956 title: "Probe".to_string(),
3957 entity_type: "spec".to_string(),
3958 sections,
3959 metadata: IndexMap::new(),
3960 relations: Vec::new(),
3961 dry_run: false,
3962 },
3963 actor,
3964 Some(&client),
3965 None,
3966 )
3967 .unwrap();
3968 assert_eq!(count_references(&engine), baseline_refs + 2);
3969
3970 let relate1 = engine
3975 .relate_entity(
3976 RelateEntityArgs {
3977 source: probe.id.clone(),
3978 expected_hash: Some(probe.content_hash.clone()),
3979 rel_type: "INFORMED_BY".to_string(),
3980 target: foo.id.clone(),
3981 remove: false,
3982 description: None,
3983 dry_run: false,
3984 },
3985 actor,
3986 Some(&client),
3987 None,
3988 )
3989 .unwrap();
3990 assert_eq!(
3991 count_references(&engine),
3992 baseline_refs + 2,
3993 "set-membership aliasing — adding INFORMED_BY does not \
3994 absorb the REFERENCES relation"
3995 );
3996
3997 let mut sections = IndexMap::new();
4001 sections.insert("identity".to_string(), "See [[foo]] inline.".to_string());
4002 let updated = engine
4003 .update_entity(
4004 UpdateEntityArgs {
4005 anchors: Vec::new(),
4006 id: probe.id.clone(),
4007 expected_hash: Some(relate1.content_hash.clone()),
4008 sections,
4009 append_sections: IndexMap::new(),
4010 patch_sections: IndexMap::new(),
4011 sections_unset: Vec::new(),
4012 metadata: IndexMap::new(),
4013 metadata_unset: Vec::new(),
4014 declare_relations: Vec::new(),
4015 dry_run: false,
4016 relations_unset: Vec::new(),
4017 anchors_unset: Vec::new(),
4018 },
4019 actor,
4020 Some(&client),
4021 None,
4022 )
4023 .unwrap();
4024 assert_eq!(
4025 count_references(&engine),
4026 baseline_refs + 1,
4027 "REFERENCES → bar must be auto-GC'd when its body link drops"
4028 );
4029
4030 let renamed = engine
4032 .rename_entity(
4033 crate::engine::RenameEntityArgs {
4034 id: probe.id.clone(),
4035 expected_hash: Some(updated.content_hash.clone()),
4036 new_title: "Probe Renamed".to_string(),
4037 },
4038 actor,
4039 Some(&client),
4040 None,
4041 )
4042 .unwrap();
4043 assert_eq!(count_references(&engine), baseline_refs + 1);
4044
4045 engine
4048 .delete_entity(
4049 crate::engine::DeleteEntityArgs {
4050 id: renamed.new_id.clone(),
4051 expected_hash: Some(renamed.content_hash.clone()),
4052 },
4053 actor,
4054 Some(&client),
4055 None,
4056 )
4057 .unwrap();
4058
4059 assert_eq!(
4061 engine.store().edge_count(),
4062 baseline_edges,
4063 "total edges must round-trip to baseline"
4064 );
4065 assert_eq!(
4066 count_references(&engine),
4067 baseline_refs,
4068 "REFERENCES counter must round-trip to baseline"
4069 );
4070
4071 engine.reload_one_mem("specs").unwrap();
4075 assert_eq!(
4076 engine.store().edge_count(),
4077 baseline_edges,
4078 "total edges must match disk after reload"
4079 );
4080 assert_eq!(
4081 count_references(&engine),
4082 baseline_refs,
4083 "REFERENCES must match disk after reload"
4084 );
4085 assert!(engine.store().contains(&foo.id));
4087 assert!(engine.store().contains(&bar.id));
4088 }
4089
4090 #[test]
4091 fn update_entity_returns_write_id_title_modified_date_warnings_shape() {
4092 let tmp = TempDir::new().unwrap();
4093 let (mut engine, seeded) = engine_with_seed(&tmp, "Subject");
4094 let (actor, client) = cli_actor();
4095
4096 let mut sections = IndexMap::new();
4097 sections.insert("identity".to_string(), "edited body".to_string());
4098
4099 let outcome = engine
4100 .update_entity(
4101 UpdateEntityArgs {
4102 anchors: Vec::new(),
4103 id: seeded.id.clone(),
4104 expected_hash: Some(seeded.content_hash.clone()),
4105 sections,
4106 append_sections: IndexMap::new(),
4107 patch_sections: IndexMap::new(),
4108 sections_unset: Vec::new(),
4109 metadata: IndexMap::new(),
4110 metadata_unset: Vec::new(),
4111 declare_relations: Vec::new(),
4112 dry_run: false,
4113 relations_unset: Vec::new(),
4114 anchors_unset: Vec::new(),
4115 },
4116 actor,
4117 Some(&client),
4118 None,
4119 )
4120 .unwrap();
4121
4122 assert!(
4124 !outcome.write_id.is_empty(),
4125 "write_id must be populated on a real update"
4126 );
4127 assert_eq!(outcome.title, "Subject");
4129 assert!(
4134 !outcome.modified_date.is_empty(),
4135 "modified_date must be auto-stamped on update for the default spec schema",
4136 );
4137 assert!(outcome.warnings.is_empty());
4141 assert_eq!(
4143 outcome.modified_sections.replaced,
4144 vec!["identity".to_string()]
4145 );
4146 }
4147
4148 #[test]
4157 fn update_entity_noop_resetting_section_to_current_value_preserves_last_modified() {
4158 let tmp = TempDir::new().unwrap();
4159 let (mut engine, seeded) = engine_with_seed(&tmp, "Section Resetter");
4160 let (actor, client) = cli_actor();
4161
4162 let pre_last_modified = engine
4165 .get_entity(&seeded.id)
4166 .and_then(|e| e.metadata.get("last_modified"))
4167 .map(|v| v.to_frontmatter_string())
4168 .expect("seeded entity has last_modified");
4169
4170 let mut sections = IndexMap::new();
4174 sections.insert("identity".to_string(), "fixture identity body".to_string());
4175 let outcome = engine
4176 .update_entity(
4177 UpdateEntityArgs {
4178 anchors: Vec::new(),
4179 id: seeded.id.clone(),
4180 expected_hash: Some(seeded.content_hash.clone()),
4181 sections,
4182 append_sections: IndexMap::new(),
4183 patch_sections: IndexMap::new(),
4184 sections_unset: Vec::new(),
4185 metadata: IndexMap::new(),
4186 metadata_unset: Vec::new(),
4187 declare_relations: Vec::new(),
4188 dry_run: false,
4189 relations_unset: Vec::new(),
4190 anchors_unset: Vec::new(),
4191 },
4192 actor,
4193 Some(&client),
4194 None,
4195 )
4196 .unwrap();
4197
4198 assert_eq!(outcome.write_id, "", "no-op must not commit");
4199 assert_eq!(
4200 outcome.content_hash, seeded.content_hash,
4201 "no-op must not advance content_hash",
4202 );
4203 assert!(
4204 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4205 "UPDATE_NOOP must fire on bytes-identical re-set",
4206 );
4207 assert_eq!(
4208 outcome.modified_date, pre_last_modified,
4209 "no-op must preserve last_modified at the pre-call value",
4210 );
4211 assert!(
4216 outcome.modified_sections.replaced.is_empty()
4217 && outcome.modified_sections.appended.is_empty()
4218 && outcome.modified_sections.patched.is_empty(),
4219 "no-op must report an empty section delta, got {:?}",
4220 outcome.modified_sections,
4221 );
4222
4223 let post_last_modified = engine
4227 .get_entity(&seeded.id)
4228 .and_then(|e| e.metadata.get("last_modified"))
4229 .map(|v| v.to_frontmatter_string())
4230 .expect("entity still in store");
4231 assert_eq!(post_last_modified, pre_last_modified);
4232 }
4233
4234 #[test]
4246 fn update_entity_empty_payload_refuses_with_typed_code() {
4247 let tmp = TempDir::new().unwrap();
4248 let (mut engine, seeded) = engine_with_seed(&tmp, "Empty Payload");
4249 let (actor, client) = cli_actor();
4250
4251 let err = engine
4252 .update_entity(
4253 UpdateEntityArgs {
4254 anchors: Vec::new(),
4255 id: seeded.id.clone(),
4256 expected_hash: Some(seeded.content_hash.clone()),
4257 sections: IndexMap::new(),
4258 append_sections: IndexMap::new(),
4259 patch_sections: IndexMap::new(),
4260 sections_unset: Vec::new(),
4261 metadata: IndexMap::new(),
4262 metadata_unset: Vec::new(),
4263 declare_relations: Vec::new(),
4264 dry_run: false,
4265 relations_unset: Vec::new(),
4266 anchors_unset: Vec::new(),
4267 },
4268 actor,
4269 Some(&client),
4270 None,
4271 )
4272 .unwrap_err();
4273 match err {
4274 EngineError::EmptyUpdate { id } => {
4275 assert_eq!(id, seeded.id.to_string());
4276 }
4277 other => panic!("expected EMPTY_UPDATE, got {other:?}"),
4278 }
4279 let log_path = tmp.path().join(".memstead/changes.jsonl");
4281 if let Ok(log) = std::fs::read_to_string(&log_path) {
4282 let updates = log.matches("\"kind\":\"update\"").count();
4283 assert_eq!(updates, 0, "EMPTY_UPDATE refusal must not log an update");
4284 }
4285 }
4286
4287 #[test]
4293 fn update_entity_noop_same_content_surfaces_warning() {
4294 let tmp = TempDir::new().unwrap();
4295 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Content Noop");
4296 let (actor, client) = cli_actor();
4297
4298 let mut sections = IndexMap::new();
4300 sections.insert("identity".to_string(), "fixture identity body".to_string());
4301
4302 let outcome = engine
4303 .update_entity(
4304 UpdateEntityArgs {
4305 anchors: Vec::new(),
4306 id: seeded.id.clone(),
4307 expected_hash: Some(seeded.content_hash.clone()),
4308 sections,
4309 append_sections: IndexMap::new(),
4310 patch_sections: IndexMap::new(),
4311 sections_unset: Vec::new(),
4312 metadata: IndexMap::new(),
4313 metadata_unset: Vec::new(),
4314 declare_relations: Vec::new(),
4315 dry_run: false,
4316 relations_unset: Vec::new(),
4317 anchors_unset: Vec::new(),
4318 },
4319 actor,
4320 Some(&client),
4321 None,
4322 )
4323 .unwrap();
4324
4325 assert_eq!(outcome.write_id, "");
4326 assert_eq!(outcome.content_hash, seeded.content_hash);
4327 let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code()).collect();
4328 assert!(
4329 codes.contains(&"UPDATE_NOOP"),
4330 "same-content update must surface UPDATE_NOOP; got {codes:?}",
4331 );
4332 }
4333
4334 #[test]
4335 fn update_entity_noop_metadata_unset_on_absent_key() {
4336 let tmp = TempDir::new().unwrap();
4341 let (mut engine, seeded) = engine_with_seed(&tmp, "Absent Key Noop");
4342 let (actor, client) = cli_actor();
4343
4344 let outcome = engine
4345 .update_entity(
4346 UpdateEntityArgs {
4347 anchors: Vec::new(),
4348 id: seeded.id.clone(),
4349 expected_hash: Some(seeded.content_hash.clone()),
4350 sections: IndexMap::new(),
4351 append_sections: IndexMap::new(),
4352 patch_sections: IndexMap::new(),
4353 sections_unset: Vec::new(),
4354 metadata: IndexMap::new(),
4355 metadata_unset: vec!["tags".to_string()],
4359 declare_relations: Vec::new(),
4360 dry_run: false,
4361 relations_unset: Vec::new(),
4362 anchors_unset: Vec::new(),
4363 },
4364 actor,
4365 Some(&client),
4366 None,
4367 )
4368 .unwrap();
4369
4370 assert_eq!(outcome.write_id, "");
4371 assert_eq!(outcome.content_hash, seeded.content_hash);
4372 assert!(
4373 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4374 "absent-key metadata_unset must surface UPDATE_NOOP",
4375 );
4376 assert!(
4379 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
4380 "no-op must report an empty metadata delta, got {:?}",
4381 outcome.modified_metadata,
4382 );
4383
4384 let mut sections = IndexMap::new();
4387 sections.insert("identity".to_string(), "real change".to_string());
4388 let real = engine
4389 .update_entity(
4390 UpdateEntityArgs {
4391 anchors: Vec::new(),
4392 id: seeded.id.clone(),
4393 expected_hash: Some(seeded.content_hash.clone()),
4394 sections,
4395 append_sections: IndexMap::new(),
4396 patch_sections: IndexMap::new(),
4397 sections_unset: Vec::new(),
4398 metadata: IndexMap::new(),
4399 metadata_unset: Vec::new(),
4400 declare_relations: Vec::new(),
4401 dry_run: false,
4402 relations_unset: Vec::new(),
4403 anchors_unset: Vec::new(),
4404 },
4405 actor,
4406 Some(&client),
4407 None,
4408 )
4409 .unwrap();
4410 assert!(!real.write_id.is_empty());
4411 assert_ne!(real.content_hash, seeded.content_hash);
4412 }
4413
4414 #[test]
4421 fn update_entity_noop_setting_metadata_to_current_value_reports_empty_delta() {
4422 let tmp = TempDir::new().unwrap();
4423 let (mut engine, seeded) = engine_with_seed(&tmp, "Stability Resetter");
4424 let (actor, client) = cli_actor();
4425
4426 let mut metadata = IndexMap::new();
4429 metadata.insert("level".to_string(), "M0".to_string());
4430 let outcome = engine
4431 .update_entity(
4432 UpdateEntityArgs {
4433 anchors: Vec::new(),
4434 id: seeded.id.clone(),
4435 expected_hash: Some(seeded.content_hash.clone()),
4436 sections: IndexMap::new(),
4437 append_sections: IndexMap::new(),
4438 patch_sections: IndexMap::new(),
4439 sections_unset: Vec::new(),
4440 metadata,
4441 metadata_unset: Vec::new(),
4442 declare_relations: Vec::new(),
4443 dry_run: false,
4444 relations_unset: Vec::new(),
4445 anchors_unset: Vec::new(),
4446 },
4447 actor,
4448 Some(&client),
4449 None,
4450 )
4451 .unwrap();
4452
4453 assert_eq!(outcome.write_id, "", "no-op must not commit");
4454 assert_eq!(
4455 outcome.content_hash, seeded.content_hash,
4456 "no-op must not advance hash"
4457 );
4458 assert!(
4459 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4460 "re-set to current value must surface UPDATE_NOOP",
4461 );
4462 assert!(
4463 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
4464 "no-op must not claim `level` was set — applied delta is empty, got {:?}",
4465 outcome.modified_metadata,
4466 );
4467 }
4468
4469 #[test]
4470 fn update_entity_noop_declare_already_related_edge() {
4471 use crate::ops::RelateArg;
4476 let tmp = TempDir::new().unwrap();
4477 let mem_dir = tmp.path().to_path_buf();
4478 let writer = FilesystemMemWriter::new(mem_dir.clone());
4479 let mut engine = Engine::from_mounts(vec![(
4480 folder_mount("specs", mem_dir),
4481 Box::new(writer) as Box<dyn MemBackend>,
4482 )])
4483 .unwrap();
4484 let (actor, client) = cli_actor();
4485 let target = engine
4486 .create_entity(
4487 empty_create_args("specs", "Target Already Related"),
4488 actor,
4489 Some(&client),
4490 None,
4491 )
4492 .unwrap();
4493 let source = engine
4494 .create_entity(
4495 empty_create_args("specs", "Source Already Related"),
4496 actor,
4497 Some(&client),
4498 None,
4499 )
4500 .unwrap();
4501 let after_relate = engine
4502 .relate_entity(
4503 RelateEntityArgs {
4504 source: source.id.clone(),
4505 expected_hash: Some(source.content_hash.clone()),
4506 rel_type: "USES".to_string(),
4507 target: target.id.clone(),
4508 remove: false,
4509 description: None,
4510 dry_run: false,
4511 },
4512 actor,
4513 Some(&client),
4514 None,
4515 )
4516 .unwrap();
4517 let outcome = engine
4519 .update_entity(
4520 UpdateEntityArgs {
4521 anchors: Vec::new(),
4522 relations_unset: Vec::new(),
4523 anchors_unset: Vec::new(),
4524 id: source.id.clone(),
4525 expected_hash: Some(after_relate.content_hash.clone()),
4526 sections: IndexMap::new(),
4527 append_sections: IndexMap::new(),
4528 patch_sections: IndexMap::new(),
4529 sections_unset: Vec::new(),
4530 metadata: IndexMap::new(),
4531 metadata_unset: Vec::new(),
4532 declare_relations: vec![RelateArg {
4533 rel_type: "USES".to_string(),
4534 target: target.id.clone(),
4535 description: None,
4536 }],
4537 dry_run: false,
4538 },
4539 actor,
4540 Some(&client),
4541 None,
4542 )
4543 .unwrap();
4544
4545 assert_eq!(outcome.write_id, "");
4546 assert_eq!(outcome.content_hash, after_relate.content_hash);
4547 assert!(
4548 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4549 "duplicate declare must surface UPDATE_NOOP",
4550 );
4551 assert_eq!(outcome.relations_declared.len(), 1);
4554 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
4555 assert_eq!(outcome.relations_declared[0].target, target.id);
4556 assert!(!outcome.relations_declared[0].target_was_stubbed);
4557 }
4558
4559 #[test]
4560 fn update_entity_real_change_still_commits_and_advances_hash() {
4561 let tmp = TempDir::new().unwrap();
4566 let (mut engine, seeded) = engine_with_seed(&tmp, "Real Change Subject");
4567 let (actor, client) = cli_actor();
4568
4569 let mut sections = IndexMap::new();
4570 sections.insert("identity".to_string(), "definitely new body".to_string());
4571
4572 let outcome = engine
4573 .update_entity(
4574 UpdateEntityArgs {
4575 anchors: Vec::new(),
4576 id: seeded.id.clone(),
4577 expected_hash: Some(seeded.content_hash.clone()),
4578 sections,
4579 append_sections: IndexMap::new(),
4580 patch_sections: IndexMap::new(),
4581 sections_unset: Vec::new(),
4582 metadata: IndexMap::new(),
4583 metadata_unset: Vec::new(),
4584 declare_relations: Vec::new(),
4585 dry_run: false,
4586 relations_unset: Vec::new(),
4587 anchors_unset: Vec::new(),
4588 },
4589 actor,
4590 Some(&client),
4591 None,
4592 )
4593 .unwrap();
4594
4595 assert!(!outcome.write_id.is_empty(), "real change must commit");
4596 assert_ne!(
4597 outcome.content_hash, seeded.content_hash,
4598 "real change must advance content_hash",
4599 );
4600 assert!(
4601 !outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4602 "real change must not surface UPDATE_NOOP",
4603 );
4604 }
4605
4606 #[test]
4607 fn update_entity_noop_preserves_expected_hash_across_chain() {
4608 let tmp = TempDir::new().unwrap();
4613 let (mut engine, seeded) = engine_with_seed(&tmp, "Chained Noops Subject");
4614 let (actor, client) = cli_actor();
4615
4616 let mut noop_sections = IndexMap::new();
4621 noop_sections.insert("identity".to_string(), "fixture identity body".to_string());
4622 for _ in 0..2 {
4623 let outcome = engine
4624 .update_entity(
4625 UpdateEntityArgs {
4626 anchors: Vec::new(),
4627 id: seeded.id.clone(),
4628 expected_hash: Some(seeded.content_hash.clone()),
4629 sections: noop_sections.clone(),
4630 append_sections: IndexMap::new(),
4631 patch_sections: IndexMap::new(),
4632 sections_unset: Vec::new(),
4633 metadata: IndexMap::new(),
4634 metadata_unset: Vec::new(),
4635 declare_relations: Vec::new(),
4636 dry_run: false,
4637 relations_unset: Vec::new(),
4638 anchors_unset: Vec::new(),
4639 },
4640 actor,
4641 Some(&client),
4642 None,
4643 )
4644 .unwrap();
4645 assert_eq!(outcome.write_id, "");
4646 assert_eq!(outcome.content_hash, seeded.content_hash);
4647 }
4648
4649 let mut sections = IndexMap::new();
4652 sections.insert(
4653 "identity".to_string(),
4654 "third call: real change".to_string(),
4655 );
4656 let real = engine
4657 .update_entity(
4658 UpdateEntityArgs {
4659 anchors: Vec::new(),
4660 id: seeded.id.clone(),
4661 expected_hash: Some(seeded.content_hash.clone()),
4662 sections,
4663 append_sections: IndexMap::new(),
4664 patch_sections: IndexMap::new(),
4665 sections_unset: Vec::new(),
4666 metadata: IndexMap::new(),
4667 metadata_unset: Vec::new(),
4668 declare_relations: Vec::new(),
4669 dry_run: false,
4670 relations_unset: Vec::new(),
4671 anchors_unset: Vec::new(),
4672 },
4673 actor,
4674 Some(&client),
4675 None,
4676 )
4677 .unwrap();
4678 assert!(!real.write_id.is_empty());
4679 assert_ne!(real.content_hash, seeded.content_hash);
4680 }
4681
4682 #[test]
4691 fn synthesis_gc_drops_auto_emitted_reference_when_body_link_removed() {
4692 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4696 use indexmap::IndexMap;
4697 use tempfile::TempDir;
4698
4699 let tmp = TempDir::new().unwrap();
4700 let mem_dir = tmp.path().to_path_buf();
4701 let writer = FilesystemMemWriter::new(mem_dir.clone());
4702 let mut engine = Engine::from_mounts(vec![(
4703 folder_mount("specs", mem_dir.clone()),
4704 Box::new(writer) as Box<dyn MemBackend>,
4705 )])
4706 .unwrap();
4707 engine.set_workspace_root(mem_dir.clone());
4708 let (actor, client) = cli_actor();
4709
4710 let target = engine
4711 .create_entity(
4712 empty_create_args("specs", "Target"),
4713 actor,
4714 Some(&client),
4715 None,
4716 )
4717 .unwrap();
4718 let mut sections: IndexMap<String, String> = IndexMap::new();
4721 sections.insert("identity".to_string(), "source identity".to_string());
4722 sections.insert(
4723 "purpose".to_string(),
4724 "see [[target]] for context".to_string(),
4725 );
4726 let source = engine
4727 .create_entity(
4728 CreateEntityArgs {
4729 anchors: Vec::new(),
4730 mem: "specs".to_string(),
4731 title: "Source".to_string(),
4732 entity_type: "spec".to_string(),
4733 sections,
4734 metadata: IndexMap::new(),
4735 relations: Vec::new(),
4736 dry_run: false,
4737 },
4738 actor,
4739 Some(&client),
4740 None,
4741 )
4742 .unwrap();
4743 assert!(
4744 engine
4745 .get_entity(&source.id)
4746 .unwrap()
4747 .relationships
4748 .iter()
4749 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4750 "create-time synthesis must emit REFERENCES → target",
4751 );
4752
4753 let mut new_sections: IndexMap<String, String> = IndexMap::new();
4756 new_sections.insert("purpose".to_string(), "no link any more".to_string());
4757 engine
4758 .update_entity(
4759 UpdateEntityArgs {
4760 anchors: Vec::new(),
4761 id: source.id.clone(),
4762 expected_hash: Some(source.content_hash.clone()),
4763 sections: new_sections,
4764 append_sections: IndexMap::new(),
4765 patch_sections: IndexMap::new(),
4766 sections_unset: Vec::new(),
4767 metadata: IndexMap::new(),
4768 metadata_unset: Vec::new(),
4769 declare_relations: Vec::new(),
4770 dry_run: false,
4771 relations_unset: Vec::new(),
4772 anchors_unset: Vec::new(),
4773 },
4774 actor,
4775 Some(&client),
4776 None,
4777 )
4778 .expect("update must succeed; GC drops the now-orphan REFERENCES");
4779 let in_mem = engine.get_entity(&source.id).unwrap();
4780 assert!(
4781 !in_mem
4782 .relationships
4783 .iter()
4784 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4785 "GC must drop the auto-emitted REFERENCES after body link removal; got {:?}",
4786 in_mem.relationships,
4787 );
4788 }
4789
4790 #[test]
4791 fn update_gc_removes_orphan_stub_when_last_body_link_dropped() {
4792 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4800 use indexmap::IndexMap;
4801 use tempfile::TempDir;
4802
4803 let tmp = TempDir::new().unwrap();
4804 let mem_dir = tmp.path().to_path_buf();
4805 let writer = FilesystemMemWriter::new(mem_dir.clone());
4806 let mut engine = Engine::from_mounts(vec![(
4807 folder_mount("specs", mem_dir.clone()),
4808 Box::new(writer) as Box<dyn MemBackend>,
4809 )])
4810 .unwrap();
4811 engine.set_workspace_root(mem_dir.clone());
4812 let (actor, client) = cli_actor();
4813
4814 let ghost = crate::EntityId::new("specs", "ghost");
4815 let mut sections: IndexMap<String, String> = IndexMap::new();
4816 sections.insert("identity".to_string(), "source identity".to_string());
4817 sections.insert(
4818 "purpose".to_string(),
4819 "see [[ghost]] for context".to_string(),
4820 );
4821 let source = engine
4822 .create_entity(
4823 CreateEntityArgs {
4824 anchors: Vec::new(),
4825 mem: "specs".to_string(),
4826 title: "Source".to_string(),
4827 entity_type: "spec".to_string(),
4828 sections,
4829 metadata: IndexMap::new(),
4830 relations: Vec::new(),
4831 dry_run: false,
4832 },
4833 actor,
4834 Some(&client),
4835 None,
4836 )
4837 .unwrap();
4838 assert!(
4839 engine.store().contains(&ghost) && engine.get_entity(&ghost).unwrap().stub,
4840 "body wiki-link to an absent target must auto-stub it",
4841 );
4842 assert_eq!(
4843 engine.health().stub_count,
4844 1,
4845 "one stub before the link drop"
4846 );
4847
4848 let mut new_sections: IndexMap<String, String> = IndexMap::new();
4849 new_sections.insert("purpose".to_string(), "no link any more".to_string());
4850 let outcome = engine
4851 .update_entity(
4852 UpdateEntityArgs {
4853 anchors: Vec::new(),
4854 id: source.id.clone(),
4855 expected_hash: Some(source.content_hash.clone()),
4856 sections: new_sections,
4857 append_sections: IndexMap::new(),
4858 patch_sections: IndexMap::new(),
4859 sections_unset: Vec::new(),
4860 metadata: IndexMap::new(),
4861 metadata_unset: Vec::new(),
4862 declare_relations: Vec::new(),
4863 dry_run: false,
4864 relations_unset: Vec::new(),
4865 anchors_unset: Vec::new(),
4866 },
4867 actor,
4868 Some(&client),
4869 None,
4870 )
4871 .expect("update must succeed and GC the now-orphan stub");
4872
4873 assert_eq!(
4874 outcome.orphan_stubs_removed,
4875 vec![ghost.clone()],
4876 "the update that dropped the last body link must report the GC'd stub",
4877 );
4878 assert!(
4879 !engine.store().contains(&ghost),
4880 "orphan stub must be gone from the in-memory store",
4881 );
4882 assert_eq!(
4883 engine.health().stub_count,
4884 0,
4885 "stub count decremented in-session"
4886 );
4887
4888 engine.reload_each_writable_mem().unwrap();
4892 assert!(
4893 !engine.store().contains(&ghost),
4894 "stub stays gone after reload-from-disk",
4895 );
4896 assert_eq!(
4897 engine.health().stub_count,
4898 0,
4899 "reloaded-from-disk store carries the same stub count as the in-session post-update state",
4900 );
4901 }
4902
4903 #[test]
4904 fn update_gc_noop_when_section_edit_changes_no_body_link() {
4905 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4910 use indexmap::IndexMap;
4911 use tempfile::TempDir;
4912
4913 let tmp = TempDir::new().unwrap();
4914 let mem_dir = tmp.path().to_path_buf();
4915 let writer = FilesystemMemWriter::new(mem_dir.clone());
4916 let mut engine = Engine::from_mounts(vec![(
4917 folder_mount("specs", mem_dir.clone()),
4918 Box::new(writer) as Box<dyn MemBackend>,
4919 )])
4920 .unwrap();
4921 engine.set_workspace_root(mem_dir.clone());
4922 let (actor, client) = cli_actor();
4923
4924 let ghost = crate::EntityId::new("specs", "ghost");
4925 let mut sections: IndexMap<String, String> = IndexMap::new();
4926 sections.insert("identity".to_string(), "original identity".to_string());
4927 sections.insert(
4928 "purpose".to_string(),
4929 "see [[ghost]] for context".to_string(),
4930 );
4931 let source = engine
4932 .create_entity(
4933 CreateEntityArgs {
4934 anchors: Vec::new(),
4935 mem: "specs".to_string(),
4936 title: "Source".to_string(),
4937 entity_type: "spec".to_string(),
4938 sections,
4939 metadata: IndexMap::new(),
4940 relations: Vec::new(),
4941 dry_run: false,
4942 },
4943 actor,
4944 Some(&client),
4945 None,
4946 )
4947 .unwrap();
4948 assert!(engine.store().contains(&ghost), "ghost stub materialised");
4949
4950 let mut edit: IndexMap<String, String> = IndexMap::new();
4953 edit.insert("identity".to_string(), "edited identity".to_string());
4954 let outcome = engine
4955 .update_entity(
4956 UpdateEntityArgs {
4957 anchors: Vec::new(),
4958 id: source.id.clone(),
4959 expected_hash: Some(source.content_hash.clone()),
4960 sections: edit,
4961 append_sections: IndexMap::new(),
4962 patch_sections: IndexMap::new(),
4963 sections_unset: Vec::new(),
4964 metadata: IndexMap::new(),
4965 metadata_unset: Vec::new(),
4966 declare_relations: Vec::new(),
4967 dry_run: false,
4968 relations_unset: Vec::new(),
4969 anchors_unset: Vec::new(),
4970 },
4971 actor,
4972 Some(&client),
4973 None,
4974 )
4975 .expect("update must succeed");
4976 assert!(
4977 outcome.orphan_stubs_removed.is_empty(),
4978 "an edit that keeps every body wiki-link orphans nothing; got {:?}",
4979 outcome.orphan_stubs_removed,
4980 );
4981 assert!(
4982 engine.store().contains(&ghost),
4983 "the still-referenced stub survives the unrelated section edit",
4984 );
4985 }
4986
4987 #[test]
4988 fn update_gc_preserves_stub_with_surviving_referrer() {
4989 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4993 use indexmap::IndexMap;
4994 use tempfile::TempDir;
4995
4996 let tmp = TempDir::new().unwrap();
4997 let mem_dir = tmp.path().to_path_buf();
4998 let writer = FilesystemMemWriter::new(mem_dir.clone());
4999 let mut engine = Engine::from_mounts(vec![(
5000 folder_mount("specs", mem_dir.clone()),
5001 Box::new(writer) as Box<dyn MemBackend>,
5002 )])
5003 .unwrap();
5004 engine.set_workspace_root(mem_dir.clone());
5005 let (actor, client) = cli_actor();
5006
5007 let ghost = crate::EntityId::new("specs", "ghost");
5008 let make_with_link = |title: &str| {
5009 let mut sections: IndexMap<String, String> = IndexMap::new();
5010 sections.insert("identity".to_string(), format!("{title} identity"));
5011 sections.insert("purpose".to_string(), "see [[ghost]]".to_string());
5012 CreateEntityArgs {
5013 anchors: Vec::new(),
5014 mem: "specs".to_string(),
5015 title: title.to_string(),
5016 entity_type: "spec".to_string(),
5017 sections,
5018 metadata: IndexMap::new(),
5019 relations: Vec::new(),
5020 dry_run: false,
5021 }
5022 };
5023 let source_a = engine
5024 .create_entity(make_with_link("Source A"), actor, Some(&client), None)
5025 .unwrap();
5026 engine
5027 .create_entity(make_with_link("Source B"), actor, Some(&client), None)
5028 .unwrap();
5029 assert!(engine.store().contains(&ghost), "ghost stub materialised");
5030
5031 let mut drop_link: IndexMap<String, String> = IndexMap::new();
5033 drop_link.insert("purpose".to_string(), "no link here".to_string());
5034 let outcome = engine
5035 .update_entity(
5036 UpdateEntityArgs {
5037 anchors: Vec::new(),
5038 id: source_a.id.clone(),
5039 expected_hash: Some(source_a.content_hash.clone()),
5040 sections: drop_link,
5041 append_sections: IndexMap::new(),
5042 patch_sections: IndexMap::new(),
5043 sections_unset: Vec::new(),
5044 metadata: IndexMap::new(),
5045 metadata_unset: Vec::new(),
5046 declare_relations: Vec::new(),
5047 dry_run: false,
5048 relations_unset: Vec::new(),
5049 anchors_unset: Vec::new(),
5050 },
5051 actor,
5052 Some(&client),
5053 None,
5054 )
5055 .expect("update must succeed");
5056 assert!(
5057 outcome.orphan_stubs_removed.is_empty(),
5058 "the stub keeps a referrer (source B), so nothing is GC'd; got {:?}",
5059 outcome.orphan_stubs_removed,
5060 );
5061 assert!(
5062 engine.store().contains(&ghost),
5063 "stub survives via the surviving referrer",
5064 );
5065 }
5066
5067 #[test]
5068 fn synthesis_gc_preserves_non_pointer_explicit_relation_across_body_update() {
5069 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
5078 use indexmap::IndexMap;
5079 use tempfile::TempDir;
5080
5081 let tmp = TempDir::new().unwrap();
5082 let mem_dir = tmp.path().to_path_buf();
5083 let writer = FilesystemMemWriter::new(mem_dir.clone());
5084 let mut engine = Engine::from_mounts(vec![(
5085 folder_mount("specs", mem_dir.clone()),
5086 Box::new(writer) as Box<dyn MemBackend>,
5087 )])
5088 .unwrap();
5089 engine.set_workspace_root(mem_dir.clone());
5090 let (actor, client) = cli_actor();
5091
5092 let target = engine
5093 .create_entity(
5094 empty_create_args("specs", "Target"),
5095 actor,
5096 Some(&client),
5097 None,
5098 )
5099 .unwrap();
5100 let source = engine
5101 .create_entity(
5102 empty_create_args("specs", "Source"),
5103 actor,
5104 Some(&client),
5105 None,
5106 )
5107 .unwrap();
5108
5109 let relate = engine
5111 .relate_entity(
5112 RelateEntityArgs {
5113 source: source.id.clone(),
5114 expected_hash: Some(source.content_hash.clone()),
5115 rel_type: "USES".to_string(),
5116 target: target.id.clone(),
5117 remove: false,
5118 description: None,
5119 dry_run: false,
5120 },
5121 actor,
5122 Some(&client),
5123 None,
5124 )
5125 .unwrap();
5126
5127 let mut sections: IndexMap<String, String> = IndexMap::new();
5130 sections.insert("purpose".to_string(), "unrelated edit".to_string());
5131 engine
5132 .update_entity(
5133 UpdateEntityArgs {
5134 anchors: Vec::new(),
5135 id: source.id.clone(),
5136 expected_hash: Some(relate.content_hash.clone()),
5137 sections,
5138 append_sections: IndexMap::new(),
5139 patch_sections: IndexMap::new(),
5140 sections_unset: Vec::new(),
5141 metadata: IndexMap::new(),
5142 metadata_unset: Vec::new(),
5143 declare_relations: Vec::new(),
5144 dry_run: false,
5145 relations_unset: Vec::new(),
5146 anchors_unset: Vec::new(),
5147 },
5148 actor,
5149 Some(&client),
5150 None,
5151 )
5152 .expect("update must succeed");
5153 let in_mem = engine.get_entity(&source.id).unwrap();
5154 assert!(
5155 in_mem
5156 .relationships
5157 .iter()
5158 .any(|r| r.rel_type == "USES" && r.target == target.id),
5159 "explicit USES must survive an unrelated body update; got {:?}",
5160 in_mem.relationships,
5161 );
5162 }
5163
5164 #[test]
5165 fn synthesis_dedupes_repeated_body_links_to_same_target() {
5166 use crate::engine::UpdateEntityArgs;
5169 use indexmap::IndexMap;
5170 use tempfile::TempDir;
5171
5172 let tmp = TempDir::new().unwrap();
5173 let mem_dir = tmp.path().to_path_buf();
5174 let writer = FilesystemMemWriter::new(mem_dir.clone());
5175 let mut engine = Engine::from_mounts(vec![(
5176 folder_mount("specs", mem_dir.clone()),
5177 Box::new(writer) as Box<dyn MemBackend>,
5178 )])
5179 .unwrap();
5180 engine.set_workspace_root(mem_dir.clone());
5181 let (actor, client) = cli_actor();
5182
5183 let target = engine
5184 .create_entity(
5185 empty_create_args("specs", "Target"),
5186 actor,
5187 Some(&client),
5188 None,
5189 )
5190 .unwrap();
5191 let source = engine
5192 .create_entity(
5193 empty_create_args("specs", "Source"),
5194 actor,
5195 Some(&client),
5196 None,
5197 )
5198 .unwrap();
5199
5200 let mut sections: IndexMap<String, String> = IndexMap::new();
5201 sections.insert(
5202 "purpose".to_string(),
5203 "see [[target]] and again [[target]]".to_string(),
5204 );
5205 engine
5206 .update_entity(
5207 UpdateEntityArgs {
5208 anchors: Vec::new(),
5209 id: source.id.clone(),
5210 expected_hash: Some(source.content_hash.clone()),
5211 sections,
5212 append_sections: IndexMap::new(),
5213 patch_sections: IndexMap::new(),
5214 sections_unset: Vec::new(),
5215 metadata: IndexMap::new(),
5216 metadata_unset: Vec::new(),
5217 declare_relations: Vec::new(),
5218 dry_run: false,
5219 relations_unset: Vec::new(),
5220 anchors_unset: Vec::new(),
5221 },
5222 actor,
5223 Some(&client),
5224 None,
5225 )
5226 .unwrap();
5227 let in_mem = engine.get_entity(&source.id).unwrap();
5228 let count = in_mem
5229 .relationships
5230 .iter()
5231 .filter(|r| r.rel_type == "REFERENCES" && r.target == target.id)
5232 .count();
5233 assert_eq!(
5234 count, 1,
5235 "dedupe must leave exactly one REFERENCES → target; got {:?}",
5236 in_mem.relationships,
5237 );
5238 }
5239
5240 #[test]
5241 fn synthesis_coexists_with_explicit_uses_to_same_target() {
5242 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
5247 use indexmap::IndexMap;
5248 use tempfile::TempDir;
5249
5250 let tmp = TempDir::new().unwrap();
5251 let mem_dir = tmp.path().to_path_buf();
5252 let writer = FilesystemMemWriter::new(mem_dir.clone());
5253 let mut engine = Engine::from_mounts(vec![(
5254 folder_mount("specs", mem_dir.clone()),
5255 Box::new(writer) as Box<dyn MemBackend>,
5256 )])
5257 .unwrap();
5258 engine.set_workspace_root(mem_dir.clone());
5259 let (actor, client) = cli_actor();
5260
5261 let target = engine
5262 .create_entity(
5263 empty_create_args("specs", "Target"),
5264 actor,
5265 Some(&client),
5266 None,
5267 )
5268 .unwrap();
5269 let source = engine
5270 .create_entity(
5271 empty_create_args("specs", "Source"),
5272 actor,
5273 Some(&client),
5274 None,
5275 )
5276 .unwrap();
5277 let relate = engine
5279 .relate_entity(
5280 RelateEntityArgs {
5281 source: source.id.clone(),
5282 expected_hash: Some(source.content_hash.clone()),
5283 rel_type: "USES".to_string(),
5284 target: target.id.clone(),
5285 remove: false,
5286 description: None,
5287 dry_run: false,
5288 },
5289 actor,
5290 Some(&client),
5291 None,
5292 )
5293 .unwrap();
5294 let mut sections: IndexMap<String, String> = IndexMap::new();
5296 sections.insert(
5297 "purpose".to_string(),
5298 "we also reference [[target]]".to_string(),
5299 );
5300 engine
5301 .update_entity(
5302 UpdateEntityArgs {
5303 anchors: Vec::new(),
5304 id: source.id.clone(),
5305 expected_hash: Some(relate.content_hash.clone()),
5306 sections,
5307 append_sections: IndexMap::new(),
5308 patch_sections: IndexMap::new(),
5309 sections_unset: Vec::new(),
5310 metadata: IndexMap::new(),
5311 metadata_unset: Vec::new(),
5312 declare_relations: Vec::new(),
5313 dry_run: false,
5314 relations_unset: Vec::new(),
5315 anchors_unset: Vec::new(),
5316 },
5317 actor,
5318 Some(&client),
5319 None,
5320 )
5321 .unwrap();
5322 let in_mem = engine.get_entity(&source.id).unwrap();
5323 assert!(
5324 in_mem
5325 .relationships
5326 .iter()
5327 .any(|r| r.rel_type == "USES" && r.target == target.id),
5328 "USES must survive — synthesis dedupes on (rel_type, target)",
5329 );
5330 assert!(
5331 in_mem
5332 .relationships
5333 .iter()
5334 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
5335 "REFERENCES must be synthesised even though USES already targets the same entity",
5336 );
5337 }
5338
5339 mod alias_synthesis_custom_schema {
5351 use std::path::Path;
5352
5353 use indexmap::IndexMap;
5354 use memstead_schema::SchemaRef;
5355 use tempfile::TempDir;
5356
5357 use crate::backend::MemBackend;
5358 use crate::engine::test_helpers::*;
5359 use crate::engine::{CreateEntityArgs, Engine, EngineError, UpdateEntityArgs};
5360 use crate::storage::FilesystemMemWriter;
5361 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
5362
5363 const TYPE_BODY: &str = r#"description: t
5364when_to_use: tests
5365sections:
5366 - key: body
5367 heading: Body
5368 required: true
5369 search_weight: 10.0
5370 catch_all: true
5371 write_rules: []
5372metadata_fields: []
5373title_weight: 100.0
5374text_fields:
5375 - body
5376hierarchy_relationship: _default
5377no_self_loop_relationships: []
5378updatable_fields:
5379 - title
5380 - body
5381health_required_fields:
5382 - body
5383staleness_threshold_days: 90
5384write_rules: []
5385"#;
5386
5387 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
5388 let dir = root.join(name);
5389 std::fs::create_dir_all(dir.join("types")).unwrap();
5390 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
5391 for (type_name, body) in types {
5392 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
5393 }
5394 }
5395
5396 fn make_type_yaml(name: &str) -> String {
5397 format!("name: {name}\n{TYPE_BODY}")
5398 }
5399
5400 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
5401 Mount {
5402 mem: mem.to_string(),
5403 schema: Some(pin),
5404 storage: MountStorage::Folder { path },
5405 capability: MountCapability::Write,
5406 lifecycle: MountLifecycle::Eager,
5407 cross_linkable: true,
5408 migration_target: None,
5409 }
5410 }
5411
5412 fn engine_with_schema(
5413 manifest: &str,
5414 type_yaml_name: &str,
5415 schema_name: &str,
5416 schema_version: semver::Version,
5417 ) -> (Engine, TempDir) {
5418 let tmp = TempDir::new().unwrap();
5419 let schemas_dir = tmp.path().join("schemas");
5420 std::fs::create_dir_all(&schemas_dir).unwrap();
5421 write_schema_files(
5422 &schemas_dir,
5423 schema_name,
5424 manifest,
5425 &[(type_yaml_name, &make_type_yaml(type_yaml_name))],
5426 );
5427 let mem_dir = tmp.path().join("mem");
5428 std::fs::create_dir_all(&mem_dir).unwrap();
5429 let writer = FilesystemMemWriter::new(mem_dir.clone());
5430 let pin = SchemaRef::new(schema_name, schema_version);
5431 let mount = folder_mount_with_pin("v", mem_dir, pin);
5432 let mut engine = Engine::from_mounts_with_schemas_dir(
5433 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
5434 Some(&schemas_dir),
5435 )
5436 .expect("engine with custom schema constructs");
5437 engine.set_workspace_root(tmp.path().to_path_buf());
5438 (engine, tmp)
5439 }
5440
5441 #[test]
5442 fn non_references_alias_pointer_emits_named_rel_type_from_body_link() {
5443 let manifest = r#"name: aliased
5448version: 0.1.0
5449description: alias-synthesis fixture using a non-REFERENCES pointer
5450when_to_use: tests prove the engine does not hard-code REFERENCES
5451types:
5452 - doc
5453relationships:
5454 mode: strict
5455 definitions:
5456 - name: CITES
5457 description: Citation — auto-emitted from body wiki-links
5458 default_weight: 0.5
5459 - name: PART_OF
5460 description: Hierarchy
5461 default_weight: 3.0
5462 acyclic: true
5463 - name: _default
5464 description: Fallback
5465 default_weight: 1.0
5466alias_target_rel_type: CITES
5467community:
5468 resolution: 1.0
5469 seed: 42
5470"#;
5471 let (mut engine, _tmp) =
5472 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5473 let (actor, client) = cli_actor();
5474
5475 let target = engine
5476 .create_entity(
5477 CreateEntityArgs {
5478 anchors: Vec::new(),
5479 mem: "v".to_string(),
5480 title: "Target".to_string(),
5481 entity_type: "doc".to_string(),
5482 sections: IndexMap::from_iter([(
5483 "body".to_string(),
5484 "target body".to_string(),
5485 )]),
5486 metadata: IndexMap::new(),
5487 relations: Vec::new(),
5488 dry_run: false,
5489 },
5490 actor,
5491 Some(&client),
5492 None,
5493 )
5494 .unwrap();
5495
5496 let mut sections: IndexMap<String, String> = IndexMap::new();
5497 sections.insert("body".to_string(), "see [[target]]".to_string());
5498 let source = engine
5499 .create_entity(
5500 CreateEntityArgs {
5501 anchors: Vec::new(),
5502 mem: "v".to_string(),
5503 title: "Source".to_string(),
5504 entity_type: "doc".to_string(),
5505 sections,
5506 metadata: IndexMap::new(),
5507 relations: Vec::new(),
5508 dry_run: false,
5509 },
5510 actor,
5511 Some(&client),
5512 None,
5513 )
5514 .expect("create must succeed; CITES is auto-emitted by synthesis");
5515
5516 let in_mem = engine.get_entity(&source.id).unwrap();
5517 assert!(
5518 in_mem
5519 .relationships
5520 .iter()
5521 .any(|r| r.rel_type == "CITES" && r.target == target.id),
5522 "synthesis must emit CITES (the pointer rel-type), not REFERENCES; got {:?}",
5523 in_mem.relationships,
5524 );
5525 assert!(
5526 !in_mem
5527 .relationships
5528 .iter()
5529 .any(|r| r.rel_type == "REFERENCES"),
5530 "engine must not hard-code REFERENCES — pointer rel-type is CITES; got {:?}",
5531 in_mem.relationships,
5532 );
5533 }
5534
5535 #[test]
5536 fn no_pointer_schema_refuses_unbacked_body_wiki_link() {
5537 let manifest = r#"name: no-alias
5542version: 0.1.0
5543description: schema without alias_target_rel_type pointer
5544when_to_use: tests prove strict validator still fires for opt-out schemas
5545types:
5546 - doc
5547relationships:
5548 mode: strict
5549 definitions:
5550 - name: USES
5551 description: Use
5552 default_weight: 1.0
5553 - name: PART_OF
5554 description: Hierarchy
5555 default_weight: 3.0
5556 acyclic: true
5557 - name: _default
5558 description: Fallback
5559 default_weight: 1.0
5560community:
5561 resolution: 1.0
5562 seed: 42
5563"#;
5564 let (mut engine, _tmp) =
5565 engine_with_schema(manifest, "doc", "no-alias", semver::Version::new(0, 1, 0));
5566 let (actor, client) = cli_actor();
5567
5568 let target = engine
5569 .create_entity(
5570 CreateEntityArgs {
5571 anchors: Vec::new(),
5572 mem: "v".to_string(),
5573 title: "Target".to_string(),
5574 entity_type: "doc".to_string(),
5575 sections: IndexMap::from_iter([(
5576 "body".to_string(),
5577 "target body".to_string(),
5578 )]),
5579 metadata: IndexMap::new(),
5580 relations: Vec::new(),
5581 dry_run: false,
5582 },
5583 actor,
5584 Some(&client),
5585 None,
5586 )
5587 .unwrap();
5588 let source = engine
5589 .create_entity(
5590 CreateEntityArgs {
5591 anchors: Vec::new(),
5592 mem: "v".to_string(),
5593 title: "Source".to_string(),
5594 entity_type: "doc".to_string(),
5595 sections: IndexMap::from_iter([(
5596 "body".to_string(),
5597 "source body".to_string(),
5598 )]),
5599 metadata: IndexMap::new(),
5600 relations: Vec::new(),
5601 dry_run: false,
5602 },
5603 actor,
5604 Some(&client),
5605 None,
5606 )
5607 .unwrap();
5608
5609 let mut sections: IndexMap<String, String> = IndexMap::new();
5613 sections.insert("body".to_string(), "see [[target]]".to_string());
5614 let err = engine
5615 .update_entity(
5616 UpdateEntityArgs {
5617 anchors: Vec::new(),
5618 id: source.id.clone(),
5619 expected_hash: Some(source.content_hash.clone()),
5620 sections,
5621 append_sections: IndexMap::new(),
5622 patch_sections: IndexMap::new(),
5623 sections_unset: Vec::new(),
5624 metadata: IndexMap::new(),
5625 metadata_unset: Vec::new(),
5626 declare_relations: Vec::new(),
5627 dry_run: false,
5628 relations_unset: Vec::new(),
5629 anchors_unset: Vec::new(),
5630 },
5631 actor,
5632 Some(&client),
5633 None,
5634 )
5635 .unwrap_err();
5636 match err {
5637 EngineError::WikiLinkWithoutRelation { from_id, missing } => {
5638 assert_eq!(from_id, source.id.to_string());
5639 assert_eq!(missing.len(), 1);
5640 assert_eq!(missing[0].section_key, "body");
5641 assert_eq!(missing[0].target_id, target.id.to_string());
5642 }
5643 other => panic!(
5644 "no-pointer schema must refuse with WikiLinkWithoutRelation; got {other:?}"
5645 ),
5646 }
5647 }
5648
5649 #[test]
5658 fn natural_form_body_wiki_link_refuses_with_typed_envelope() {
5659 let manifest = r#"name: aliased
5660version: 0.1.0
5661description: alias-synthesis fixture
5662when_to_use: tests prove strict wiki-link grammar at mutation entry
5663types:
5664 - doc
5665relationships:
5666 mode: strict
5667 definitions:
5668 - name: REFERENCES
5669 description: Reference — auto-emitted from body wiki-links
5670 default_weight: 0.5
5671 - name: PART_OF
5672 description: Hierarchy
5673 default_weight: 3.0
5674 acyclic: true
5675 - name: _default
5676 description: Fallback
5677 default_weight: 1.0
5678alias_target_rel_type: REFERENCES
5679community:
5680 resolution: 1.0
5681 seed: 42
5682"#;
5683 let (mut engine, _tmp) =
5684 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5685 let (actor, client) = cli_actor();
5686
5687 let mut sections: IndexMap<String, String> = IndexMap::new();
5688 sections.insert("body".to_string(), "see [[Knowledge Graph]]".to_string());
5689 let err = engine
5690 .create_entity(
5691 CreateEntityArgs {
5692 anchors: Vec::new(),
5693 mem: "v".to_string(),
5694 title: "Source".to_string(),
5695 entity_type: "doc".to_string(),
5696 sections,
5697 metadata: IndexMap::new(),
5698 relations: Vec::new(),
5699 dry_run: false,
5700 },
5701 actor,
5702 Some(&client),
5703 None,
5704 )
5705 .unwrap_err();
5706 match err {
5707 EngineError::InvalidWikiLinkTarget {
5708 raw,
5709 suggested,
5710 section,
5711 link_source,
5712 ..
5713 } => {
5714 assert_eq!(raw, "Knowledge Graph");
5715 assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
5716 assert_eq!(section, "body");
5717 assert_eq!(link_source, "body_link");
5718 }
5719 other => panic!(
5720 "natural-form body wiki-link must refuse with InvalidWikiLinkTarget; got {other:?}"
5721 ),
5722 }
5723 }
5724
5725 #[test]
5731 fn tier_two_bad_mem_prefix_refuses_with_distinct_envelope() {
5732 let manifest = r#"name: aliased
5733version: 0.1.0
5734description: alias-synthesis fixture
5735when_to_use: tests prove strict mem-prefix grammar at mutation entry
5736types:
5737 - doc
5738relationships:
5739 mode: strict
5740 definitions:
5741 - name: REFERENCES
5742 description: Reference
5743 default_weight: 0.5
5744 - name: PART_OF
5745 description: Hierarchy
5746 default_weight: 3.0
5747 acyclic: true
5748 - name: _default
5749 description: Fallback
5750 default_weight: 1.0
5751alias_target_rel_type: REFERENCES
5752community:
5753 resolution: 1.0
5754 seed: 42
5755"#;
5756 let (mut engine, _tmp) =
5757 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5758 let (actor, client) = cli_actor();
5759
5760 let mut sections: IndexMap<String, String> = IndexMap::new();
5761 sections.insert("body".to_string(), "see [[Other Mem:foo]]".to_string());
5762 let err = engine
5763 .create_entity(
5764 CreateEntityArgs {
5765 anchors: Vec::new(),
5766 mem: "v".to_string(),
5767 title: "Source".to_string(),
5768 entity_type: "doc".to_string(),
5769 sections,
5770 metadata: IndexMap::new(),
5771 relations: Vec::new(),
5772 dry_run: false,
5773 },
5774 actor,
5775 Some(&client),
5776 None,
5777 )
5778 .unwrap_err();
5779 match err {
5780 EngineError::InvalidWikiLinkMem { raw, section, .. } => {
5781 assert_eq!(raw, "Other Mem");
5782 assert_eq!(section, "body");
5783 }
5784 other => panic!(
5785 "Tier-2 bad mem prefix must refuse with InvalidWikiLinkMem; got {other:?}"
5786 ),
5787 }
5788 }
5789
5790 #[test]
5797 fn hierarchical_dash_form_body_link_refuses_with_colon_suggestion() {
5798 let manifest = r#"name: aliased
5799version: 0.1.0
5800description: alias-synthesis fixture
5801when_to_use: tests prove hierarchical dash-form refusal at mutation entry
5802types:
5803 - doc
5804relationships:
5805 mode: strict
5806 definitions:
5807 - name: REFERENCES
5808 description: Reference — auto-emitted from body wiki-links
5809 default_weight: 0.5
5810 - name: PART_OF
5811 description: Hierarchy
5812 default_weight: 3.0
5813 acyclic: true
5814 - name: _default
5815 description: Fallback
5816 default_weight: 1.0
5817alias_target_rel_type: REFERENCES
5818community:
5819 resolution: 1.0
5820 seed: 42
5821"#;
5822 let (mut engine, _tmp) =
5823 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5824 let (actor, client) = cli_actor();
5825
5826 let mut sections: IndexMap<String, String> = IndexMap::new();
5827 sections.insert(
5828 "body".to_string(),
5829 "see [[team/sub-mem--target]]".to_string(),
5830 );
5831 let err = engine
5832 .create_entity(
5833 CreateEntityArgs {
5834 anchors: Vec::new(),
5835 mem: "v".to_string(),
5836 title: "Source".to_string(),
5837 entity_type: "doc".to_string(),
5838 sections,
5839 metadata: IndexMap::new(),
5840 relations: Vec::new(),
5841 dry_run: false,
5842 },
5843 actor,
5844 Some(&client),
5845 None,
5846 )
5847 .unwrap_err();
5848 match err {
5849 EngineError::InvalidWikiLinkTarget {
5850 raw,
5851 suggested,
5852 section,
5853 link_source,
5854 ..
5855 } => {
5856 assert_eq!(raw, "team/sub-mem--target");
5857 assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
5858 assert_eq!(section, "body");
5859 assert_eq!(link_source, "body_link");
5860 }
5861 other => panic!(
5862 "hierarchical dash-form body link must refuse with InvalidWikiLinkTarget; got {other:?}"
5863 ),
5864 }
5865
5866 let listed = engine.store().all_entities().collect::<Vec<_>>();
5869 assert!(
5870 listed.is_empty(),
5871 "refused create must not leave any entity behind, got: {listed:?}"
5872 );
5873 }
5874 }
5875
5876 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";
5885
5886 fn repair_engine() -> (TempDir, Engine) {
5887 let tmp = TempDir::new().unwrap();
5888 let mem_dir = tmp.path().to_path_buf();
5889 std::fs::write(
5890 mem_dir.join("anchor.md"),
5891 "---\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",
5892 )
5893 .unwrap();
5894 std::fs::write(mem_dir.join("drifted.md"), DRIFTED_MD).unwrap();
5895 let writer = FilesystemMemWriter::new(mem_dir.clone());
5896 let engine = Engine::from_mounts(vec![(
5897 folder_mount("specs", mem_dir),
5898 Box::new(writer) as Box<dyn MemBackend>,
5899 )])
5900 .unwrap();
5901 (tmp, engine)
5902 }
5903
5904 fn repair_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5905 UpdateEntityArgs {
5906 anchors: Vec::new(),
5907 id,
5908 expected_hash: hash,
5909 sections: IndexMap::new(),
5910 append_sections: IndexMap::new(),
5911 patch_sections: IndexMap::new(),
5912 sections_unset: Vec::new(),
5913 metadata: IndexMap::new(),
5914 metadata_unset: Vec::new(),
5915 declare_relations: Vec::new(),
5916 dry_run: false,
5917 relations_unset: vec![crate::ops::RelationUnsetArg {
5918 rel_type: "USES".to_string(),
5919 target: EntityId::new("specs", "anchor"),
5920 }],
5921 anchors_unset: Vec::new(),
5922 }
5923 }
5924
5925 #[test]
5930 fn relations_unset_on_conformant_entity_refuses_repair_not_needed() {
5931 let (_tmp, mut engine) = repair_engine();
5932 let anchor = EntityId::new("specs", "anchor");
5935 let drifted = EntityId::new("specs", "drifted");
5936 engine
5937 .relate_entity(
5938 RelateEntityArgs {
5939 source: anchor.clone(),
5940 expected_hash: None,
5941 rel_type: "USES".to_string(),
5942 target: drifted.clone(),
5943 remove: false,
5944 description: None,
5945 dry_run: false,
5946 },
5947 Actor::Cli,
5948 None,
5949 None,
5950 )
5951 .expect("relate on conformant entity works");
5952 let mut args = repair_args(anchor.clone(), None);
5953 args.relations_unset[0].target = drifted.clone();
5954 let err = engine
5955 .update_entity(args, Actor::Cli, None, None)
5956 .unwrap_err();
5957 match err {
5958 EngineError::RepairNotNeeded { id, recovery } => {
5959 assert_eq!(id, anchor.to_string());
5960 assert!(
5961 recovery.contains("memstead_relate"),
5962 "recovery must point at the focused tool; got {recovery}"
5963 );
5964 }
5965 other => panic!("expected RepairNotNeeded, got {other:?}"),
5966 }
5967 let entity = engine.store().get(&anchor).unwrap();
5969 assert!(
5970 entity.relationships.iter().any(|r| r.target == drifted),
5971 "gate must not modify the entity"
5972 );
5973 }
5974
5975 #[test]
5980 fn relations_unset_repairs_non_conformant_entity_atomically() {
5981 let (_tmp, mut engine) = repair_engine();
5982 let drifted = EntityId::new("specs", "drifted");
5983 let pre = engine.conformance_findings("specs", None).unwrap();
5985 assert!(
5986 pre.iter().any(|f| f.id == drifted.to_string()),
5987 "fixture must lint non-conformant; got {pre:?}"
5988 );
5989 let mut args = repair_args(drifted.clone(), None);
5990 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
5991 engine
5992 .update_entity(args, Actor::Cli, None, None)
5993 .expect("repair update lands");
5994 let entity = engine.store().get(&drifted).unwrap();
5995 assert!(
5996 entity.relationships.is_empty(),
5997 "relation must be removed; got {:?}",
5998 entity.relationships
5999 );
6000 assert!(
6001 !entity.metadata.contains_key("zzz_bogus_field"),
6002 "conformance break must be repaired in the same update"
6003 );
6004 let post = engine.conformance_findings("specs", None).unwrap();
6005 assert!(
6006 post.iter().all(|f| f.id != drifted.to_string()),
6007 "post-repair entity must be conformant; got {post:?}"
6008 );
6009 }
6010
6011 #[test]
6015 fn relations_unset_post_state_must_still_validate() {
6016 let (_tmp, mut engine) = repair_engine();
6017 let drifted = EntityId::new("specs", "drifted");
6018 let mut args = repair_args(drifted.clone(), None);
6019 args.sections = IndexMap::from_iter([("nonexistent_section".to_string(), "x".to_string())]);
6022 let err = engine
6023 .update_entity(args, Actor::Cli, None, None)
6024 .unwrap_err();
6025 assert_eq!(
6026 err.code(),
6027 "UNKNOWN_SECTION",
6028 "strict-write post-condition must hold during repair; got {err:?}"
6029 );
6030 let entity = engine.store().get(&drifted).unwrap();
6032 assert!(
6033 !entity.relationships.is_empty(),
6034 "refused repair must not partially apply"
6035 );
6036 }
6037
6038 #[test]
6041 fn relations_unset_absent_pair_is_silent_noop() {
6042 let (_tmp, mut engine) = repair_engine();
6043 let drifted = EntityId::new("specs", "drifted");
6044 let mut args = repair_args(drifted.clone(), None);
6045 args.relations_unset[0].rel_type = "NEVER_DECLARED".to_string();
6046 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
6048 engine
6049 .update_entity(args, Actor::Cli, None, None)
6050 .expect("absent pair no-ops, update lands");
6051 let entity = engine.store().get(&drifted).unwrap();
6052 assert_eq!(
6053 entity.relationships.len(),
6054 1,
6055 "the USES relation must survive an unmatched unset"
6056 );
6057 }
6058
6059 fn anchor_input(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
6062 crate::anchor::AnchorInput {
6063 artifact: Some(artifact.to_string()),
6064 grain: Some("file".to_string()),
6065 class: Some("anchored".to_string()),
6066 hash: Some(hash.to_string()),
6067 hash_stability: Some("stable".to_string()),
6068 ..Default::default()
6069 }
6070 }
6071
6072 fn anchor_unset(artifact: &str) -> crate::anchor::AnchorUnsetInput {
6073 crate::anchor::AnchorUnsetInput {
6074 artifact: Some(artifact.to_string()),
6075 grain: None,
6076 class: None,
6077 }
6078 }
6079
6080 fn anchor_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
6082 UpdateEntityArgs {
6083 anchors: Vec::new(),
6084 anchors_unset: Vec::new(),
6085 id,
6086 expected_hash: hash,
6087 sections: IndexMap::new(),
6088 append_sections: IndexMap::new(),
6089 patch_sections: IndexMap::new(),
6090 sections_unset: Vec::new(),
6091 metadata: IndexMap::new(),
6092 metadata_unset: Vec::new(),
6093 declare_relations: Vec::new(),
6094 dry_run: false,
6095 relations_unset: Vec::new(),
6096 }
6097 }
6098
6099 fn anchored_engine() -> (Engine, TempDir, EntityId, String) {
6102 let tmp = TempDir::new().unwrap();
6103 let mem_dir = tmp.path().to_path_buf();
6104 let writer = FilesystemMemWriter::new(mem_dir.clone());
6105 let mut engine = Engine::from_mounts(vec![(
6106 folder_mount("specs", mem_dir),
6107 Box::new(writer) as Box<dyn MemBackend>,
6108 )])
6109 .unwrap();
6110 let (actor, client) = cli_actor();
6111 let mut args = empty_create_args("specs", "Anchored");
6112 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
6113 let created = engine
6114 .create_entity(args, actor, Some(&client), None)
6115 .unwrap();
6116 let id = EntityId::new("specs", "anchored");
6117 assert_eq!(engine.entity_anchors(&id).len(), 2);
6118 (engine, tmp, id, created.content_hash)
6119 }
6120
6121 #[test]
6126 fn update_anchors_merge_appends_and_replaces_by_triple() {
6127 let (mut engine, _tmp, id, hash) = anchored_engine();
6128 let (actor, client) = cli_actor();
6129
6130 let mut args = anchor_args(id.clone(), Some(hash));
6132 args.anchors = vec![anchor_input("c.rs", "h-c")];
6133 let out = engine
6134 .update_entity(args, actor, Some(&client), None)
6135 .unwrap();
6136 let anchors = engine.entity_anchors(&id);
6137 assert_eq!(anchors.len(), 3, "N existing + 1 new = N+1");
6138 assert_eq!(anchors[0].artifact, "a.rs");
6139 assert_eq!(anchors[0].hash.as_deref(), Some("h-a"));
6140 assert_eq!(anchors[1].artifact, "b.rs");
6141 assert_eq!(anchors[2].artifact, "c.rs");
6142 assert!(!engine.anchors_referencing_artifact("a.rs").is_empty());
6143 assert!(!engine.anchors_referencing_artifact("c.rs").is_empty());
6144
6145 let mut args = anchor_args(id.clone(), Some(out.content_hash));
6147 args.anchors = vec![anchor_input("a.rs", "h-a2")];
6148 engine
6149 .update_entity(args, actor, Some(&client), None)
6150 .unwrap();
6151 let anchors = engine.entity_anchors(&id);
6152 assert_eq!(anchors.len(), 3);
6153 assert_eq!(anchors[0].artifact, "a.rs");
6154 assert_eq!(anchors[0].hash.as_deref(), Some("h-a2"));
6155 assert_eq!(anchors[1].hash.as_deref(), Some("h-b"), "b untouched");
6156 assert_eq!(anchors[2].hash.as_deref(), Some("h-c"), "c untouched");
6157 }
6158
6159 #[test]
6163 fn update_anchors_full_resend_and_absent_are_noops_on_stored_set() {
6164 let (mut engine, tmp, id, hash) = anchored_engine();
6165 let (actor, client) = cli_actor();
6166 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
6167 let before = std::fs::read(&sidecar_path).unwrap();
6168
6169 let mut args = anchor_args(id.clone(), Some(hash));
6171 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
6172 let out = engine
6173 .update_entity(args, actor, Some(&client), None)
6174 .unwrap();
6175 assert_eq!(
6176 std::fs::read(&sidecar_path).unwrap(),
6177 before,
6178 "full re-send keeps the stored bytes"
6179 );
6180
6181 let mut args = anchor_args(id.clone(), Some(out.content_hash));
6183 args.sections
6184 .insert("identity".to_string(), "changed body".to_string());
6185 engine
6186 .update_entity(args, actor, Some(&client), None)
6187 .unwrap();
6188 assert_eq!(
6189 std::fs::read(&sidecar_path).unwrap(),
6190 before,
6191 "an anchorless update never touches the stored set"
6192 );
6193 }
6194
6195 #[test]
6200 fn update_anchors_unset_bare_narrowed_idempotent_and_unset_first() {
6201 let (mut engine, _tmp, id, hash) = anchored_engine();
6202 let (actor, client) = cli_actor();
6203
6204 let mut span = anchor_input("a.rs", "h-span");
6206 span.grain = Some("span".to_string());
6207 let mut args = anchor_args(id.clone(), Some(hash));
6208 args.anchors = vec![span];
6209 let out = engine
6210 .update_entity(args, actor, Some(&client), None)
6211 .unwrap();
6212 assert_eq!(engine.entity_anchors(&id).len(), 3);
6213
6214 let mut narrowed = anchor_unset("a.rs");
6216 narrowed.grain = Some("span".to_string());
6217 let mut args = anchor_args(id.clone(), Some(out.content_hash));
6218 args.anchors_unset = vec![narrowed];
6219 let out = engine
6220 .update_entity(args, actor, Some(&client), None)
6221 .unwrap();
6222 let anchors = engine.entity_anchors(&id);
6223 assert_eq!(anchors.len(), 2);
6224 assert!(
6225 anchors
6226 .iter()
6227 .all(|a| a.grain == crate::anchor::AnchorGrain::File)
6228 );
6229
6230 let mut args = anchor_args(id.clone(), Some(out.content_hash.clone()));
6232 args.anchors_unset = vec![anchor_unset("never-there.rs")];
6233 engine
6234 .update_entity(args, actor, Some(&client), None)
6235 .expect("unset of a nonexistent target is a no-op, not an error");
6236 assert_eq!(engine.entity_anchors(&id).len(), 2);
6237
6238 let mut args = anchor_args(id.clone(), Some(out.content_hash));
6241 args.anchors_unset = vec![anchor_unset("a.rs")];
6242 args.anchors = vec![anchor_input("a.rs", "h-a-fresh")];
6243 engine
6244 .update_entity(args, actor, Some(&client), None)
6245 .unwrap();
6246 let anchors = engine.entity_anchors(&id);
6247 assert_eq!(anchors.len(), 2);
6248 assert_eq!(anchors[0].artifact, "b.rs", "b.rs untouched throughout");
6249 assert_eq!(anchors[1].hash.as_deref(), Some("h-a-fresh"));
6250 }
6251
6252 #[test]
6259 fn a_payload_naming_one_triple_twice_is_refused() {
6260 let (mut engine, _tmp, id, hash) = anchored_engine();
6261 let (actor, client) = cli_actor();
6262
6263 let mut args = anchor_args(id.clone(), Some(hash.clone()));
6264 args.anchors = vec![
6265 anchor_input("a.rs", "h-first"),
6266 anchor_input("a.rs", "h-second"),
6267 ];
6268 let err = engine
6269 .update_entity(args, actor, Some(&client), None)
6270 .expect_err("the repeated triple must refuse");
6271 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
6272 assert!(
6273 format!("{err}").contains("more than once"),
6274 "the refusal names the collapse: {err}"
6275 );
6276
6277 assert_eq!(engine.entity_anchors(&id).len(), 2);
6279
6280 let mut span = anchor_input("a.rs", "h-span");
6283 span.grain = Some("span".to_string());
6284 let mut args = anchor_args(id.clone(), Some(hash));
6285 args.anchors = vec![anchor_input("a.rs", "h-file"), span];
6286 engine
6287 .update_entity(args, actor, Some(&client), None)
6288 .expect("two grains on one artifact are two rows");
6289 assert_eq!(engine.entity_anchors(&id).len(), 3);
6290 }
6291
6292 #[test]
6296 fn a_re_pin_without_a_hash_keeps_the_stored_baseline() {
6297 let (mut engine, _tmp, id, hash) = anchored_engine();
6298 let (actor, client) = cli_actor();
6299
6300 let mut hashless = anchor_input("a.rs", "");
6301 hashless.hash = None;
6302 let mut args = anchor_args(id.clone(), Some(hash));
6303 args.anchors = vec![hashless];
6304 engine
6305 .update_entity(args, actor, Some(&client), None)
6306 .unwrap();
6307
6308 let kept = engine
6309 .entity_anchors(&id)
6310 .into_iter()
6311 .find(|a| a.artifact == "a.rs")
6312 .expect("the row is still there");
6313 assert_eq!(
6314 kept.hash.as_deref(),
6315 Some("h-a"),
6316 "the baseline the re-pin did not mention survives it"
6317 );
6318 }
6319
6320 #[test]
6324 fn update_anchor_only_and_unset_only_commit_without_hash_movement() {
6325 let (mut engine, _tmp, id, hash) = anchored_engine();
6326 let (actor, client) = cli_actor();
6327
6328 let mut args = anchor_args(id.clone(), Some(hash.clone()));
6329 args.anchors_unset = vec![anchor_unset("b.rs")];
6330 let out = engine
6331 .update_entity(args, actor, Some(&client), None)
6332 .unwrap();
6333 assert!(
6334 !out.write_id.is_empty(),
6335 "unset-only update commits the sidecar"
6336 );
6337 assert_eq!(out.content_hash, hash, "anchors never move `_hash`");
6338 assert_eq!(engine.entity_anchors(&id).len(), 1);
6339
6340 let err = engine
6343 .update_entity(
6344 anchor_args(id.clone(), Some(hash)),
6345 actor,
6346 Some(&client),
6347 None,
6348 )
6349 .unwrap_err();
6350 assert!(matches!(err, EngineError::EmptyUpdate { .. }));
6351 }
6352
6353 #[test]
6361 fn anchor_only_update_across_second_boundary_never_moves_hash() {
6362 let (mut engine, _tmp, id, hash) = anchored_engine();
6363 let (actor, client) = cli_actor();
6364
6365 let t0 = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_778_243_696);
6366 engine.set_mutation_clock(std::sync::Arc::new(move || t0));
6367 let mut args = anchor_args(id.clone(), Some(hash));
6370 args.metadata = [("level".to_string(), "M1".to_string())]
6371 .into_iter()
6372 .collect();
6373 let restamped = engine
6374 .update_entity(args, actor, Some(&client), None)
6375 .unwrap();
6376
6377 let t1 = t0 + std::time::Duration::from_secs(1);
6379 engine.set_mutation_clock(std::sync::Arc::new(move || t1));
6380 let mut args = anchor_args(id.clone(), Some(restamped.content_hash.clone()));
6381 args.anchors = vec![anchor_input("c.rs", "h-c")];
6382 let out = engine
6383 .update_entity(args, actor, Some(&client), None)
6384 .unwrap();
6385 assert!(!out.write_id.is_empty(), "anchor-only update commits");
6386 assert_eq!(
6387 out.content_hash, restamped.content_hash,
6388 "anchors never move `_hash`, even across a second boundary"
6389 );
6390 let entity = engine.store().get(&id).unwrap();
6392 assert_eq!(
6393 entity
6394 .metadata
6395 .get("last_modified")
6396 .and_then(|v| v.as_str()),
6397 Some("2026-05-08T12:34:56Z"),
6398 "anchor-only update must not restamp last_modified"
6399 );
6400 }
6401
6402 #[test]
6406 fn malformed_anchor_unset_refuses_and_nothing_is_written() {
6407 let (mut engine, tmp, id, hash) = anchored_engine();
6408 let (actor, client) = cli_actor();
6409 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
6410 let before = std::fs::read(&sidecar_path).unwrap();
6411
6412 let mut bad = anchor_unset("a.rs");
6413 bad.grain = Some("paragraph".to_string()); let mut args = anchor_args(id.clone(), Some(hash));
6415 args.anchors_unset = vec![bad];
6416 args.anchors = vec![anchor_input("c.rs", "h-c")];
6418 let err = engine
6419 .update_entity(args, actor, Some(&client), None)
6420 .unwrap_err();
6421 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
6422 assert_eq!(engine.entity_anchors(&id).len(), 2, "no partial apply");
6423 assert_eq!(std::fs::read(&sidecar_path).unwrap(), before);
6424 }
6425
6426 fn bare_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
6430 UpdateEntityArgs {
6431 anchors: Vec::new(),
6432 anchors_unset: Vec::new(),
6433 id,
6434 expected_hash: hash,
6435 sections: IndexMap::new(),
6436 append_sections: IndexMap::new(),
6437 patch_sections: IndexMap::new(),
6438 sections_unset: Vec::new(),
6439 metadata: IndexMap::new(),
6440 metadata_unset: Vec::new(),
6441 declare_relations: Vec::new(),
6442 dry_run: false,
6443 relations_unset: Vec::new(),
6444 }
6445 }
6446
6447 #[test]
6455 fn reserved_key_unset_repairs_smuggled_entity_and_set_stays_refused() {
6456 let tmp = TempDir::new().unwrap();
6457 let mem_dir = tmp.path().to_path_buf();
6458 std::fs::write(
6460 mem_dir.join("smuggled.md"),
6461 "---\ntype: spec\nmem: wrong-mem\nid: bogus-id\n---\n# Smuggled\n\n## Identity\n\nsmuggled identity.\n\n## Purpose\n\nsmuggled purpose.\n",
6462 )
6463 .unwrap();
6464 let writer = FilesystemMemWriter::new(mem_dir.clone());
6465 let mut engine = Engine::from_mounts(vec![(
6466 folder_mount("specs", mem_dir.clone()),
6467 Box::new(writer) as Box<dyn MemBackend>,
6468 )])
6469 .unwrap();
6470 let (actor, client) = cli_actor();
6471 let id = EntityId::new("specs", "smuggled");
6472 let entity = engine.get_entity(&id).expect("fixture boots");
6473 assert!(
6474 entity.metadata.contains_key("mem") && entity.metadata.contains_key("id"),
6475 "fixture must carry the smuggled keys after boot"
6476 );
6477 let hash = entity.content_hash.clone();
6478
6479 for reserved in ["type", "mem", "id"] {
6481 let mut args = bare_args(id.clone(), Some(hash.clone()));
6482 args.metadata
6483 .insert(reserved.to_string(), "resmuggled".to_string());
6484 let err = engine
6485 .update_entity(args, actor, Some(&client), None)
6486 .expect_err("reserved-key set must refuse on update");
6487 assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
6488 }
6489 let mut batch_item = bare_args(id.clone(), Some(hash.clone()));
6492 batch_item
6493 .metadata
6494 .insert("id".to_string(), "resmuggled".to_string());
6495 let batch = engine
6496 .batch_update(vec![(batch_item, None)], actor, Some(&client), false)
6497 .expect("batch returns a result envelope");
6498 assert!(
6499 !batch.applied,
6500 "batch with a reserved-key set must not apply"
6501 );
6502 assert_eq!(batch.failed, 1);
6503
6504 let mut args = bare_args(id.clone(), Some(hash));
6506 args.metadata_unset = vec!["mem".to_string(), "id".to_string()];
6507 let out = engine
6508 .update_entity(args, actor, Some(&client), None)
6509 .expect("reserved-key unset is the sanctioned repair");
6510 assert!(!out.write_id.is_empty(), "repair is a real commit");
6511 assert_eq!(
6512 out.modified_metadata.unset,
6513 vec!["mem".to_string(), "id".to_string()]
6514 );
6515
6516 let entity = engine.get_entity(&id).expect("entity survives repair");
6519 assert!(
6520 !entity.metadata.contains_key("mem") && !entity.metadata.contains_key("id"),
6521 "smuggled keys must be gone from the store"
6522 );
6523 let on_disk = std::fs::read_to_string(mem_dir.join("smuggled.md")).unwrap();
6524 assert!(
6525 !on_disk.contains("wrong-mem") && !on_disk.contains("bogus-id"),
6526 "smuggled keys must be gone from the file: {on_disk}"
6527 );
6528 let mut args = bare_args(id.clone(), Some(entity.content_hash.clone()));
6529 args.sections
6530 .insert("identity".to_string(), "repaired identity".to_string());
6531 engine
6532 .update_entity(args, actor, Some(&client), None)
6533 .expect("post-repair entity round-trips cleanly");
6534 }
6535
6536 #[test]
6544 fn reserved_type_unset_reseeds_and_is_a_noop_on_healthy_entities() {
6545 let tmp = TempDir::new().unwrap();
6546 let mem_dir = tmp.path().to_path_buf();
6547 let writer = FilesystemMemWriter::new(mem_dir.clone());
6548 let mut engine = Engine::from_mounts(vec![(
6549 folder_mount("specs", mem_dir.clone()),
6550 Box::new(writer) as Box<dyn MemBackend>,
6551 )])
6552 .unwrap();
6553 let (actor, client) = cli_actor();
6554 let created = engine
6555 .create_entity(
6556 empty_create_args("specs", "Healthy"),
6557 actor,
6558 Some(&client),
6559 None,
6560 )
6561 .unwrap();
6562 let id = EntityId::new("specs", "healthy");
6563
6564 for key in ["type", "mem", "id"] {
6565 let mut args = bare_args(id.clone(), Some(created.content_hash.clone()));
6566 args.metadata_unset = vec![key.to_string()];
6567 let out = engine
6568 .update_entity(args, actor, Some(&client), None)
6569 .unwrap_or_else(|e| panic!("unset '{key}' on a healthy entity must no-op: {e:?}"));
6570 assert!(
6571 out.write_id.is_empty(),
6572 "unset '{key}' on a healthy entity is a no-op, not a commit"
6573 );
6574 assert!(
6575 out.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
6576 "no-op must carry the UPDATE_NOOP warning for '{key}'"
6577 );
6578 }
6579 let entity = engine.get_entity(&id).unwrap();
6580 assert_eq!(entity.entity_type, "spec");
6581 assert_eq!(
6582 entity.metadata.get("type").and_then(|v| v.as_str()),
6583 Some("spec"),
6584 "the discriminator survives a type unset"
6585 );
6586 }
6587
6588 #[test]
6597 fn declare_relations_refuses_cycle_and_self_loop_like_relate() {
6598 let tmp = TempDir::new().unwrap();
6599 let mem_dir = tmp.path().to_path_buf();
6600 let writer = FilesystemMemWriter::new(mem_dir.clone());
6601 let mut engine = Engine::from_mounts(vec![(
6602 folder_mount("specs", mem_dir),
6603 Box::new(writer) as Box<dyn MemBackend>,
6604 )])
6605 .unwrap();
6606 let (actor, client) = cli_actor();
6607
6608 let alpha = engine
6610 .create_entity(
6611 empty_create_args("specs", "Alpha"),
6612 actor,
6613 Some(&client),
6614 None,
6615 )
6616 .unwrap();
6617 let beta = engine
6618 .create_entity(
6619 empty_create_args("specs", "Beta"),
6620 actor,
6621 Some(&client),
6622 None,
6623 )
6624 .unwrap();
6625 engine
6626 .relate_entity(
6627 crate::engine::RelateEntityArgs {
6628 source: alpha.id.clone(),
6629 target: beta.id.clone(),
6630 rel_type: "PART_OF".to_string(),
6631 remove: false,
6632 expected_hash: None,
6633 description: None,
6634 dry_run: false,
6635 },
6636 actor,
6637 Some(&client),
6638 None,
6639 )
6640 .unwrap();
6641
6642 let declare = |rel_type: &str, from: &EntityId, to: &EntityId, hash: String| {
6643 let mut args = bare_args(from.clone(), Some(hash));
6644 args.declare_relations = vec![crate::ops::RelateArg {
6645 target: to.clone(),
6646 rel_type: rel_type.to_string(),
6647 description: None,
6648 }];
6649 args
6650 };
6651
6652 let err = engine
6654 .update_entity(
6655 declare("PART_OF", &beta.id, &alpha.id, beta.content_hash.clone()),
6656 actor,
6657 Some(&client),
6658 None,
6659 )
6660 .expect_err("cycle-closing declare_relations must refuse");
6661 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
6662 let details = err.details();
6663 assert_eq!(details["rel_type"], "PART_OF");
6664 assert!(details["existing_path"].is_array());
6665 assert!(
6666 engine
6667 .get_entity(&beta.id)
6668 .unwrap()
6669 .relationships
6670 .is_empty(),
6671 "the refused edge must not land"
6672 );
6673
6674 let alpha_hash = engine.get_entity(&alpha.id).unwrap().content_hash.clone();
6677 let err = engine
6678 .update_entity(
6679 declare("USES", &alpha.id, &alpha.id, alpha_hash),
6680 actor,
6681 Some(&client),
6682 None,
6683 )
6684 .expect_err("self-loop declare_relations must refuse");
6685 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
6686
6687 engine
6689 .update_entity(
6690 declare(
6691 "PART_OF",
6692 &beta.id,
6693 &EntityId::new("specs", "gamma"),
6694 beta.content_hash.clone(),
6695 ),
6696 actor,
6697 Some(&client),
6698 None,
6699 )
6700 .expect("a non-cycle PART_OF declare must land as today");
6701 }
6702}