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 anchors_changed: Option<bool>,
83 content_changed: bool,
86}
87
88struct AppliedWrite {
91 content_hash: String,
92 title: String,
93 orphan_stubs_removed: Vec<EntityId>,
94}
95
96impl Engine {
97 pub fn update_entity(
113 &mut self,
114 args: UpdateEntityArgs,
115 actor: Actor,
116 client: Option<&ClientId>,
117 note: Option<&str>,
118 ) -> Result<UpdateEntityOutcome, EngineError> {
119 let mut args = args;
122 let (resolved, short_hint) = self.resolve_entity_id(&args.id)?;
123 args.id = resolved;
124 let mut drift_warnings: Vec<WarningHint> = short_hint.into_iter().collect();
125 for r in &mut args.declare_relations {
126 let (target, hint) = self.resolve_entity_id(&r.target)?;
127 r.target = target;
128 drift_warnings.extend(hint);
129 }
130 drift_warnings.extend(self.reload_if_stale(Some(args.id.mem())));
137 if args.declare_relations.iter().any(|r| {
147 self.schemas.get(args.id.mem()).is_some_and(|s| {
148 s.relationship_acyclic(&r.rel_type)
149 || s.acyclic_set_containing(&r.rel_type).is_some()
150 }) || self
151 .schemas
152 .get(r.target.mem())
153 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
154 }) || self
155 .schemas
156 .get(args.id.mem())
157 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
158 {
159 self.ensure_mems_loaded(None);
160 }
161 let mut outcome = match self.prepare_update(args)? {
162 PrepareOutcome::Done(outcome) => outcome,
163 PrepareOutcome::Prepared(prepared) => {
164 self.commit_prepared_update(prepared, actor, client, note)?
165 }
166 };
167 drift_warnings.append(&mut outcome.warnings);
168 outcome.warnings = drift_warnings;
169 Ok(outcome)
170 }
171
172 fn commit_prepared_update(
177 &mut self,
178 prepared: PreparedUpdate,
179 actor: Actor,
180 client: Option<&ClientId>,
181 note: Option<&str>,
182 ) -> Result<UpdateEntityOutcome, EngineError> {
183 let signal_snapshot = {
192 let mut candidates: Vec<EntityId> = vec![prepared.id.clone()];
193 candidates.extend(
194 self.store
195 .outgoing(&prepared.id)
196 .iter()
197 .map(|e| e.target.clone()),
198 );
199 candidates.extend(
200 self.store
201 .incoming(&prepared.id)
202 .iter()
203 .map(|e| e.from.clone()),
204 );
205 if let Ok(parsed) = parse_markdown(
206 &prepared.markdown,
207 &prepared.file_path,
208 prepared.type_def.as_ref(),
209 &prepared.mem,
210 ) {
211 candidates.extend(parsed.entity.relationships.iter().map(|r| r.target.clone()));
212 }
213 crate::ops::signals::snapshot_levels(&self.store, &self.schemas, candidates.iter())
214 };
215 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
216 backend.write_entity(Path::new(&prepared.file_path), prepared.markdown.as_bytes())?;
217 if prepared.anchors_changed == Some(true) {
220 super::stage_anchors_sidecar(
221 backend,
222 &prepared.id,
223 &prepared.anchor_unsets,
224 prepared.anchors.clone(),
225 prepared.content_changed,
226 )?;
227 }
228 if let Some(schema) = self.schemas.get(prepared.id.mem()) {
232 for r in prepared
233 .relations_declared
234 .iter()
235 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
236 {
237 let hash = self
238 .store
239 .get(&r.target)
240 .map(|e| e.content_hash.clone())
241 .unwrap_or_default();
242 let (from, rel, to) = (
243 prepared.id.to_string(),
244 r.rel_type.clone(),
245 r.target.to_string(),
246 );
247 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
248 }
249 }
250 let commit_subject = if prepared.anchor_only {
256 format!("memstead: anchor {}", prepared.id)
257 } else {
258 format!("memstead: update {}", prepared.id)
259 };
260 let ctx = CommitContext {
261 actor,
262 client: client.cloned(),
263 tool: Some("update_entity"),
264 note: note.map(String::from),
265 role: self.current_role,
266 identity: self.current_identity.clone(),
267 logical_operation_id: None,
268 entity_ids: None,
269 };
270 let write_id = backend.commit(&commit_subject, &ctx)?;
271 backend.append_provenance(
272 &Provenance::new(
273 std::time::SystemTime::now(),
274 ProvenanceKind::Update,
275 Some(prepared.id.to_string()),
276 actor,
277 client.cloned(),
278 note.map(String::from),
279 )
280 .with_role(self.current_role)
281 .with_identity(self.current_identity.clone()),
282 )?;
283 self.record_self_write(prepared.mount_idx, &write_id);
284 let stamp_warnings = self.stamp_mutation_versions(prepared.mount_idx);
285
286 let applied = self.apply_prepared_to_store(&prepared)?;
287
288 self.invalidate_communities();
289 self.maintain_search_indexes(std::slice::from_ref(&prepared.id));
293
294 let mut warnings = prepared.warnings;
298 warnings.extend(stamp_warnings);
299 warnings.extend(crate::ops::signals::crossing_warnings(
302 &self.store,
303 &self.schemas,
304 &signal_snapshot,
305 ));
306 if let Some(w) = self.note_missing_warning("update_entity", note) {
307 warnings.push(w);
308 }
309
310 Ok(UpdateEntityOutcome {
311 id: prepared.id.clone(),
312 title: applied.title,
313 file_path: prepared.file_path,
314 content_hash: applied.content_hash,
315 write_id,
316 modified_date: prepared.modified_date,
317 orphan_stubs_removed: applied.orphan_stubs_removed,
318 modified_sections: prepared.modified_sections,
319 modified_metadata: prepared.modified_metadata,
320 prospective_hash: None,
321 warnings,
322 relations_declared: prepared.relations_declared,
323 anchors_changed: prepared.anchors_changed,
324 })
325 }
326
327 fn apply_prepared_to_store(
334 &mut self,
335 prepared: &PreparedUpdate,
336 ) -> Result<AppliedWrite, EngineError> {
337 let parse_result = parse_markdown(
338 &prepared.markdown,
339 &prepared.file_path,
340 prepared.type_def.as_ref(),
341 &prepared.mem,
342 )
343 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
344 let content_hash = parse_result.entity.content_hash.clone();
345 let title = parse_result.entity.title.clone();
346 let fallback = engine_fallback_type();
347 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
348 crate::entity::store_builder::remap_alias_target_edge_sources(
349 &mut self.store,
350 &self.schemas,
351 );
352 let orphan_stubs_removed =
353 super::gc_orphan_stubs_among(&mut self.store, &prepared.prev_body_targets);
354 Ok(AppliedWrite {
355 content_hash,
356 title,
357 orphan_stubs_removed,
358 })
359 }
360
361 fn prepare_update(&mut self, args: UpdateEntityArgs) -> Result<PrepareOutcome, EngineError> {
369 let id = &args.id;
370 let mem = id.mem().to_string();
371
372 let mount_idx = self
373 .mounts
374 .iter()
375 .position(|m| m.mount.mem == mem)
376 .ok_or_else(|| self.unknown_mem_error(&mem))?;
377 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
378 return Err(EngineError::ReadOnlyMount(mem));
379 }
380
381 let entity = self
382 .store
383 .get(id)
384 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
385
386 let prev_body_targets = super::collect_body_link_targets(entity);
392
393 if entity.stub {
401 return Err(EngineError::StubNotUpdatable { id: id.to_string() });
402 }
403
404 if !args.dry_run
410 && let Some(expected) = args.expected_hash.as_deref()
411 && entity.content_hash != expected
412 {
413 return Err(EngineError::HashMismatch {
414 id: id.to_string(),
415 current: entity.content_hash.clone(),
416 is_stub: entity.stub,
417 });
418 }
419
420 if args.sections.is_empty()
432 && args.append_sections.is_empty()
433 && args.patch_sections.is_empty()
434 && args.sections_unset.is_empty()
435 && args.metadata.is_empty()
436 && args.metadata_unset.is_empty()
437 && args.declare_relations.is_empty()
438 && args.relations_unset.is_empty()
439 && args.anchors.is_empty()
440 && args.anchors_unset.is_empty()
441 {
442 return Err(EngineError::EmptyUpdate { id: id.to_string() });
443 }
444
445 let validated_anchors = self.validate_anchor_inputs(&mem, &args.anchors)?;
451 let validated_anchor_unsets = Self::validate_anchor_unsets(&args.anchors_unset)?;
452
453 let schema = self
454 .schemas
455 .get(&mem)
456 .expect("schema present for every registered mount")
457 .clone();
458 let type_def = schema
459 .get_type(&entity.entity_type)
460 .ok_or_else(|| unknown_type_error(schema.as_ref(), &entity.entity_type))?;
461
462 for key in args.sections.keys() {
469 let mut modes = vec!["sections".to_string()];
470 if args.append_sections.contains_key(key) {
471 modes.push("append_sections".to_string());
472 }
473 if args.patch_sections.contains_key(key) {
474 modes.push("patch_sections".to_string());
475 }
476 if modes.len() > 1 {
477 return Err(EngineError::ConflictingSectionModes {
478 section: key.clone(),
479 modes,
480 });
481 }
482 }
483 for key in args.append_sections.keys() {
484 if args.patch_sections.contains_key(key) {
485 return Err(EngineError::ConflictingSectionModes {
486 section: key.clone(),
487 modes: vec!["append_sections".to_string(), "patch_sections".to_string()],
488 });
489 }
490 }
491 for key in &args.sections_unset {
494 let mut modes = vec!["sections_unset".to_string()];
495 if args.sections.contains_key(key) {
496 modes.push("sections".to_string());
497 }
498 if args.append_sections.contains_key(key) {
499 modes.push("append_sections".to_string());
500 }
501 if args.patch_sections.contains_key(key) {
502 modes.push("patch_sections".to_string());
503 }
504 if modes.len() > 1 {
505 return Err(EngineError::ConflictingSectionModes {
506 section: key.clone(),
507 modes,
508 });
509 }
510 }
511 let unset_required: Vec<crate::runtime_validator::MissingRequiredSection> = type_def
516 .required_sections()
517 .filter(|sec| args.sections_unset.contains(&sec.key))
518 .map(|sec| crate::runtime_validator::MissingRequiredSection {
519 entity_type: type_def.name.clone(),
520 key: sec.key.clone(),
521 heading: sec.heading.clone(),
522 write_rules: sec.write_rules.clone(),
523 })
524 .collect();
525 if !unset_required.is_empty() {
526 let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> =
527 std::collections::BTreeMap::new();
528 type_guidance.insert(type_def.name.clone(), type_def.write_rules.clone());
529 return Err(EngineError::MissingRequiredSection {
530 entity_type: type_def.name.clone(),
531 missing_count: unset_required.len(),
532 sections: unset_required,
533 type_guidance,
534 pre_announced_missing_fields: Vec::new(),
535 });
536 }
537 for key in &args.sections_unset {
543 if entity.sections.contains_key(key) || key == "relationships" {
544 validate_updatable_section(key.as_str(), type_def.as_ref())?;
545 }
546 }
547
548 validate_section_keys(
549 args.sections
550 .keys()
551 .chain(args.append_sections.keys())
552 .chain(args.patch_sections.keys())
553 .map(String::as_str),
554 type_def.as_ref(),
555 )?;
556 let mut heading_buf: Vec<&str> = Vec::new();
557 #[allow(unused_assignments)]
558 let mut catch_all = None;
559 validate_section_content(
565 args.sections
566 .iter()
567 .map(|(k, v)| (k.as_str(), v.as_str()))
568 .chain(
569 args.append_sections
570 .iter()
571 .map(|(k, v)| (k.as_str(), v.as_str())),
572 )
573 .chain(
574 args.patch_sections
575 .iter()
576 .flat_map(|(k, ps)| ps.iter().map(move |p| (k.as_str(), p.new.as_str()))),
577 ),
578 {
579 let t: &memstead_schema::TypeDefinition = type_def.as_ref();
580 catch_all = crate::runtime_validator::catch_all_context(t, &mut heading_buf);
581 catch_all
582 },
583 )?;
584 for key in args.sections.keys() {
585 validate_updatable_section(key.as_str(), type_def.as_ref())?;
586 }
587 for key in args.append_sections.keys() {
588 validate_updatable_section(key.as_str(), type_def.as_ref())?;
589 }
590 for key in args.patch_sections.keys() {
591 validate_updatable_section(key.as_str(), type_def.as_ref())?;
592 }
593 for key in args.metadata.keys() {
594 validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
595 }
596 for key in &args.metadata_unset {
602 validate_unsettable_metadata_key(key.as_str(), type_def.as_ref())?;
603 }
604
605 let mut overlap: Vec<String> = args
612 .metadata
613 .keys()
614 .filter(|k| args.metadata_unset.iter().any(|u| u == k.as_str()))
615 .cloned()
616 .collect();
617 if !overlap.is_empty() {
618 overlap.sort();
619 overlap.dedup();
620 return Err(EngineError::SetAndUnsetConflict { keys: overlap });
621 }
622
623 if !args.relations_unset.is_empty() {
632 let findings = crate::ops::integrity::entity_conformance_findings(
633 &self.store,
634 entity,
635 schema.as_ref(),
636 &self.schemas,
637 );
638 if findings.is_empty() {
639 return Err(EngineError::RepairNotNeeded {
640 id: id.to_string(),
641 recovery: "use memstead_relate(remove=true) to detach an edge from a conformant entity, or the additive memstead_update params to evolve it"
642 .to_string(),
643 });
644 }
645 }
646
647 let mut next = entity.clone();
648
649 for unset in &args.relations_unset {
656 let canonical = crate::entity::id::validate_rel_type(&unset.rel_type)
657 .unwrap_or_else(|_| unset.rel_type.clone());
658 next.relationships
659 .retain(|r| !(r.rel_type == canonical && r.target == unset.target));
660 }
661
662 let relations_declared = apply_declare_relations(
672 self,
673 &mut next,
674 &args.declare_relations,
675 &mem,
676 mount_idx,
677 type_def.as_ref(),
678 schema.as_ref(),
679 )?;
680
681 let format_touched: std::collections::HashSet<String> = args
685 .sections
686 .keys()
687 .chain(args.append_sections.keys())
688 .chain(args.patch_sections.keys())
689 .cloned()
690 .collect();
691
692 let mut modified_sections: Vec<String> = Vec::new();
693 for (key, body) in args.sections {
694 modified_sections.push(key.clone());
695 next.sections.insert(key, body);
696 }
697
698 let mut modified_sections_appended: Vec<String> = Vec::new();
702 for (key, value) in args.append_sections {
703 let existing = next.sections.get(&key).cloned().unwrap_or_default();
704 let new_content = if existing.trim().is_empty() {
705 value
706 } else {
707 format!("{existing}\n{value}")
708 };
709 next.sections.insert(key.clone(), new_content);
710 modified_sections_appended.push(key);
711 }
712
713 let mut modified_sections_patched: Vec<String> = Vec::new();
721 for (key, patches) in args.patch_sections {
722 for patch in patches {
727 let existing = next
728 .sections
729 .get(&key)
730 .ok_or_else(|| EngineError::PatchSectionEmpty {
731 section: key.clone(),
732 })?
733 .clone();
734 if !existing.contains(&patch.old) {
735 let cap = PATCH_OLD_NOT_FOUND_CONTENT_CAP;
736 let truncated = existing.len() > cap;
737 let mut cut = cap.min(existing.len());
740 while cut > 0 && !existing.is_char_boundary(cut) {
741 cut -= 1;
742 }
743 let current_content = if truncated {
744 existing[..cut].to_string()
745 } else {
746 existing.clone()
747 };
748 let found_in_sections: Vec<String> = next
752 .sections
753 .iter()
754 .filter(|(k, body)| k.as_str() != key && body.contains(&patch.old))
755 .map(|(k, _)| k.clone())
756 .collect();
757 return Err(EngineError::PatchOldNotFound {
758 section: key,
759 current_content,
760 truncated,
761 found_in_sections,
762 });
763 }
764 let patched = if patch.all {
765 existing.replace(&patch.old, &patch.new)
766 } else {
767 existing.replacen(&patch.old, &patch.new, 1)
768 };
769 next.sections.insert(key.clone(), patched);
770 }
771 modified_sections_patched.push(key);
772 }
773
774 let mut modified_sections_unset: Vec<String> = Vec::new();
779 for key in &args.sections_unset {
780 if next.sections.shift_remove(key).is_some() {
781 modified_sections_unset.push(key.clone());
782 }
783 }
784
785 let mut modified_metadata_set: Vec<String> = Vec::new();
786 for (key, value) in &args.metadata {
787 let parsed = parse_metadata_value(key.as_str(), value.as_str(), type_def.as_ref())?;
788 modified_metadata_set.push(key.clone());
789 next.metadata.insert(key.clone(), parsed);
790 }
791
792 let mut modified_metadata_unset: Vec<String> = Vec::new();
793 for key in args.metadata_unset {
794 if crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str()) {
806 if key == "type" {
807 let authoritative =
808 crate::entity::MetadataValue::String(next.entity_type.clone());
809 if next
810 .metadata
811 .shift_remove("type")
812 .is_some_and(|removed| removed != authoritative)
813 {
814 modified_metadata_unset.push(key);
815 }
816 next.metadata.insert("type".to_string(), authoritative);
817 } else if next.metadata.shift_remove(&key).is_some() {
818 modified_metadata_unset.push(key);
819 }
820 continue;
821 }
822 let field_def = type_def.metadata_field(&key);
827 let is_required = field_def.map(|f| f.is_required()).unwrap_or(false);
828 if is_required {
829 let (field_description, enum_values) = match field_def {
830 Some(f) => (
831 Some(f.description.clone()),
832 f.enum_values.clone().unwrap_or_default(),
833 ),
834 None => (None, Vec::new()),
835 };
836 return Err(EngineError::RequiredFieldUnset {
837 field: key,
838 entity_type: type_def.name.clone(),
839 field_description,
840 enum_values,
841 type_write_rules: type_def.write_rules.clone(),
842 on_create: false,
848 missing: Vec::new(),
853 });
854 }
855 if next.metadata.shift_remove(&key).is_some() {
856 modified_metadata_unset.push(key);
857 }
858 }
859
860 let today = self.now_iso();
869
870 let alias_outcome = super::synthesise_alias_relations(self, &prev_body_targets, &mut next)?;
881 let synthesised_relations = alias_outcome.emitted;
882 let self_link_ignored = alias_outcome.self_link_ignored;
883 let undeclared_targets: std::collections::HashSet<crate::entity::EntityId> = alias_outcome
884 .undeclared_dropped
885 .iter()
886 .map(|d| d.target.clone())
887 .collect();
888 let undeclared_dropped = alias_outcome.undeclared_dropped;
889
890 let missing = super::scan_wikilinks_without_relation(&next, &undeclared_targets)?;
896 if !missing.is_empty() {
897 return Err(EngineError::WikiLinkWithoutRelation {
898 from_id: id.to_string(),
899 missing: missing
900 .into_iter()
901 .map(|(section_key, target)| crate::engine::MissingWikiLink {
902 section_key,
903 target_id: target.to_string(),
904 })
905 .collect(),
906 });
907 }
908
909 let file_path = next.file_path.clone();
910
911 let markdown_pre_stamp = super::render_for_write(&next, type_def.as_ref())?;
920
921 let content_unchanged =
932 crate::entity::parser::compute_hash(&markdown_pre_stamp) == next.content_hash;
933
934 let anchors_changed: Option<bool> =
953 if validated_anchors.is_empty() && validated_anchor_unsets.is_empty() {
954 None
955 } else {
956 Some(super::anchors_would_change(
957 self.mounts[mount_idx].backend.as_ref(),
958 id,
959 &validated_anchor_unsets,
960 &validated_anchors,
961 !content_unchanged,
962 )?)
963 };
964 if !args.dry_run {
965 if content_unchanged && anchors_changed != Some(true) {
969 let modified_date = next
974 .metadata
975 .get("last_modified")
976 .and_then(|v| v.as_str().map(str::to_string))
977 .unwrap_or_default();
978 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
979 id: id.clone(),
980 title: next.title.clone(),
981 file_path,
982 content_hash: next.content_hash.clone(),
983 write_id: String::new(),
984 modified_date,
985 modified_sections: ModifiedSections::default(),
994 modified_metadata: ModifiedMetadata::default(),
995 prospective_hash: None,
996 orphan_stubs_removed: Vec::new(),
999 warnings: vec![WarningHint::UpdateNoop { id: id.clone() }],
1000 relations_declared,
1001 anchors_changed,
1002 }));
1003 }
1004 }
1005
1006 if !content_unchanged {
1016 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
1017 }
1018 let markdown = super::render_for_write(&next, type_def.as_ref())?;
1019
1020 let mut warnings: Vec<WarningHint> = Vec::new();
1021
1022 for key in modified_sections
1031 .iter()
1032 .chain(modified_sections_appended.iter())
1033 .chain(modified_sections_patched.iter())
1034 {
1035 let Some(def) = type_def.section(key) else {
1036 continue;
1037 };
1038 if let Some(existing) = next.raw_section_headings.iter().find(|h| {
1039 h.as_str() != def.heading && memstead_schema::derive_section_key(h) == *key
1040 }) {
1041 warnings.push(WarningHint::SectionHeadingDivergence {
1042 entity_id: id.clone(),
1043 section_key: key.clone(),
1044 writing_heading: def.heading.clone(),
1045 existing_heading: existing.clone(),
1046 });
1047 }
1048 }
1049
1050 for def in &type_def.sections {
1064 if def.format_severity != memstead_schema::ConstraintSeverity::Block {
1065 continue;
1066 }
1067 if !format_touched.contains(def.key.as_str()) {
1068 continue;
1069 }
1070 let Some(body) = next.sections.get(def.key.as_str()) else {
1071 continue;
1072 };
1073 if let Some(first) = crate::section_format::check_section_format(def, body)
1074 .into_iter()
1075 .next()
1076 {
1077 return Err(EngineError::SectionFormatRefused {
1078 entity_type: next.entity_type.clone(),
1079 entity_id: id.to_string(),
1080 violation: first,
1081 });
1082 }
1083 }
1084
1085 let unsatisfied =
1086 crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref());
1087 if !unsatisfied.is_empty() {
1088 let blocked: Vec<_> = unsatisfied
1092 .iter()
1093 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
1094 .cloned()
1095 .collect();
1096 if !blocked.is_empty() {
1097 return Err(EngineError::RequiredOutgoingUnsatisfied {
1098 entity_type: next.entity_type.clone(),
1099 entity_id: id.to_string(),
1100 missing: blocked,
1101 });
1102 }
1103 warnings.push(WarningHint::MissingRequiredOutgoing {
1104 entity_type: next.entity_type.clone(),
1105 entity_id: id.clone(),
1106 missing: unsatisfied,
1107 });
1108 }
1109
1110 let check_provider = self.check_standing_provider();
1114 let violated = crate::ops::health::unsatisfied_constraints(
1115 &self.store,
1116 &next,
1117 type_def.as_ref(),
1118 Some(id),
1119 Some(&check_provider),
1120 );
1121 if !violated.is_empty() {
1122 let blocked: Vec<_> = violated
1123 .iter()
1124 .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
1125 .cloned()
1126 .collect();
1127 if !blocked.is_empty() {
1128 return Err(EngineError::ConstraintUnsatisfied {
1129 entity_type: next.entity_type.clone(),
1130 entity_id: id.to_string(),
1131 violations: blocked,
1132 });
1133 }
1134 warnings.push(WarningHint::ConstraintUnsatisfied {
1135 entity_type: next.entity_type.clone(),
1136 entity_id: id.clone(),
1137 violations: violated,
1138 });
1139 }
1140
1141 let auto_stubbed: Vec<EntityId> = synthesised_relations
1149 .iter()
1150 .filter_map(|rel| {
1151 if !self.store.contains(&rel.target) {
1152 Some(rel.target.clone())
1153 } else {
1154 None
1155 }
1156 })
1157 .collect();
1158 if !auto_stubbed.is_empty() {
1159 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
1160 from: id.clone(),
1161 stubs: auto_stubbed,
1162 });
1163 }
1164 if self_link_ignored {
1167 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
1168 }
1169 for dropped in undeclared_dropped {
1173 warnings.push(WarningHint::CrossSchemaLinkUndeclared {
1174 from: id.clone(),
1175 target: dropped.target,
1176 source_schema: dropped.source_schema,
1177 target_schema: dropped.target_schema,
1178 });
1179 }
1180
1181 if args.dry_run {
1188 let prospective = crate::entity::parser::compute_hash(&markdown);
1189 let current_hash = next.content_hash.clone();
1193 let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1194 today.clone()
1195 } else {
1196 String::new()
1197 };
1198 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
1199 id: id.clone(),
1200 title: next.title.clone(),
1201 file_path,
1202 content_hash: current_hash,
1203 write_id: String::new(),
1204 modified_date,
1205 modified_sections: ModifiedSections {
1206 replaced: modified_sections,
1207 appended: modified_sections_appended,
1208 patched: modified_sections_patched,
1209 unset: modified_sections_unset,
1210 },
1211 modified_metadata: ModifiedMetadata {
1212 set: modified_metadata_set,
1213 unset: modified_metadata_unset,
1214 },
1215 prospective_hash: Some(prospective),
1216 orphan_stubs_removed: Vec::new(),
1219 warnings,
1220 relations_declared: relations_declared.clone(),
1221 anchors_changed,
1222 }));
1223 }
1224
1225 let modified_date = if content_unchanged {
1232 next.metadata
1235 .get("last_modified")
1236 .and_then(|v| v.as_str().map(str::to_string))
1237 .unwrap_or_default()
1238 } else if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1239 today.clone()
1240 } else {
1241 String::new()
1242 };
1243
1244 Ok(PrepareOutcome::Prepared(PreparedUpdate {
1245 mount_idx,
1246 id: id.clone(),
1247 mem,
1248 type_def,
1249 file_path,
1250 markdown,
1251 prev_body_targets,
1252 modified_date,
1253 modified_sections: ModifiedSections {
1254 replaced: modified_sections,
1255 appended: modified_sections_appended,
1256 patched: modified_sections_patched,
1257 unset: modified_sections_unset,
1258 },
1259 modified_metadata: ModifiedMetadata {
1260 set: modified_metadata_set,
1261 unset: modified_metadata_unset,
1262 },
1263 warnings,
1266 relations_declared,
1267 anchor_only: content_unchanged && anchors_changed == Some(true),
1275 anchors_changed,
1276 content_changed: !content_unchanged,
1277 anchors: validated_anchors,
1278 anchor_unsets: validated_anchor_unsets,
1279 }))
1280 }
1281
1282 pub fn batch_update(
1324 &mut self,
1325 updates: Vec<(UpdateEntityArgs, Option<String>)>,
1326 actor: Actor,
1327 client: Option<&ClientId>,
1328 dry_run: bool,
1329 ) -> Result<crate::ops::BatchResult, EngineError> {
1330 if updates.is_empty() {
1331 return Ok(crate::ops::BatchResult {
1332 warnings: Vec::new(),
1333 orphan_stubs_removed: Vec::new(),
1334 errors_suppressed: 0,
1335 applied: true,
1336 results: Vec::new(),
1337 succeeded: 0,
1338 failed: 0,
1339 write_id: String::new(),
1340 });
1341 }
1342
1343 let mut short_hints: Vec<WarningHint> = Vec::new();
1353 let mut short_errors: Vec<(usize, EngineError)> = Vec::new();
1354 let updates: Vec<(UpdateEntityArgs, Option<String>)> = updates
1355 .into_iter()
1356 .enumerate()
1357 .map(|(i, (mut a, n))| {
1358 match self.resolve_entity_id(&a.id) {
1359 Ok((rid, hint)) => {
1360 a.id = rid;
1361 short_hints.extend(hint);
1362 }
1363 Err(e) => short_errors.push((i, e)),
1364 }
1365 for r in &mut a.declare_relations {
1366 match self.resolve_entity_id(&r.target) {
1367 Ok((t, hint)) => {
1368 r.target = t;
1369 short_hints.extend(hint);
1370 }
1371 Err(e) => short_errors.push((i, e)),
1372 }
1373 }
1374 (a, n)
1375 })
1376 .collect();
1377 let mut touched_mems: Vec<String> = updates
1378 .iter()
1379 .map(|(a, _)| a.id.mem().to_string())
1380 .collect();
1381 touched_mems.sort();
1382 touched_mems.dedup();
1383 for v in &touched_mems {
1384 self.reload_if_stale(Some(v));
1385 }
1386 if updates.iter().any(|(a, _)| {
1393 a.declare_relations.iter().any(|r| {
1394 self.schemas.get(a.id.mem()).is_some_and(|s| {
1395 s.relationship_acyclic(&r.rel_type)
1396 || s.acyclic_set_containing(&r.rel_type).is_some()
1397 }) || self
1398 .schemas
1399 .get(r.target.mem())
1400 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1401 }) || self
1402 .schemas
1403 .get(a.id.mem())
1404 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1405 }) {
1406 self.ensure_mems_loaded(None);
1407 }
1408
1409 let store_snapshot = self.store.clone();
1415
1416 enum Item {
1422 Prepared,
1423 Noop,
1424 Error,
1425 }
1426 let mut items: Vec<(EntityId, Item)> = Vec::with_capacity(updates.len());
1427 let mut prepared: Vec<PreparedUpdate> = Vec::new();
1428 let mut notes: Vec<Option<String>> = Vec::new();
1429 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1430
1431 for (i, (args, note)) in updates.into_iter().enumerate() {
1436 let id = args.id.clone();
1437 if let Some(pos) = short_errors.iter().position(|(j, _)| *j == i) {
1438 let (_, e) = short_errors.remove(pos);
1439 items.push((id, Item::Error));
1440 errors.push((i, e));
1441 continue;
1442 }
1443 let mut args = args;
1448 args.dry_run = false;
1449 match self.prepare_update(args) {
1450 Ok(PrepareOutcome::Done(_)) => {
1451 items.push((id, Item::Noop));
1453 }
1454 Ok(PrepareOutcome::Prepared(p)) => {
1455 prepared.push(p);
1456 notes.push(note);
1457 items.push((id, Item::Prepared));
1458 }
1459 Err(e) => {
1460 items.push((id, Item::Error));
1461 errors.push((i, e));
1462 }
1463 }
1464 }
1465
1466 if !errors.is_empty() {
1467 self.store = store_snapshot;
1472 self.discard_all_pending();
1473 let failed = errors.len();
1474 let mut error_map: std::collections::HashMap<usize, EngineError> =
1475 errors.into_iter().collect();
1476 let mut reported = 0usize;
1477 let mut suppressed = 0usize;
1478 let results: Vec<crate::ops::BatchEntry> = items
1479 .into_iter()
1480 .enumerate()
1481 .map(|(i, (id, _))| match error_map.remove(&i) {
1482 Some(e) => {
1483 if reported < Self::BATCH_ERROR_REPORT_CAP {
1484 reported += 1;
1485 crate::ops::BatchEntry {
1486 id,
1487 action: "error".to_string(),
1488 error: Some(batch_error_envelope(&e)),
1489 }
1490 } else {
1491 suppressed += 1;
1492 crate::ops::BatchEntry {
1493 id,
1494 action: "error".to_string(),
1495 error: None,
1496 }
1497 }
1498 }
1499 None => crate::ops::BatchEntry {
1500 id,
1501 action: "not_applied".to_string(),
1502 error: None,
1503 },
1504 })
1505 .collect();
1506 return Ok(crate::ops::BatchResult {
1507 warnings: Vec::new(),
1508 orphan_stubs_removed: Vec::new(),
1509 errors_suppressed: suppressed,
1510 applied: false,
1511 results,
1512 succeeded: 0,
1513 failed,
1514 write_id: String::new(),
1515 });
1516 }
1517
1518 if dry_run {
1524 self.store = store_snapshot;
1525 self.discard_all_pending();
1526 let succeeded = items.len();
1527 let results: Vec<crate::ops::BatchEntry> = items
1528 .into_iter()
1529 .map(|(id, item)| crate::ops::BatchEntry {
1530 id,
1531 action: match item {
1532 Item::Prepared => "updated".to_string(),
1533 Item::Noop => "noop".to_string(),
1534 Item::Error => unreachable!("refusal path returned above"),
1535 },
1536 error: None,
1537 })
1538 .collect();
1539 return Ok(crate::ops::BatchResult {
1540 warnings: Vec::new(),
1541 orphan_stubs_removed: Vec::new(),
1542 errors_suppressed: 0,
1543 applied: true,
1544 results,
1545 succeeded,
1546 failed: 0,
1547 write_id: String::new(),
1548 });
1549 }
1550
1551 for p in &prepared {
1554 if let Err(e) = self.mounts[p.mount_idx]
1555 .backend
1556 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1557 {
1558 self.store = store_snapshot;
1559 self.discard_all_pending();
1560 return Err(e.into());
1561 }
1562 if (!p.anchors.is_empty() || !p.anchor_unsets.is_empty())
1565 && let Err(e) = super::stage_anchors_sidecar(
1566 self.mounts[p.mount_idx].backend.as_ref(),
1567 &p.id,
1568 &p.anchor_unsets,
1569 p.anchors.clone(),
1570 p.content_changed,
1571 )
1572 {
1573 self.store = store_snapshot;
1574 self.discard_all_pending();
1575 return Err(e);
1576 }
1577 if let Some(schema) = self.schemas.get(p.id.mem()) {
1580 for r in p
1581 .relations_declared
1582 .iter()
1583 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1584 {
1585 let hash = self
1586 .store
1587 .get(&r.target)
1588 .map(|e| e.content_hash.clone())
1589 .unwrap_or_default();
1590 let (from, rel, to) =
1591 (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1592 if let Err(e) = super::stage_derivation_sidecar(
1593 self.mounts[p.mount_idx].backend.as_ref(),
1594 |s| s.set(&from, &rel, &to, &hash),
1595 ) {
1596 self.store = store_snapshot;
1597 self.discard_all_pending();
1598 return Err(e);
1599 }
1600 }
1601 }
1602 }
1603
1604 let mut distinct_mounts: Vec<usize> = Vec::new();
1606 for p in &prepared {
1607 if !distinct_mounts.contains(&p.mount_idx) {
1608 distinct_mounts.push(p.mount_idx);
1609 }
1610 }
1611 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1612 for &m in &distinct_mounts {
1613 let entity_ids: Vec<String> = prepared
1614 .iter()
1615 .filter(|p| p.mount_idx == m)
1616 .map(|p| p.id.to_string())
1617 .collect();
1618 let count = entity_ids.len();
1619 let subject = format!("memstead: batch-update ({count} entities)");
1620 let note_lines: Vec<String> = prepared
1625 .iter()
1626 .zip(notes.iter())
1627 .filter(|(p, _)| p.mount_idx == m)
1628 .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.id)))
1629 .collect();
1630 let ctx = CommitContext {
1631 actor,
1632 client: client.cloned(),
1633 tool: Some("batch_update"),
1634 note: if note_lines.is_empty() {
1635 None
1636 } else {
1637 Some(note_lines.join("\n"))
1638 },
1639 role: self.current_role,
1640 identity: self.current_identity.clone(),
1641 logical_operation_id: None,
1642 entity_ids: Some(entity_ids),
1646 };
1647 match self.mounts[m].backend.commit(&subject, &ctx) {
1648 Ok(sha) => mount_commits.push((m, sha)),
1649 Err(e) => {
1650 self.store = store_snapshot;
1654 self.discard_all_pending();
1655 return Err(e.into());
1656 }
1657 }
1658 }
1659
1660 let mut batch_warnings: Vec<WarningHint> = short_hints;
1664 for (p, note) in prepared.iter().zip(notes.iter()) {
1665 let write_id = mount_commits
1666 .iter()
1667 .find(|(m, _)| *m == p.mount_idx)
1668 .map(|(_, s)| s.clone())
1669 .unwrap_or_default();
1670 self.mounts[p.mount_idx].backend.append_provenance(
1671 &Provenance::new(
1672 std::time::SystemTime::now(),
1673 ProvenanceKind::Update,
1674 Some(p.id.to_string()),
1675 actor,
1676 client.cloned(),
1677 note.clone(),
1678 )
1679 .with_role(self.current_role)
1680 .with_identity(self.current_identity.clone()),
1681 )?;
1682 self.record_self_write(p.mount_idx, &write_id);
1683 batch_warnings.extend(self.stamp_mutation_versions(p.mount_idx));
1684 self.apply_prepared_to_store(p)?;
1685 }
1686
1687 self.invalidate_communities();
1688 self.invalidate_search_indexes();
1689
1690 let write_id = mount_commits
1693 .last()
1694 .map(|(_, s)| s.clone())
1695 .unwrap_or_default();
1696 let succeeded = items.len();
1697 let results: Vec<crate::ops::BatchEntry> = items
1698 .into_iter()
1699 .map(|(id, item)| crate::ops::BatchEntry {
1700 id,
1701 action: match item {
1702 Item::Prepared => "updated".to_string(),
1703 Item::Noop => "noop".to_string(),
1704 Item::Error => unreachable!("refusal path returned above"),
1705 },
1706 error: None,
1707 })
1708 .collect();
1709
1710 Ok(crate::ops::BatchResult {
1711 warnings: batch_warnings,
1712 orphan_stubs_removed: Vec::new(),
1713 errors_suppressed: 0,
1714 applied: true,
1715 results,
1716 succeeded,
1717 failed: 0,
1718 write_id,
1719 })
1720 }
1721
1722 pub(super) fn discard_all_pending(&self) {
1727 for mount in &self.mounts {
1728 let _ = mount.backend.discard_pending();
1729 }
1730 }
1731
1732 pub fn update_entity_with_ctx(
1735 &mut self,
1736 args: UpdateEntityArgs,
1737 ctx: &CommitContext<'_>,
1738 ) -> Result<UpdateEntityOutcome, EngineError> {
1739 self.update_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1740 }
1741}
1742
1743pub(super) fn batch_error_envelope(err: &EngineError) -> crate::ops::BatchError {
1750 let code = err.code().to_string();
1756 let message = err.to_string();
1757 let details = err.details();
1758 crate::ops::BatchError {
1759 code,
1760 message,
1761 details,
1762 }
1763}
1764
1765fn apply_declare_relations(
1780 engine: &mut Engine,
1781 next: &mut Entity,
1782 declarations: &[crate::ops::RelateArg],
1783 source_mem: &str,
1784 source_mount_idx: usize,
1785 type_def: &memstead_schema::TypeDefinition,
1786 schema: &memstead_schema::Schema,
1787) -> Result<Vec<RelationDeclared>, EngineError> {
1788 let _ = type_def; let _ = source_mount_idx; let mut declared: Vec<RelationDeclared> = Vec::with_capacity(declarations.len());
1791 for rel in declarations {
1792 let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
1795 .unwrap_or_else(|_| rel.rel_type.clone());
1796
1797 validate_relation_target_grammar(&rel.target)?;
1798
1799 let target_mem = rel.target.mem().to_string();
1800 super::validate_cross_mem_add_policy(engine, source_mem, &rel.target)?;
1803
1804 let target_type = engine
1813 .store
1814 .get(&rel.target)
1815 .map(|e| e.entity_type.clone())
1816 .filter(|t| !t.is_empty());
1817 let target_type = match target_type {
1820 Some(t) => Some(t),
1821 None => super::peek_deferred_target_type(engine, &rel.target)?,
1822 };
1823 let _ = super::route_edge_validation(
1824 engine,
1825 &canonical,
1826 next.entity_type.as_str(),
1827 target_type.as_deref(),
1828 source_mem,
1829 &target_mem,
1830 &next.id,
1831 &rel.target,
1832 true,
1833 )?;
1834
1835 let normalised_description =
1840 crate::entity::normalise_description(rel.description.as_deref());
1841 super::validate_description_posture(
1842 engine,
1843 &canonical,
1844 normalised_description.as_deref(),
1845 source_mem,
1846 &target_mem,
1847 &next.id,
1848 &rel.target,
1849 )?;
1850 super::validate_manual_authoring_posture(
1853 engine,
1854 &canonical,
1855 source_mem,
1856 &next.id,
1857 &rel.target,
1858 )?;
1859
1860 super::validate_edge_acyclicity(
1864 &engine.store,
1865 schema,
1866 &next.id,
1867 next.entity_type.as_str(),
1868 &rel.target,
1869 &canonical,
1870 )?;
1871
1872 let exists = next
1877 .relationships
1878 .iter()
1879 .any(|r| r.rel_type == canonical && r.target == rel.target);
1880 if !exists {
1881 next.relationships.push(Relationship {
1882 rel_type: canonical.clone(),
1883 target: rel.target.clone(),
1884 description: normalised_description,
1885 });
1886 }
1887
1888 let target_was_stubbed = !engine.store.contains(&rel.target);
1893 if target_was_stubbed && !exists {
1894 let kind = super::deferred_verified_stub_kind(engine, &rel.target)?;
1895 engine
1896 .store
1897 .upsert(rel.target.clone(), make_stub(&rel.target, kind));
1898 }
1899
1900 declared.push(RelationDeclared {
1901 rel_type: canonical,
1902 target: rel.target.clone(),
1903 target_was_stubbed,
1904 });
1905 }
1906 Ok(declared)
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911
1912 use indexmap::IndexMap;
1913 use tempfile::TempDir;
1914
1915 use crate::backend::MemBackend;
1916 use crate::engine::test_helpers::*;
1917 use crate::engine::{
1918 CreateEntityArgs, Engine, EngineError, RelateEntityArgs, UpdateEntityArgs,
1919 };
1920 use crate::entity::EntityId;
1921
1922 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1923 use crate::vcs::Actor;
1924
1925 #[test]
1933 fn short_id_resolves_when_unique_and_refuses_with_candidates_otherwise() {
1934 let tmp_a = TempDir::new().unwrap();
1935 let tmp_b = TempDir::new().unwrap();
1936 let mut engine = Engine::from_mounts(vec![
1937 (
1938 folder_mount("a", tmp_a.path().to_path_buf()),
1939 Box::new(FilesystemMemWriter::new(tmp_a.path().to_path_buf()))
1940 as Box<dyn MemBackend>,
1941 ),
1942 (
1943 folder_mount("b", tmp_b.path().to_path_buf()),
1944 Box::new(FilesystemMemWriter::new(tmp_b.path().to_path_buf()))
1945 as Box<dyn MemBackend>,
1946 ),
1947 ])
1948 .unwrap();
1949 for (mem, title) in [("a", "Foo"), ("b", "Foo"), ("a", "Only")] {
1950 engine
1951 .create_entity(empty_create_args(mem, title), Actor::Cli, None, None)
1952 .unwrap();
1953 }
1954 let update_args = |id: &str, tag: &str| UpdateEntityArgs {
1955 anchors: Vec::new(),
1956 id: EntityId(id.into()),
1957 expected_hash: None,
1958 sections: IndexMap::new(),
1959 append_sections: IndexMap::new(),
1960 patch_sections: IndexMap::new(),
1961 sections_unset: Vec::new(),
1962 metadata: IndexMap::from_iter([("tags".to_string(), tag.to_string())]),
1963 metadata_unset: Vec::new(),
1964 declare_relations: Vec::new(),
1965 dry_run: false,
1966 relations_unset: Vec::new(),
1967 anchors_unset: Vec::new(),
1968 };
1969
1970 let out = engine
1972 .update_entity(update_args("only", "x"), Actor::Cli, None, None)
1973 .expect("unique short id resolves");
1974 assert_eq!(out.id.0, "a--only");
1975 assert_eq!(
1976 out.warnings.first().map(|w| w.code()),
1977 Some("SHORT_ID_RESOLVED"),
1978 "{:?}",
1979 out.warnings
1980 );
1981
1982 let err = engine
1984 .update_entity(update_args("foo", "x"), Actor::Cli, None, None)
1985 .unwrap_err();
1986 assert_eq!(err.code(), "ENTITY_ID_MISSING_MEM");
1987 assert_eq!(
1988 err.details()["candidates"],
1989 serde_json::json!(["a--foo", "b--foo"])
1990 );
1991
1992 let err = engine
1994 .update_entity(update_args("nothing", "x"), Actor::Cli, None, None)
1995 .unwrap_err();
1996 assert_eq!(err.code(), "ENTITY_ID_MISSING_MEM");
1997 assert_eq!(err.details()["candidates"], serde_json::json!([]));
1998
1999 let err = engine
2001 .delete_entity(
2002 crate::engine::DeleteEntityArgs {
2003 id: EntityId("foo".into()),
2004 expected_hash: None,
2005 },
2006 Actor::Cli,
2007 None,
2008 None,
2009 )
2010 .unwrap_err();
2011 assert_eq!(err.code(), "ENTITY_ID_MISSING_MEM");
2012
2013 let out = engine
2015 .update_entity(update_args("a--only", "y"), Actor::Cli, None, None)
2016 .unwrap();
2017 assert!(
2018 out.warnings.iter().all(|w| w.code() != "SHORT_ID_RESOLVED"),
2019 "{:?}",
2020 out.warnings
2021 );
2022 }
2023
2024 #[test]
2030 fn update_warns_on_section_heading_divergence_and_still_commits() {
2031 let tmp = TempDir::new().unwrap();
2032 let mem_dir = tmp.path().to_path_buf();
2033 std::fs::write(
2036 mem_dir.join("diverged.md"),
2037 "---\ntype: spec\n---\n# Diverged\n\n## IDENTITY\n\nold text.\n",
2038 )
2039 .unwrap();
2040 let writer = FilesystemMemWriter::new(mem_dir.clone());
2041 let mut engine = Engine::from_mounts(vec![(
2042 folder_mount("specs", mem_dir),
2043 Box::new(writer) as Box<dyn MemBackend>,
2044 )])
2045 .unwrap();
2046 let (actor, client) = cli_actor();
2047 let id = EntityId::new("specs", "diverged");
2048
2049 let update_identity = |engine: &mut Engine, body: &str| {
2050 let current = engine.get_entity(&id).unwrap().content_hash.clone();
2051 let mut sections = IndexMap::new();
2052 sections.insert("identity".to_string(), body.to_string());
2053 engine
2054 .update_entity(
2055 UpdateEntityArgs {
2056 anchors: Vec::new(),
2057 id: id.clone(),
2058 expected_hash: Some(current),
2059 sections,
2060 append_sections: IndexMap::new(),
2061 patch_sections: IndexMap::new(),
2062 sections_unset: Vec::new(),
2063 metadata: IndexMap::new(),
2064 metadata_unset: Vec::new(),
2065 declare_relations: Vec::new(),
2066 dry_run: false,
2067 relations_unset: Vec::new(),
2068 anchors_unset: Vec::new(),
2069 },
2070 actor,
2071 Some(&client),
2072 None,
2073 )
2074 .unwrap()
2075 };
2076
2077 let outcome = update_identity(&mut engine, "new text.");
2078 assert!(!outcome.write_id.is_empty(), "the mutation still commits");
2079 let divergences: Vec<_> = outcome
2080 .warnings
2081 .iter()
2082 .filter_map(|w| match w {
2083 crate::ops::WarningHint::SectionHeadingDivergence {
2084 section_key,
2085 writing_heading,
2086 existing_heading,
2087 ..
2088 } => Some((
2089 section_key.clone(),
2090 writing_heading.clone(),
2091 existing_heading.clone(),
2092 )),
2093 _ => None,
2094 })
2095 .collect();
2096 assert_eq!(
2097 divergences,
2098 vec![(
2099 "identity".to_string(),
2100 "Identity".to_string(),
2101 "IDENTITY".to_string()
2102 )],
2103 "warning names both headings; all warnings = {:?}",
2104 outcome.warnings
2105 );
2106
2107 let outcome2 = update_identity(&mut engine, "third text.");
2110 assert!(
2111 !outcome2
2112 .warnings
2113 .iter()
2114 .any(|w| matches!(w, crate::ops::WarningHint::SectionHeadingDivergence { .. })),
2115 "matching heading emits no divergence warning: {:?}",
2116 outcome2.warnings
2117 );
2118 }
2119
2120 #[test]
2121 fn batch_update_empty_batch_returns_zero_counts() {
2122 let tmp = TempDir::new().unwrap();
2125 let mem_dir = tmp.path().to_path_buf();
2126 let writer = FilesystemMemWriter::new(mem_dir.clone());
2127 let mut engine = Engine::from_mounts(vec![(
2128 folder_mount("specs", mem_dir),
2129 Box::new(writer) as Box<dyn MemBackend>,
2130 )])
2131 .unwrap();
2132
2133 let result = engine
2134 .batch_update(Vec::new(), Actor::Cli, None, false)
2135 .unwrap();
2136 assert!(result.applied, "empty batch is a vacuous success");
2137 assert_eq!(result.results.len(), 0);
2138 assert_eq!(result.succeeded, 0);
2139 assert_eq!(result.failed, 0);
2140 assert_eq!(result.write_id, "");
2141 }
2142
2143 #[test]
2144 fn batch_update_refuses_whole_batch_when_one_item_fails() {
2145 let tmp = TempDir::new().unwrap();
2152 let mem_dir = tmp.path().to_path_buf();
2153 let writer = FilesystemMemWriter::new(mem_dir.clone());
2154 let mut engine = Engine::from_mounts(vec![(
2155 folder_mount("specs", mem_dir),
2156 Box::new(writer) as Box<dyn MemBackend>,
2157 )])
2158 .unwrap();
2159
2160 let create_args = CreateEntityArgs {
2162 anchors: Vec::new(),
2163 mem: "specs".to_string(),
2164 title: "Seed".to_string(),
2165 entity_type: "spec".to_string(),
2166 sections: IndexMap::from_iter([
2167 ("identity".to_string(), "seed identity".to_string()),
2168 ("purpose".to_string(), "seed purpose".to_string()),
2169 ]),
2170 metadata: IndexMap::new(),
2171 relations: Vec::new(),
2172 dry_run: false,
2173 };
2174 let created = engine
2175 .create_entity(create_args, Actor::Cli, None, None)
2176 .unwrap();
2177
2178 let valid_update = UpdateEntityArgs {
2180 anchors: Vec::new(),
2181 id: created.id.clone(),
2182 expected_hash: Some(created.content_hash.clone()),
2183 sections: IndexMap::from_iter([("identity".to_string(), "updated body".to_string())]),
2184 append_sections: IndexMap::new(),
2185 patch_sections: IndexMap::new(),
2186 sections_unset: Vec::new(),
2187 metadata: IndexMap::new(),
2188 metadata_unset: Vec::new(),
2189 declare_relations: Vec::new(),
2190 dry_run: false,
2191 relations_unset: Vec::new(),
2192 anchors_unset: Vec::new(),
2193 };
2194 let missing_update = UpdateEntityArgs {
2195 anchors: Vec::new(),
2196 id: EntityId("specs--nonexistent".to_string()),
2197 expected_hash: None,
2198 sections: IndexMap::new(),
2199 append_sections: IndexMap::new(),
2200 patch_sections: IndexMap::new(),
2201 sections_unset: Vec::new(),
2202 metadata: IndexMap::new(),
2203 metadata_unset: Vec::new(),
2204 declare_relations: Vec::new(),
2205 dry_run: false,
2206 relations_unset: Vec::new(),
2207 anchors_unset: Vec::new(),
2208 };
2209
2210 let result = engine
2211 .batch_update(
2212 vec![(valid_update, None), (missing_update, None)],
2213 Actor::Cli,
2214 None,
2215 false,
2216 )
2217 .unwrap();
2218 assert!(!result.applied, "a failing item must refuse the batch");
2220 assert_eq!(result.results.len(), 2);
2221 assert_eq!(result.succeeded, 0);
2222 assert_eq!(result.failed, 1);
2223 assert_eq!(result.write_id, "", "refused batch must not commit");
2224 assert_eq!(result.results[0].action, "not_applied");
2227 assert!(result.results[0].error.is_none());
2228 assert_eq!(result.results[1].action, "error");
2230 let err = result.results[1]
2231 .error
2232 .as_ref()
2233 .expect("failed entry must carry a structured error envelope");
2234 assert_eq!(err.code, "ENTITY_NOT_FOUND");
2235 assert!(err.message.contains("not found"), "got: {}", err.message);
2236
2237 let seed = engine.get_entity(&created.id).unwrap();
2240 assert_eq!(
2241 seed.sections.get("identity").map(String::as_str),
2242 Some("seed identity"),
2243 "refused batch must leave the in-memory store untouched",
2244 );
2245 assert_eq!(
2246 seed.content_hash, created.content_hash,
2247 "refused batch must not change the entity's content hash",
2248 );
2249 }
2250
2251 #[test]
2252 fn batch_update_applies_all_valid_items_as_one_commit() {
2253 let tmp = TempDir::new().unwrap();
2257 let mem_dir = tmp.path().to_path_buf();
2258 let writer = FilesystemMemWriter::new(mem_dir.clone());
2259 let mut engine = Engine::from_mounts(vec![(
2260 folder_mount("specs", mem_dir),
2261 Box::new(writer) as Box<dyn MemBackend>,
2262 )])
2263 .unwrap();
2264
2265 let mk = |title: &str| CreateEntityArgs {
2266 anchors: Vec::new(),
2267 mem: "specs".to_string(),
2268 title: title.to_string(),
2269 entity_type: "spec".to_string(),
2270 sections: IndexMap::from_iter([
2271 ("identity".to_string(), "id".to_string()),
2272 ("purpose".to_string(), "purp".to_string()),
2273 ]),
2274 metadata: IndexMap::new(),
2275 relations: Vec::new(),
2276 dry_run: false,
2277 };
2278 let a = engine
2279 .create_entity(mk("A"), Actor::Cli, None, None)
2280 .unwrap();
2281 let b = engine
2282 .create_entity(mk("B"), Actor::Cli, None, None)
2283 .unwrap();
2284
2285 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
2286 anchors: Vec::new(),
2287 id,
2288 expected_hash: Some(hash),
2289 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
2290 append_sections: IndexMap::new(),
2291 patch_sections: IndexMap::new(),
2292 sections_unset: Vec::new(),
2293 metadata: IndexMap::new(),
2294 metadata_unset: Vec::new(),
2295 declare_relations: Vec::new(),
2296 dry_run: false,
2297 relations_unset: Vec::new(),
2298 anchors_unset: Vec::new(),
2299 };
2300
2301 let result = engine
2302 .batch_update(
2303 vec![
2304 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
2305 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
2306 ],
2307 Actor::Cli,
2308 None,
2309 false,
2310 )
2311 .unwrap();
2312 assert!(result.applied);
2313 assert_eq!(result.succeeded, 2);
2314 assert_eq!(result.failed, 0);
2315 assert!(
2316 !result.write_id.is_empty(),
2317 "applied batch carries the commit"
2318 );
2319 assert!(result.results.iter().all(|e| e.action == "updated"));
2320 assert_eq!(
2322 engine
2323 .get_entity(&a.id)
2324 .unwrap()
2325 .sections
2326 .get("identity")
2327 .map(String::as_str),
2328 Some("A body"),
2329 );
2330 assert_eq!(
2331 engine
2332 .get_entity(&b.id)
2333 .unwrap()
2334 .sections
2335 .get("identity")
2336 .map(String::as_str),
2337 Some("B body"),
2338 );
2339 }
2340
2341 #[test]
2350 fn batch_update_dry_run_reports_receipt_and_writes_nothing() {
2351 let tmp = TempDir::new().unwrap();
2352 let mem_dir = tmp.path().to_path_buf();
2353 let writer = FilesystemMemWriter::new(mem_dir.clone());
2354 let mut engine = Engine::from_mounts(vec![(
2355 folder_mount("specs", mem_dir),
2356 Box::new(writer) as Box<dyn MemBackend>,
2357 )])
2358 .unwrap();
2359
2360 let mk = |title: &str| CreateEntityArgs {
2361 anchors: Vec::new(),
2362 mem: "specs".to_string(),
2363 title: title.to_string(),
2364 entity_type: "spec".to_string(),
2365 sections: IndexMap::from_iter([
2366 ("identity".to_string(), "id".to_string()),
2367 ("purpose".to_string(), "purp".to_string()),
2368 ]),
2369 metadata: IndexMap::new(),
2370 relations: Vec::new(),
2371 dry_run: false,
2372 };
2373 let a = engine
2374 .create_entity(mk("A"), Actor::Cli, None, None)
2375 .unwrap();
2376 let b = engine
2377 .create_entity(mk("B"), Actor::Cli, None, None)
2378 .unwrap();
2379
2380 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
2381 anchors: Vec::new(),
2382 id,
2383 expected_hash: Some(hash),
2384 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
2385 append_sections: IndexMap::new(),
2386 patch_sections: IndexMap::new(),
2387 sections_unset: Vec::new(),
2388 metadata: IndexMap::new(),
2389 metadata_unset: Vec::new(),
2390 declare_relations: Vec::new(),
2391 dry_run: false,
2392 relations_unset: Vec::new(),
2393 anchors_unset: Vec::new(),
2394 };
2395 let batch = || {
2396 vec![
2397 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
2398 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
2399 ]
2400 };
2401
2402 let rehearsed = engine
2403 .batch_update(batch(), Actor::Cli, None, true)
2404 .unwrap();
2405 assert!(rehearsed.applied, "{rehearsed:?}");
2406 assert_eq!(rehearsed.succeeded, 2);
2407 assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
2408 assert!(rehearsed.results.iter().all(|e| e.action == "updated"));
2409 let a_now = engine.get_entity(&a.id).unwrap();
2411 assert_eq!(
2412 a_now.sections.get("identity").map(String::as_str),
2413 Some("id")
2414 );
2415 assert_eq!(a_now.content_hash, a.content_hash);
2416
2417 let real = engine
2419 .batch_update(batch(), Actor::Cli, None, false)
2420 .unwrap();
2421 assert!(real.applied, "{real:?}");
2422 assert!(!real.write_id.is_empty());
2423 assert_eq!(
2424 engine
2425 .get_entity(&a.id)
2426 .unwrap()
2427 .sections
2428 .get("identity")
2429 .map(String::as_str),
2430 Some("A body"),
2431 );
2432 }
2433
2434 #[test]
2438 fn batch_update_dry_run_refuses_identically_to_real() {
2439 let tmp = TempDir::new().unwrap();
2440 let mem_dir = tmp.path().to_path_buf();
2441 let writer = FilesystemMemWriter::new(mem_dir.clone());
2442 let mut engine = Engine::from_mounts(vec![(
2443 folder_mount("specs", mem_dir),
2444 Box::new(writer) as Box<dyn MemBackend>,
2445 )])
2446 .unwrap();
2447 let created = engine
2448 .create_entity(
2449 CreateEntityArgs {
2450 anchors: Vec::new(),
2451 mem: "specs".to_string(),
2452 title: "Valid".to_string(),
2453 entity_type: "spec".to_string(),
2454 sections: IndexMap::from_iter([
2455 ("identity".to_string(), "x".to_string()),
2456 ("purpose".to_string(), "p".to_string()),
2457 ]),
2458 metadata: IndexMap::new(),
2459 relations: Vec::new(),
2460 dry_run: false,
2461 },
2462 Actor::Cli,
2463 None,
2464 None,
2465 )
2466 .unwrap();
2467 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2468 anchors: Vec::new(),
2469 id,
2470 expected_hash: hash,
2471 sections: IndexMap::from_iter([("identity".to_string(), "new".to_string())]),
2472 append_sections: IndexMap::new(),
2473 patch_sections: IndexMap::new(),
2474 sections_unset: Vec::new(),
2475 metadata: IndexMap::new(),
2476 metadata_unset: Vec::new(),
2477 declare_relations: Vec::new(),
2478 dry_run: false,
2479 relations_unset: Vec::new(),
2480 anchors_unset: Vec::new(),
2481 };
2482 let batch = || {
2483 vec![
2484 (
2485 upd(created.id.clone(), Some("wrong-hash".to_string())),
2486 None,
2487 ),
2488 (upd(EntityId("specs--missing".to_string()), None), None),
2489 ]
2490 };
2491
2492 let rehearsed = engine
2493 .batch_update(batch(), Actor::Cli, None, true)
2494 .unwrap();
2495 let real = engine
2496 .batch_update(batch(), Actor::Cli, None, false)
2497 .unwrap();
2498 assert!(!rehearsed.applied && !real.applied);
2499 let envelope = |r: &crate::ops::BatchResult| {
2500 r.results
2501 .iter()
2502 .map(|e| {
2503 (
2504 e.id.to_string(),
2505 e.action.clone(),
2506 e.error.as_ref().map(|err| {
2507 (err.code.clone(), err.message.clone(), err.details.clone())
2508 }),
2509 )
2510 })
2511 .collect::<Vec<_>>()
2512 };
2513 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
2514 assert_eq!(
2516 engine
2517 .get_entity(&created.id)
2518 .unwrap()
2519 .sections
2520 .get("identity")
2521 .map(String::as_str),
2522 Some("x"),
2523 );
2524 }
2525
2526 #[test]
2530 fn batch_update_reports_every_failing_item() {
2531 let tmp = TempDir::new().unwrap();
2532 let mem_dir = tmp.path().to_path_buf();
2533 let writer = FilesystemMemWriter::new(mem_dir.clone());
2534 let mut engine = Engine::from_mounts(vec![(
2535 folder_mount("specs", mem_dir),
2536 Box::new(writer) as Box<dyn MemBackend>,
2537 )])
2538 .unwrap();
2539 let created = engine
2540 .create_entity(
2541 CreateEntityArgs {
2542 anchors: Vec::new(),
2543 mem: "specs".to_string(),
2544 title: "Seed".to_string(),
2545 entity_type: "spec".to_string(),
2546 sections: IndexMap::from_iter([
2547 ("identity".to_string(), "seed identity".to_string()),
2548 ("purpose".to_string(), "seed purpose".to_string()),
2549 ]),
2550 metadata: IndexMap::new(),
2551 relations: Vec::new(),
2552 dry_run: false,
2553 },
2554 Actor::Cli,
2555 None,
2556 None,
2557 )
2558 .unwrap();
2559
2560 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2561 anchors: Vec::new(),
2562 id,
2563 expected_hash: hash,
2564 sections: IndexMap::from_iter([("identity".to_string(), "new body".to_string())]),
2565 append_sections: IndexMap::new(),
2566 patch_sections: IndexMap::new(),
2567 sections_unset: Vec::new(),
2568 metadata: IndexMap::new(),
2569 metadata_unset: Vec::new(),
2570 declare_relations: Vec::new(),
2571 dry_run: false,
2572 relations_unset: Vec::new(),
2573 anchors_unset: Vec::new(),
2574 };
2575 let result = engine
2576 .batch_update(
2577 vec![
2578 (upd(created.id.clone(), None), None),
2579 (upd(EntityId("specs--missing-one".to_string()), None), None),
2580 (upd(EntityId("specs--missing-two".to_string()), None), None),
2581 ],
2582 Actor::Cli,
2583 None,
2584 false,
2585 )
2586 .unwrap();
2587 assert!(!result.applied);
2588 assert_eq!(result.failed, 2, "{result:?}");
2589 assert_eq!(result.write_id, "");
2590 let codes: Vec<(usize, &str)> = result
2591 .results
2592 .iter()
2593 .enumerate()
2594 .filter(|(_, r)| r.action == "error")
2595 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
2596 .collect();
2597 assert_eq!(
2598 codes,
2599 vec![(1, "ENTITY_NOT_FOUND"), (2, "ENTITY_NOT_FOUND")],
2600 "BOTH failing items named, not just the first: {result:?}"
2601 );
2602 assert_eq!(result.results[0].action, "not_applied");
2603 assert_eq!(
2605 engine
2606 .get_entity(&created.id)
2607 .unwrap()
2608 .sections
2609 .get("identity")
2610 .map(String::as_str),
2611 Some("seed identity"),
2612 );
2613 }
2614
2615 #[test]
2616 fn batch_update_rolls_back_in_memory_store_auto_stub_on_refusal() {
2617 let tmp = TempDir::new().unwrap();
2625 let mem_dir = tmp.path().to_path_buf();
2626 let writer = FilesystemMemWriter::new(mem_dir.clone());
2627 let mut engine = Engine::from_mounts(vec![(
2628 folder_mount("specs", mem_dir.clone()),
2629 Box::new(writer) as Box<dyn MemBackend>,
2630 )])
2631 .unwrap();
2632 engine.set_workspace_root(mem_dir);
2633 let (actor, client) = cli_actor();
2634
2635 let a = engine
2636 .create_entity(
2637 empty_create_args("specs", "Anchor"),
2638 actor,
2639 Some(&client),
2640 None,
2641 )
2642 .unwrap();
2643
2644 let stub_target = EntityId::new("specs", "would-be-stub");
2645 let item1 = UpdateEntityArgs {
2646 anchors: Vec::new(),
2647 relations_unset: Vec::new(),
2648 anchors_unset: Vec::new(),
2649 id: a.id.clone(),
2650 expected_hash: Some(a.content_hash.clone()),
2651 sections: IndexMap::new(),
2652 append_sections: IndexMap::new(),
2653 patch_sections: IndexMap::new(),
2654 sections_unset: Vec::new(),
2655 metadata: IndexMap::new(),
2656 metadata_unset: Vec::new(),
2657 declare_relations: vec![crate::ops::RelateArg {
2658 rel_type: "USES".to_string(),
2659 target: stub_target.clone(),
2660 description: None,
2661 }],
2662 dry_run: false,
2663 };
2664 let item2 = UpdateEntityArgs {
2665 anchors: Vec::new(),
2666 id: EntityId::new("specs", "nonexistent"),
2667 expected_hash: None,
2668 sections: IndexMap::from_iter([("identity".to_string(), "x".to_string())]),
2669 append_sections: IndexMap::new(),
2670 patch_sections: IndexMap::new(),
2671 sections_unset: Vec::new(),
2672 metadata: IndexMap::new(),
2673 metadata_unset: Vec::new(),
2674 declare_relations: Vec::new(),
2675 dry_run: false,
2676 relations_unset: Vec::new(),
2677 anchors_unset: Vec::new(),
2678 };
2679
2680 assert!(engine.get_entity(&stub_target).is_none());
2682
2683 let result = engine
2684 .batch_update(
2685 vec![(item1, None), (item2, None)],
2686 actor,
2687 Some(&client),
2688 false,
2689 )
2690 .unwrap();
2691 assert!(!result.applied, "missing item 2 must refuse the batch");
2692
2693 assert!(
2696 engine.get_entity(&stub_target).is_none(),
2697 "refused batch must roll the in-memory auto-stub back out of the store",
2698 );
2699 let anchor = engine.get_entity(&a.id).unwrap();
2701 assert!(
2702 !anchor.relationships.iter().any(|r| r.target == stub_target),
2703 "refused batch must not leave the declared relation on the anchor",
2704 );
2705 }
2706
2707 #[test]
2708 fn update_entity_replaces_a_section_and_logs_provenance() {
2709 let tmp = TempDir::new().unwrap();
2710 let (mut engine, seeded) = engine_with_seed(&tmp, "Updatable");
2711 let (actor, client) = cli_actor();
2712
2713 let mut sections = IndexMap::new();
2714 sections.insert("identity".to_string(), "Updated body.".to_string());
2715
2716 let outcome = 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,
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 Some("section update"),
2736 )
2737 .unwrap();
2738
2739 assert_eq!(
2740 outcome.modified_sections.replaced,
2741 vec!["identity".to_string()]
2742 );
2743 assert_ne!(
2744 outcome.content_hash, seeded.content_hash,
2745 "hash must change"
2746 );
2747 let entity = engine.get_entity(&seeded.id).unwrap();
2749 assert!(
2750 entity
2751 .sections
2752 .get("identity")
2753 .unwrap()
2754 .contains("Updated body.")
2755 );
2756 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
2758 assert!(log.contains("\"kind\":\"update\""));
2759 assert!(log.contains("\"note\":\"section update\""));
2760 }
2761
2762 #[test]
2763 fn update_entity_rejects_hash_mismatch() {
2764 let tmp = TempDir::new().unwrap();
2765 let (mut engine, seeded) = engine_with_seed(&tmp, "Hash Guarded");
2766 let (actor, client) = cli_actor();
2767 let err = engine
2768 .update_entity(
2769 UpdateEntityArgs {
2770 anchors: Vec::new(),
2771 id: seeded.id.clone(),
2772 expected_hash: Some("wrong-hash".to_string()),
2773 sections: IndexMap::new(),
2774 append_sections: IndexMap::new(),
2775 patch_sections: IndexMap::new(),
2776 sections_unset: Vec::new(),
2777 metadata: IndexMap::new(),
2778 metadata_unset: Vec::new(),
2779 declare_relations: Vec::new(),
2780 dry_run: false,
2781 relations_unset: Vec::new(),
2782 anchors_unset: Vec::new(),
2783 },
2784 actor,
2785 Some(&client),
2786 None,
2787 )
2788 .unwrap_err();
2789 match err {
2790 EngineError::HashMismatch {
2791 id,
2792 current,
2793 is_stub,
2794 } => {
2795 assert_eq!(id, seeded.id.to_string());
2796 assert_eq!(current, seeded.content_hash);
2797 assert!(!is_stub, "real entity must not flag as stub");
2798 }
2799 other => panic!("expected HashMismatch, got {other:?}"),
2800 }
2801 }
2802
2803 #[test]
2804 fn update_entity_rejects_unknown_id() {
2805 let tmp = TempDir::new().unwrap();
2806 let (mut engine, _) = engine_with_seed(&tmp, "Anchor");
2807 let (actor, client) = cli_actor();
2808 let err = engine
2809 .update_entity(
2810 UpdateEntityArgs {
2811 anchors: Vec::new(),
2812 id: crate::EntityId::new("specs", "ghost"),
2813 expected_hash: None,
2814 sections: IndexMap::new(),
2815 append_sections: IndexMap::new(),
2816 patch_sections: IndexMap::new(),
2817 sections_unset: Vec::new(),
2818 metadata: IndexMap::new(),
2819 metadata_unset: Vec::new(),
2820 declare_relations: Vec::new(),
2821 dry_run: false,
2822 relations_unset: Vec::new(),
2823 anchors_unset: Vec::new(),
2824 },
2825 actor,
2826 Some(&client),
2827 None,
2828 )
2829 .unwrap_err();
2830 assert!(matches!(err, EngineError::NotFound { .. }));
2831 }
2832
2833 #[test]
2834 fn update_entity_rejects_read_only_mount() {
2835 let tmp = TempDir::new().unwrap();
2836 let archive_path = build_archive(
2837 tmp.path(),
2838 "ext",
2839 &[(
2840 "a.md",
2841 b"---\ntype: spec\n---\n# A\n\n## Identity\n\nbody.\n",
2842 )],
2843 );
2844 let mut engine = Engine::from_mounts(vec![(
2845 archive_mount("external", archive_path.clone()),
2846 Box::new(ArchiveBackend::new(archive_path)),
2847 )])
2848 .unwrap();
2849 let (actor, client) = cli_actor();
2850 let id = crate::EntityId::new("external", "a");
2851 let err = engine
2852 .update_entity(
2853 UpdateEntityArgs {
2854 anchors: Vec::new(),
2855 id,
2856 expected_hash: None,
2857 sections: IndexMap::new(),
2858 append_sections: IndexMap::new(),
2859 patch_sections: IndexMap::new(),
2860 sections_unset: Vec::new(),
2861 metadata: IndexMap::new(),
2862 metadata_unset: Vec::new(),
2863 declare_relations: Vec::new(),
2864 dry_run: false,
2865 relations_unset: Vec::new(),
2866 anchors_unset: Vec::new(),
2867 },
2868 actor,
2869 Some(&client),
2870 None,
2871 )
2872 .unwrap_err();
2873 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "external"));
2874 }
2875
2876 #[test]
2877 fn update_entity_patches_section_with_find_and_replace() {
2878 let tmp = TempDir::new().unwrap();
2879 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Subject");
2880 let (actor, client) = cli_actor();
2881
2882 let mut replace = IndexMap::new();
2885 replace.insert("identity".to_string(), "hello world hello".to_string());
2886 let replaced = engine
2887 .update_entity(
2888 UpdateEntityArgs {
2889 anchors: Vec::new(),
2890 id: seeded.id.clone(),
2891 expected_hash: Some(seeded.content_hash.clone()),
2892 sections: replace,
2893 append_sections: IndexMap::new(),
2894 patch_sections: IndexMap::new(),
2895 sections_unset: Vec::new(),
2896 metadata: IndexMap::new(),
2897 metadata_unset: Vec::new(),
2898 declare_relations: Vec::new(),
2899 dry_run: false,
2900 relations_unset: Vec::new(),
2901 anchors_unset: Vec::new(),
2902 },
2903 actor,
2904 Some(&client),
2905 None,
2906 )
2907 .unwrap();
2908
2909 let mut patches = IndexMap::new();
2911 patches.insert(
2912 "identity".to_string(),
2913 vec![crate::ops::PatchArg {
2914 old: "hello".to_string(),
2915 new: "HI".to_string(),
2916 all: false,
2917 }],
2918 );
2919 let outcome = engine
2920 .update_entity(
2921 UpdateEntityArgs {
2922 anchors: Vec::new(),
2923 id: seeded.id.clone(),
2924 expected_hash: Some(replaced.content_hash.clone()),
2925 sections: IndexMap::new(),
2926 append_sections: IndexMap::new(),
2927 patch_sections: patches,
2928 sections_unset: Vec::new(),
2929 metadata: IndexMap::new(),
2930 metadata_unset: Vec::new(),
2931 declare_relations: Vec::new(),
2932 dry_run: false,
2933 relations_unset: Vec::new(),
2934 anchors_unset: Vec::new(),
2935 },
2936 actor,
2937 Some(&client),
2938 None,
2939 )
2940 .unwrap();
2941 assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
2942 let body = engine
2943 .get_entity(&seeded.id)
2944 .unwrap()
2945 .sections
2946 .get("identity")
2947 .unwrap()
2948 .clone();
2949 assert!(body.contains("HI world hello"), "first-only: {body:?}");
2950 }
2951
2952 #[test]
2953 fn update_entity_patch_rejects_missing_old_substring() {
2954 let tmp = TempDir::new().unwrap();
2955 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Miss");
2956 let (actor, client) = cli_actor();
2957 let mut patches = IndexMap::new();
2958 patches.insert(
2959 "identity".to_string(),
2960 vec![crate::ops::PatchArg {
2961 old: "this-substring-does-not-exist".to_string(),
2962 new: "nope".to_string(),
2963 all: false,
2964 }],
2965 );
2966 let err = engine
2967 .update_entity(
2968 UpdateEntityArgs {
2969 anchors: Vec::new(),
2970 id: seeded.id.clone(),
2971 expected_hash: Some(seeded.content_hash.clone()),
2972 sections: IndexMap::new(),
2973 append_sections: IndexMap::new(),
2974 patch_sections: patches,
2975 sections_unset: Vec::new(),
2976 metadata: IndexMap::new(),
2977 metadata_unset: Vec::new(),
2978 declare_relations: Vec::new(),
2979 dry_run: false,
2980 relations_unset: Vec::new(),
2981 anchors_unset: Vec::new(),
2982 },
2983 actor,
2984 Some(&client),
2985 None,
2986 )
2987 .unwrap_err();
2988 match err {
2989 EngineError::PatchOldNotFound { section, .. } => {
2990 assert_eq!(section, "identity");
2991 }
2992 other => panic!("expected PatchOldNotFound, got {other:?}"),
2993 }
2994 }
2995
2996 fn unset_args(id: EntityId, hash: String, unset: &[&str]) -> UpdateEntityArgs {
2999 UpdateEntityArgs {
3000 anchors: Vec::new(),
3001 id,
3002 expected_hash: Some(hash),
3003 sections: IndexMap::new(),
3004 append_sections: IndexMap::new(),
3005 patch_sections: IndexMap::new(),
3006 sections_unset: unset.iter().map(|s| s.to_string()).collect(),
3007 metadata: IndexMap::new(),
3008 metadata_unset: Vec::new(),
3009 declare_relations: Vec::new(),
3010 dry_run: false,
3011 relations_unset: Vec::new(),
3012 anchors_unset: Vec::new(),
3013 }
3014 }
3015
3016 #[test]
3020 fn update_entity_sections_unset_removes_optional_section() {
3021 let tmp = TempDir::new().unwrap();
3022 let (mut engine, seeded) = engine_with_seed(&tmp, "Unset Subject");
3023 let (actor, client) = cli_actor();
3024 let mut sections = IndexMap::new();
3026 sections.insert("specifies".to_string(), "temporary content".to_string());
3027 let with_specifies = engine
3028 .update_entity(
3029 UpdateEntityArgs {
3030 sections,
3031 ..unset_args(seeded.id.clone(), seeded.content_hash.clone(), &[])
3032 },
3033 actor,
3034 Some(&client),
3035 None,
3036 )
3037 .unwrap();
3038
3039 let outcome = engine
3040 .update_entity(
3041 unset_args(
3042 seeded.id.clone(),
3043 with_specifies.content_hash.clone(),
3044 &["specifies", "not-present"],
3045 ),
3046 actor,
3047 Some(&client),
3048 None,
3049 )
3050 .unwrap();
3051 assert_eq!(outcome.modified_sections.unset, vec!["specifies"]);
3052 let entity = engine.store().get(&seeded.id).unwrap();
3053 assert!(
3054 !entity.sections.contains_key("specifies"),
3055 "section removed: {:?}",
3056 entity.sections.keys().collect::<Vec<_>>()
3057 );
3058 }
3059
3060 #[test]
3064 fn update_entity_sections_unset_refuses_required_section() {
3065 let tmp = TempDir::new().unwrap();
3066 let (mut engine, seeded) = engine_with_seed(&tmp, "Unset Required");
3067 let (actor, client) = cli_actor();
3068 let err = engine
3069 .update_entity(
3070 unset_args(
3071 seeded.id.clone(),
3072 seeded.content_hash.clone(),
3073 &["identity"],
3074 ),
3075 actor,
3076 Some(&client),
3077 None,
3078 )
3079 .unwrap_err();
3080 match err {
3081 EngineError::MissingRequiredSection {
3082 entity_type,
3083 sections,
3084 ..
3085 } => {
3086 assert_eq!(entity_type, "spec");
3087 assert_eq!(sections.len(), 1);
3088 assert_eq!(sections[0].key, "identity");
3089 }
3090 other => panic!("expected MissingRequiredSection, got {other:?}"),
3091 }
3092 }
3093
3094 #[test]
3098 fn update_entity_sections_unset_conflicts_and_relationships_refuse() {
3099 let tmp = TempDir::new().unwrap();
3100 let (mut engine, seeded) = engine_with_seed(&tmp, "Unset Conflict");
3101 let (actor, client) = cli_actor();
3102 let mut sections = IndexMap::new();
3103 sections.insert("specifies".to_string(), "body".to_string());
3104 let err = engine
3105 .update_entity(
3106 UpdateEntityArgs {
3107 sections,
3108 ..unset_args(
3109 seeded.id.clone(),
3110 seeded.content_hash.clone(),
3111 &["specifies"],
3112 )
3113 },
3114 actor,
3115 Some(&client),
3116 None,
3117 )
3118 .unwrap_err();
3119 match err {
3120 EngineError::ConflictingSectionModes { section, modes } => {
3121 assert_eq!(section, "specifies");
3122 assert!(modes.contains(&"sections_unset".to_string()), "{modes:?}");
3123 assert!(modes.contains(&"sections".to_string()), "{modes:?}");
3124 }
3125 other => panic!("expected ConflictingSectionModes, got {other:?}"),
3126 }
3127
3128 let err = engine
3129 .update_entity(
3130 unset_args(
3131 seeded.id.clone(),
3132 seeded.content_hash.clone(),
3133 &["relationships"],
3134 ),
3135 actor,
3136 Some(&client),
3137 None,
3138 )
3139 .unwrap_err();
3140 assert_eq!(err.code(), "SECTION_NOT_UPDATABLE", "{err:?}");
3141 }
3142
3143 #[test]
3148 fn update_entity_applies_multiple_patches_per_section_in_order() {
3149 let tmp = TempDir::new().unwrap();
3150 let (mut engine, seeded) = engine_with_seed(&tmp, "Multi Patch");
3151 let (actor, client) = cli_actor();
3152 let mut patches = IndexMap::new();
3153 patches.insert(
3154 "identity".to_string(),
3155 vec![
3156 crate::ops::PatchArg {
3157 old: "fixture".to_string(),
3158 new: "FIRST".to_string(),
3159 all: false,
3160 },
3161 crate::ops::PatchArg {
3164 old: "FIRST identity".to_string(),
3165 new: "SECOND".to_string(),
3166 all: false,
3167 },
3168 ],
3169 );
3170 let outcome = engine
3171 .update_entity(
3172 UpdateEntityArgs {
3173 patch_sections: patches,
3174 ..unset_args(seeded.id.clone(), seeded.content_hash.clone(), &[])
3175 },
3176 actor,
3177 Some(&client),
3178 None,
3179 )
3180 .unwrap();
3181 assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
3182 let entity = engine.store().get(&seeded.id).unwrap();
3183 assert_eq!(entity.sections["identity"], "SECOND body");
3184 }
3185
3186 #[test]
3191 fn update_entity_patch_names_the_sections_that_do_contain_old() {
3192 let tmp = TempDir::new().unwrap();
3193 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Wrong Section");
3194 let (actor, client) = cli_actor();
3195 let mut patches = IndexMap::new();
3196 patches.insert(
3197 "identity".to_string(),
3198 vec![crate::ops::PatchArg {
3199 old: "fixture purpose body".to_string(),
3200 new: "nope".to_string(),
3201 all: false,
3202 }],
3203 );
3204 let err = engine
3205 .update_entity(
3206 UpdateEntityArgs {
3207 anchors: Vec::new(),
3208 id: seeded.id.clone(),
3209 expected_hash: Some(seeded.content_hash.clone()),
3210 sections: IndexMap::new(),
3211 append_sections: IndexMap::new(),
3212 patch_sections: patches,
3213 sections_unset: Vec::new(),
3214 metadata: IndexMap::new(),
3215 metadata_unset: Vec::new(),
3216 declare_relations: Vec::new(),
3217 dry_run: false,
3218 relations_unset: Vec::new(),
3219 anchors_unset: Vec::new(),
3220 },
3221 actor,
3222 Some(&client),
3223 None,
3224 )
3225 .unwrap_err();
3226 match err {
3227 EngineError::PatchOldNotFound {
3228 section,
3229 found_in_sections,
3230 ..
3231 } => {
3232 assert_eq!(section, "identity");
3233 assert_eq!(found_in_sections, vec!["purpose".to_string()]);
3234 }
3235 other => panic!("expected PatchOldNotFound, got {other:?}"),
3236 }
3237 }
3238
3239 #[test]
3240 fn update_entity_appends_to_existing_section_with_newline_separator() {
3241 let tmp = TempDir::new().unwrap();
3242 let (mut engine, seeded) = engine_with_seed(&tmp, "Append Subject");
3243 let (actor, client) = cli_actor();
3244
3245 let mut appends = IndexMap::new();
3246 appends.insert("identity".to_string(), "appended tail.".to_string());
3247
3248 let outcome = engine
3249 .update_entity(
3250 UpdateEntityArgs {
3251 anchors: Vec::new(),
3252 id: seeded.id.clone(),
3253 expected_hash: Some(seeded.content_hash.clone()),
3254 sections: IndexMap::new(),
3255 append_sections: appends,
3256 patch_sections: IndexMap::new(),
3257 sections_unset: Vec::new(),
3258 metadata: IndexMap::new(),
3259 metadata_unset: Vec::new(),
3260 declare_relations: Vec::new(),
3261 dry_run: false,
3262 relations_unset: Vec::new(),
3263 anchors_unset: Vec::new(),
3264 },
3265 actor,
3266 Some(&client),
3267 None,
3268 )
3269 .unwrap();
3270
3271 assert_eq!(outcome.modified_sections.appended, vec!["identity"]);
3274 assert!(outcome.modified_sections.replaced.is_empty());
3275
3276 let updated = engine.get_entity(&seeded.id).unwrap();
3278 let body = updated.sections.get("identity").expect("identity section");
3279 assert!(
3280 body.contains("appended tail."),
3281 "appended body missing: {body:?}"
3282 );
3283 }
3284
3285 fn engine_with_open_fence(tmp: &TempDir) -> (Engine, crate::EntityId) {
3289 let (_engine, seeded) = engine_with_seed(tmp, "Fenced");
3290 let id = seeded.id.clone();
3291 let path = tmp.path().join(&seeded.file_path);
3292 let raw = std::fs::read_to_string(&path).expect("seeded file");
3293 let doctored = raw.replace("fixture identity body", "intro\n\n```rust\nfn main() {}");
3296 assert_ne!(doctored, raw, "the seeded body must be there to doctor");
3297 std::fs::write(&path, doctored).unwrap();
3298 let mem_dir = tmp.path().to_path_buf();
3299 let writer = FilesystemMemWriter::new(mem_dir.clone());
3300 let engine = Engine::from_mounts(vec![(
3301 folder_mount("specs", mem_dir),
3302 Box::new(writer) as Box<dyn MemBackend>,
3303 )])
3304 .unwrap();
3305 drop(seeded);
3306 (engine, id)
3307 }
3308
3309 #[test]
3310 fn a_write_that_does_not_resolve_an_open_fence_is_refused() {
3311 let tmp = TempDir::new().unwrap();
3312 let (mut engine, id) = engine_with_open_fence(&tmp);
3313 let (actor, client) = cli_actor();
3314 let stored = engine.get_entity(&id).expect("entity loads");
3320 assert!(
3324 stored
3325 .sections
3326 .get("purpose")
3327 .is_none_or(|v| v.trim().is_empty()),
3328 "purpose should read as absent or empty: {:?}",
3329 stored.sections.get("purpose")
3330 );
3331 assert!(
3332 stored.sections["identity"].contains("## Purpose"),
3333 "its content is inside identity: {:?}",
3334 stored.sections.get("identity")
3335 );
3336 let hash = stored.content_hash.clone();
3337
3338 let err = engine
3339 .update_entity(
3340 UpdateEntityArgs {
3341 anchors: Vec::new(),
3342 id: id.clone(),
3343 expected_hash: Some(hash),
3344 sections: IndexMap::from_iter([(
3345 "purpose".to_string(),
3346 "a new purpose".to_string(),
3347 )]),
3348 append_sections: IndexMap::new(),
3349 patch_sections: IndexMap::new(),
3350 sections_unset: Vec::new(),
3351 metadata: IndexMap::new(),
3352 metadata_unset: Vec::new(),
3353 declare_relations: Vec::new(),
3354 dry_run: false,
3355 relations_unset: Vec::new(),
3356 anchors_unset: Vec::new(),
3357 },
3358 actor,
3359 Some(&client),
3360 None,
3361 )
3362 .unwrap_err();
3363 match err {
3364 EngineError::UnterminatedFenceInStoredBody {
3365 ref section,
3366 ref fence,
3367 ref swallowed,
3368 ..
3369 } => {
3370 assert_eq!(section, "identity");
3371 assert_eq!(fence, "```");
3372 assert_eq!(swallowed, &vec!["Purpose".to_string()]);
3377 }
3378 other => panic!("expected UnterminatedFenceInStoredBody, got {other:?}"),
3379 }
3380 assert_eq!(err.code(), "UNTERMINATED_FENCE_IN_STORED_BODY");
3381 }
3382
3383 #[test]
3384 fn replacing_the_absorbing_section_is_the_way_out() {
3385 let tmp = TempDir::new().unwrap();
3390 let (mut engine, id) = engine_with_open_fence(&tmp);
3391 let (actor, client) = cli_actor();
3392 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3393 let outcome = engine
3394 .update_entity(
3395 UpdateEntityArgs {
3396 anchors: Vec::new(),
3397 id: id.clone(),
3398 expected_hash: Some(hash),
3399 sections: IndexMap::from_iter([
3400 (
3401 "identity".to_string(),
3402 "intro\n\n```rust\nfn main() {}\n```".to_string(),
3403 ),
3404 ("purpose".to_string(), "the recovered purpose".to_string()),
3405 ]),
3406 append_sections: IndexMap::new(),
3407 patch_sections: IndexMap::new(),
3408 sections_unset: Vec::new(),
3409 metadata: IndexMap::new(),
3410 metadata_unset: Vec::new(),
3411 declare_relations: Vec::new(),
3412 dry_run: false,
3413 relations_unset: Vec::new(),
3414 anchors_unset: Vec::new(),
3415 },
3416 actor,
3417 Some(&client),
3418 None,
3419 )
3420 .expect("a corrected body for the absorbing section is admitted");
3421 assert!(
3422 outcome
3423 .modified_sections
3424 .replaced
3425 .contains(&"identity".to_string())
3426 );
3427 let fixed = engine.get_entity(&id).unwrap();
3428 assert_eq!(
3429 fixed.sections.get("purpose").map(String::as_str),
3430 Some("the recovered purpose"),
3431 "the swallowed section is a section again"
3432 );
3433 assert!(
3434 crate::markdown::closing_fence_if_unterminated(fixed.sections.get("identity").unwrap())
3435 .is_none()
3436 );
3437 }
3438
3439 #[test]
3445 fn every_verb_that_regenerates_the_file_is_gated_not_only_update() {
3446 let tmp = TempDir::new().unwrap();
3447 let (mut engine, id) = engine_with_open_fence(&tmp);
3448 let (actor, client) = cli_actor();
3449 let before =
3450 std::fs::read_to_string(tmp.path().join(&engine.get_entity(&id).unwrap().file_path))
3451 .unwrap();
3452 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3453
3454 let err = engine
3455 .relate_entity(
3456 RelateEntityArgs {
3457 source: id.clone(),
3458 expected_hash: Some(hash),
3459 rel_type: "USES".to_string(),
3460 target: crate::EntityId::new("specs", "some-target"),
3461 remove: false,
3462 description: None,
3463 dry_run: false,
3464 },
3465 actor,
3466 Some(&client),
3467 None,
3468 )
3469 .expect_err("relate must not be able to freeze the absorption");
3470 assert_eq!(err.code(), "UNTERMINATED_FENCE_IN_STORED_BODY");
3471
3472 let err = engine
3473 .rename_entity(
3474 crate::engine::RenameEntityArgs {
3475 id: id.clone(),
3476 new_title: "Renamed Fenced".to_string(),
3477 expected_hash: Some(engine.get_entity(&id).unwrap().content_hash.clone()),
3478 },
3479 actor,
3480 Some(&client),
3481 None,
3482 )
3483 .expect_err("rename must not be able to freeze the absorption either");
3484 assert_eq!(err.code(), "UNTERMINATED_FENCE_IN_STORED_BODY");
3485
3486 let after =
3488 std::fs::read_to_string(tmp.path().join(&engine.get_entity(&id).unwrap().file_path))
3489 .unwrap();
3490 assert_eq!(before, after, "a refused write must not touch the file");
3491 }
3492
3493 #[test]
3494 fn an_entity_with_no_open_fence_updates_exactly_as_before() {
3495 let tmp = TempDir::new().unwrap();
3498 let (mut engine, seeded) = engine_with_seed(&tmp, "Ordinary");
3499 let (actor, client) = cli_actor();
3500 engine
3501 .update_entity(
3502 UpdateEntityArgs {
3503 anchors: Vec::new(),
3504 id: seeded.id.clone(),
3505 expected_hash: Some(seeded.content_hash.clone()),
3506 sections: IndexMap::from_iter([(
3507 "purpose".to_string(),
3508 "a new purpose".to_string(),
3509 )]),
3510 append_sections: IndexMap::new(),
3511 patch_sections: IndexMap::new(),
3512 sections_unset: Vec::new(),
3513 metadata: IndexMap::new(),
3514 metadata_unset: Vec::new(),
3515 declare_relations: Vec::new(),
3516 dry_run: false,
3517 relations_unset: Vec::new(),
3518 anchors_unset: Vec::new(),
3519 },
3520 actor,
3521 Some(&client),
3522 None,
3523 )
3524 .expect("an ordinary update is untouched by the fence gate");
3525 }
3526
3527 #[test]
3534 fn update_entity_against_stub_surfaces_typed_stub_not_updatable() {
3535 let tmp = TempDir::new().unwrap();
3536 let (mut engine, source) = engine_with_seed(&tmp, "Source");
3537 let (actor, client) = cli_actor();
3538 let stub_id = crate::EntityId::new("specs", "stub-update-target");
3541 engine
3542 .relate_entity(
3543 RelateEntityArgs {
3544 source: source.id.clone(),
3545 expected_hash: Some(source.content_hash.clone()),
3546 rel_type: "USES".to_string(),
3547 target: stub_id.clone(),
3548 remove: false,
3549 description: None,
3550 dry_run: false,
3551 },
3552 actor,
3553 Some(&client),
3554 None,
3555 )
3556 .unwrap();
3557
3558 let err = engine
3559 .update_entity(
3560 UpdateEntityArgs {
3561 anchors: Vec::new(),
3562 id: stub_id.clone(),
3563 expected_hash: Some(String::new()),
3564 sections: IndexMap::from_iter([("identity".to_string(), "body".to_string())]),
3565 append_sections: IndexMap::new(),
3566 patch_sections: IndexMap::new(),
3567 sections_unset: Vec::new(),
3568 metadata: IndexMap::new(),
3569 metadata_unset: Vec::new(),
3570 declare_relations: Vec::new(),
3571 dry_run: false,
3572 relations_unset: Vec::new(),
3573 anchors_unset: Vec::new(),
3574 },
3575 actor,
3576 Some(&client),
3577 None,
3578 )
3579 .unwrap_err();
3580 match err {
3581 EngineError::StubNotUpdatable { id } => assert_eq!(id, stub_id.to_string()),
3582 other => panic!("expected StubNotUpdatable, got {other:?}"),
3583 }
3584 }
3585
3586 #[test]
3587 fn update_entity_rejects_conflicting_section_modes() {
3588 let tmp = TempDir::new().unwrap();
3589 let (mut engine, seeded) = engine_with_seed(&tmp, "Conflict");
3590 let (actor, client) = cli_actor();
3591
3592 let mut sections = IndexMap::new();
3593 sections.insert("identity".to_string(), "replace".to_string());
3594 let mut appends = IndexMap::new();
3595 appends.insert("identity".to_string(), "append".to_string());
3596
3597 let err = engine
3598 .update_entity(
3599 UpdateEntityArgs {
3600 anchors: Vec::new(),
3601 id: seeded.id.clone(),
3602 expected_hash: Some(seeded.content_hash.clone()),
3603 sections,
3604 append_sections: appends,
3605 patch_sections: IndexMap::new(),
3606 sections_unset: Vec::new(),
3607 metadata: IndexMap::new(),
3608 metadata_unset: Vec::new(),
3609 declare_relations: Vec::new(),
3610 dry_run: false,
3611 relations_unset: Vec::new(),
3612 anchors_unset: Vec::new(),
3613 },
3614 actor,
3615 Some(&client),
3616 None,
3617 )
3618 .unwrap_err();
3619
3620 match err {
3621 EngineError::ConflictingSectionModes { section, modes } => {
3622 assert_eq!(section, "identity");
3623 assert_eq!(modes, vec!["sections", "append_sections"]);
3624 }
3625 other => panic!("expected ConflictingSectionModes, got {other:?}"),
3626 }
3627 }
3628
3629 #[test]
3630 fn update_entity_rejects_overlapping_metadata_and_metadata_unset_keys() {
3631 let tmp = TempDir::new().unwrap();
3636 let (mut engine, seeded) = engine_with_seed(&tmp, "Overlap Subject");
3637 let (actor, client) = cli_actor();
3638
3639 let mut metadata = IndexMap::new();
3640 metadata.insert("tags".to_string(), "foo".to_string());
3644
3645 let err = engine
3646 .update_entity(
3647 UpdateEntityArgs {
3648 anchors: Vec::new(),
3649 id: seeded.id.clone(),
3650 expected_hash: Some(seeded.content_hash.clone()),
3651 sections: IndexMap::new(),
3652 append_sections: IndexMap::new(),
3653 patch_sections: IndexMap::new(),
3654 sections_unset: Vec::new(),
3655 metadata,
3656 metadata_unset: vec!["tags".to_string()],
3657 declare_relations: Vec::new(),
3658 dry_run: false,
3659 relations_unset: Vec::new(),
3660 anchors_unset: Vec::new(),
3661 },
3662 actor,
3663 Some(&client),
3664 None,
3665 )
3666 .unwrap_err();
3667 match err {
3668 EngineError::SetAndUnsetConflict { keys } => {
3669 assert_eq!(keys, vec!["tags".to_string()]);
3670 }
3671 other => panic!("expected SetAndUnsetConflict, got {other:?}"),
3672 }
3673 }
3674
3675 #[test]
3676 fn update_entity_pointer_schema_auto_synthesises_references_from_body_link() {
3677 use crate::EntityId;
3685 use crate::engine::UpdateEntityArgs;
3686 use indexmap::IndexMap;
3687 use tempfile::TempDir;
3688
3689 let tmp = TempDir::new().unwrap();
3690 let mem_dir = tmp.path().to_path_buf();
3691 let writer = FilesystemMemWriter::new(mem_dir.clone());
3692 let mut engine = Engine::from_mounts(vec![(
3693 folder_mount("specs", mem_dir.clone()),
3694 Box::new(writer) as Box<dyn MemBackend>,
3695 )])
3696 .unwrap();
3697 engine.set_workspace_root(mem_dir.clone());
3698 let (actor, client) = cli_actor();
3699
3700 let target = engine
3701 .create_entity(
3702 empty_create_args("specs", "Target"),
3703 actor,
3704 Some(&client),
3705 None,
3706 )
3707 .unwrap();
3708 let source = engine
3709 .create_entity(
3710 empty_create_args("specs", "Source"),
3711 actor,
3712 Some(&client),
3713 None,
3714 )
3715 .unwrap();
3716
3717 let mut sections: IndexMap<String, String> = IndexMap::new();
3718 sections.insert(
3719 "purpose".to_string(),
3720 "see [[target]] for context".to_string(),
3721 );
3722 let outcome = engine
3723 .update_entity(
3724 UpdateEntityArgs {
3725 anchors: Vec::new(),
3726 id: source.id.clone(),
3727 expected_hash: Some(source.content_hash.clone()),
3728 sections,
3729 append_sections: IndexMap::new(),
3730 patch_sections: IndexMap::new(),
3731 sections_unset: Vec::new(),
3732 metadata: IndexMap::new(),
3733 metadata_unset: Vec::new(),
3734 declare_relations: Vec::new(),
3735 dry_run: false,
3736 relations_unset: Vec::new(),
3737 anchors_unset: Vec::new(),
3738 },
3739 actor,
3740 Some(&client),
3741 None,
3742 )
3743 .expect("auto-synthesis must satisfy the alias-existence invariant");
3744 assert!(
3746 outcome
3747 .modified_sections
3748 .replaced
3749 .iter()
3750 .any(|s| s == "purpose"),
3751 );
3752 let in_mem = engine.get_entity(&source.id).unwrap();
3753 assert_eq!(
3754 in_mem
3755 .sections
3756 .get("purpose")
3757 .map(String::as_str)
3758 .unwrap_or(""),
3759 "see [[target]] for context",
3760 );
3761 assert!(
3763 in_mem
3764 .relationships
3765 .iter()
3766 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3767 "synthesis must emit REFERENCES → target; relationships: {:?}",
3768 in_mem.relationships,
3769 );
3770 let _ = EntityId::new("specs", "x");
3772 }
3773
3774 #[test]
3775 fn update_entity_declare_relations_passes_strict_validator_in_one_call() {
3776 use crate::engine::UpdateEntityArgs;
3783 use crate::ops::RelateArg;
3784 use indexmap::IndexMap;
3785 use tempfile::TempDir;
3786
3787 let tmp = TempDir::new().unwrap();
3788 let mem_dir = tmp.path().to_path_buf();
3789 let writer = FilesystemMemWriter::new(mem_dir.clone());
3790 let mut engine = Engine::from_mounts(vec![(
3791 folder_mount("specs", mem_dir.clone()),
3792 Box::new(writer) as Box<dyn MemBackend>,
3793 )])
3794 .unwrap();
3795 engine.set_workspace_root(mem_dir.clone());
3796 let (actor, client) = cli_actor();
3797
3798 let target = engine
3799 .create_entity(
3800 empty_create_args("specs", "Target"),
3801 actor,
3802 Some(&client),
3803 None,
3804 )
3805 .unwrap();
3806 let source = engine
3807 .create_entity(
3808 empty_create_args("specs", "Source"),
3809 actor,
3810 Some(&client),
3811 None,
3812 )
3813 .unwrap();
3814
3815 let mut sections: IndexMap<String, String> = IndexMap::new();
3823 sections.insert(
3824 "purpose".to_string(),
3825 "see [[target]] for context".to_string(),
3826 );
3827 let outcome = engine
3828 .update_entity(
3829 UpdateEntityArgs {
3830 anchors: Vec::new(),
3831 relations_unset: Vec::new(),
3832 anchors_unset: Vec::new(),
3833 id: source.id.clone(),
3834 expected_hash: Some(source.content_hash.clone()),
3835 sections,
3836 append_sections: IndexMap::new(),
3837 patch_sections: IndexMap::new(),
3838 sections_unset: Vec::new(),
3839 metadata: IndexMap::new(),
3840 metadata_unset: Vec::new(),
3841 dry_run: false,
3842 declare_relations: vec![RelateArg {
3843 rel_type: "USES".to_string(),
3844 target: target.id.clone(),
3845 description: None,
3846 }],
3847 },
3848 actor,
3849 Some(&client),
3850 None,
3851 )
3852 .expect("declare_relations + body update must succeed in one call");
3853
3854 assert_eq!(outcome.relations_declared.len(), 1);
3855 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
3856 assert_eq!(outcome.relations_declared[0].target, target.id);
3857 assert!(
3858 !outcome.relations_declared[0].target_was_stubbed,
3859 "target was already present in store; target_was_stubbed must be false"
3860 );
3861
3862 let in_mem = engine.get_entity(&source.id).unwrap();
3863 assert!(
3864 in_mem.relationships.iter().any(|r| r.target == target.id),
3865 "declared relation must land in entity.relationships; got {:?}",
3866 in_mem.relationships
3867 );
3868 }
3869
3870 #[test]
3871 fn update_entity_declare_relations_auto_stubs_absent_target() {
3872 use crate::EntityId;
3876 use crate::engine::UpdateEntityArgs;
3877 use crate::ops::RelateArg;
3878 use indexmap::IndexMap;
3879
3880 let tmp = TempDir::new().unwrap();
3881 let (mut engine, source) = engine_with_seed(&tmp, "Source");
3882 let (actor, client) = cli_actor();
3883 let absent_target = EntityId::new("specs", "not-yet-existing");
3884 assert!(!engine.store().contains(&absent_target));
3885
3886 let outcome = engine
3887 .update_entity(
3888 UpdateEntityArgs {
3889 anchors: Vec::new(),
3890 relations_unset: Vec::new(),
3891 anchors_unset: Vec::new(),
3892 id: source.id.clone(),
3893 expected_hash: Some(source.content_hash.clone()),
3894 sections: IndexMap::new(),
3895 append_sections: IndexMap::new(),
3896 patch_sections: IndexMap::new(),
3897 sections_unset: Vec::new(),
3898 metadata: IndexMap::new(),
3899 metadata_unset: Vec::new(),
3900 dry_run: false,
3901 declare_relations: vec![RelateArg {
3902 rel_type: "USES".to_string(),
3903 target: absent_target.clone(),
3904 description: None,
3905 }],
3906 },
3907 actor,
3908 Some(&client),
3909 None,
3910 )
3911 .unwrap();
3912
3913 assert_eq!(outcome.relations_declared.len(), 1);
3914 assert!(
3915 outcome.relations_declared[0].target_was_stubbed,
3916 "absent target must be auto-stubbed; got target_was_stubbed=false"
3917 );
3918 assert!(engine.store().contains(&absent_target));
3920 let stub = engine.get_entity(&absent_target).unwrap();
3921 assert!(stub.stub);
3922 }
3923
3924 #[test]
3925 fn update_entity_alias_synthesis_runs_unconditionally_for_pointer_schemas() {
3926 use crate::engine::UpdateEntityArgs;
3932 use indexmap::IndexMap;
3933 use tempfile::TempDir;
3934
3935 let tmp = TempDir::new().unwrap();
3936 let mem_dir = tmp.path().to_path_buf();
3937 let writer = FilesystemMemWriter::new(mem_dir.clone());
3938 let mut engine = Engine::from_mounts(vec![(
3939 folder_mount("specs", mem_dir.clone()),
3940 Box::new(writer) as Box<dyn MemBackend>,
3941 )])
3942 .unwrap();
3943 engine.set_workspace_root(mem_dir.clone());
3944 let (actor, client) = cli_actor();
3945 let target = engine
3946 .create_entity(
3947 empty_create_args("specs", "Target"),
3948 actor,
3949 Some(&client),
3950 None,
3951 )
3952 .unwrap();
3953 let source = engine
3954 .create_entity(
3955 empty_create_args("specs", "Source"),
3956 actor,
3957 Some(&client),
3958 None,
3959 )
3960 .unwrap();
3961
3962 let mut sections: IndexMap<String, String> = IndexMap::new();
3963 sections.insert(
3964 "purpose".to_string(),
3965 "see [[target]] for context".to_string(),
3966 );
3967 engine
3968 .update_entity(
3969 UpdateEntityArgs {
3970 anchors: Vec::new(),
3971 id: source.id.clone(),
3972 expected_hash: Some(source.content_hash.clone()),
3973 sections,
3974 append_sections: IndexMap::new(),
3975 patch_sections: IndexMap::new(),
3976 sections_unset: Vec::new(),
3977 metadata: IndexMap::new(),
3978 metadata_unset: Vec::new(),
3979 declare_relations: Vec::new(),
3980 dry_run: false,
3981 relations_unset: Vec::new(),
3982 anchors_unset: Vec::new(),
3983 },
3984 actor,
3985 Some(&client),
3986 None,
3987 )
3988 .expect("synthesis must back the wiki-link and let the body land");
3989 let in_mem = engine.get_entity(&source.id).unwrap();
3990 assert!(
3991 in_mem
3992 .relationships
3993 .iter()
3994 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3995 "synthesis must emit REFERENCES → target; relationships: {:?}",
3996 in_mem.relationships,
3997 );
3998 }
3999
4000 #[test]
4001 fn update_entity_dry_run_returns_prospective_hash_without_writing() {
4002 let tmp = TempDir::new().unwrap();
4003 let (mut engine, seeded) = engine_with_seed(&tmp, "Preview Subject");
4004 let (actor, client) = cli_actor();
4005 let original_hash = seeded.content_hash.clone();
4006
4007 let mut sections = IndexMap::new();
4008 sections.insert("identity".to_string(), "preview body".to_string());
4009
4010 let outcome = engine
4011 .update_entity(
4012 UpdateEntityArgs {
4013 anchors: Vec::new(),
4014 id: seeded.id.clone(),
4015 expected_hash: Some("wrong-hash".to_string()),
4018 sections,
4019 append_sections: IndexMap::new(),
4020 patch_sections: IndexMap::new(),
4021 sections_unset: Vec::new(),
4022 metadata: IndexMap::new(),
4023 metadata_unset: Vec::new(),
4024 declare_relations: Vec::new(),
4025 dry_run: true,
4026 relations_unset: Vec::new(),
4027 anchors_unset: Vec::new(),
4028 },
4029 actor,
4030 Some(&client),
4031 None,
4032 )
4033 .unwrap();
4034
4035 assert_eq!(outcome.content_hash, original_hash);
4038 let prospective = outcome
4039 .prospective_hash
4040 .expect("prospective_hash populated on dry_run");
4041 assert_ne!(prospective, original_hash);
4042 assert!(outcome.write_id.is_empty());
4043 let store_entity = engine.get_entity(&seeded.id).unwrap();
4045 assert_eq!(store_entity.content_hash, original_hash);
4046 }
4047
4048 #[test]
4067 fn references_edges_round_trip_across_full_crud_cycle() {
4068 let tmp = TempDir::new().unwrap();
4069 let mem_dir = tmp.path().to_path_buf();
4070 let writer = FilesystemMemWriter::new(mem_dir.clone());
4071 let mut engine = Engine::from_mounts(vec![(
4072 folder_mount("specs", mem_dir),
4073 Box::new(writer) as Box<dyn MemBackend>,
4074 )])
4075 .unwrap();
4076 let (actor, client) = cli_actor();
4077
4078 let foo = engine
4082 .create_entity(
4083 empty_create_args("specs", "Foo"),
4084 actor,
4085 Some(&client),
4086 None,
4087 )
4088 .unwrap();
4089 let bar = engine
4090 .create_entity(
4091 empty_create_args("specs", "Bar"),
4092 actor,
4093 Some(&client),
4094 None,
4095 )
4096 .unwrap();
4097
4098 let count_references = |engine: &Engine| -> usize {
4099 engine
4100 .store()
4101 .all_ids()
4102 .flat_map(|id| engine.store().outgoing(id))
4103 .filter(|e| e.rel_type == "REFERENCES")
4104 .count()
4105 };
4106
4107 let baseline_edges = engine.store().edge_count();
4108 let baseline_refs = count_references(&engine);
4109
4110 let mut sections = IndexMap::new();
4116 sections.insert(
4117 "identity".to_string(),
4118 "See [[foo]] and [[bar]] inline.".to_string(),
4119 );
4120 sections.insert("purpose".to_string(), "probe purpose".to_string());
4121 let probe = engine
4122 .create_entity(
4123 CreateEntityArgs {
4124 anchors: Vec::new(),
4125 mem: "specs".to_string(),
4126 title: "Probe".to_string(),
4127 entity_type: "spec".to_string(),
4128 sections,
4129 metadata: IndexMap::new(),
4130 relations: Vec::new(),
4131 dry_run: false,
4132 },
4133 actor,
4134 Some(&client),
4135 None,
4136 )
4137 .unwrap();
4138 assert_eq!(count_references(&engine), baseline_refs + 2);
4139
4140 let relate1 = engine
4145 .relate_entity(
4146 RelateEntityArgs {
4147 source: probe.id.clone(),
4148 expected_hash: Some(probe.content_hash.clone()),
4149 rel_type: "INFORMED_BY".to_string(),
4150 target: foo.id.clone(),
4151 remove: false,
4152 description: None,
4153 dry_run: false,
4154 },
4155 actor,
4156 Some(&client),
4157 None,
4158 )
4159 .unwrap();
4160 assert_eq!(
4161 count_references(&engine),
4162 baseline_refs + 2,
4163 "set-membership aliasing — adding INFORMED_BY does not \
4164 absorb the REFERENCES relation"
4165 );
4166
4167 let mut sections = IndexMap::new();
4171 sections.insert("identity".to_string(), "See [[foo]] inline.".to_string());
4172 let updated = engine
4173 .update_entity(
4174 UpdateEntityArgs {
4175 anchors: Vec::new(),
4176 id: probe.id.clone(),
4177 expected_hash: Some(relate1.content_hash.clone()),
4178 sections,
4179 append_sections: IndexMap::new(),
4180 patch_sections: IndexMap::new(),
4181 sections_unset: Vec::new(),
4182 metadata: IndexMap::new(),
4183 metadata_unset: Vec::new(),
4184 declare_relations: Vec::new(),
4185 dry_run: false,
4186 relations_unset: Vec::new(),
4187 anchors_unset: Vec::new(),
4188 },
4189 actor,
4190 Some(&client),
4191 None,
4192 )
4193 .unwrap();
4194 assert_eq!(
4195 count_references(&engine),
4196 baseline_refs + 1,
4197 "REFERENCES → bar must be auto-GC'd when its body link drops"
4198 );
4199
4200 let renamed = engine
4202 .rename_entity(
4203 crate::engine::RenameEntityArgs {
4204 id: probe.id.clone(),
4205 expected_hash: Some(updated.content_hash.clone()),
4206 new_title: "Probe Renamed".to_string(),
4207 },
4208 actor,
4209 Some(&client),
4210 None,
4211 )
4212 .unwrap();
4213 assert_eq!(count_references(&engine), baseline_refs + 1);
4214
4215 engine
4218 .delete_entity(
4219 crate::engine::DeleteEntityArgs {
4220 id: renamed.new_id.clone(),
4221 expected_hash: Some(renamed.content_hash.clone()),
4222 },
4223 actor,
4224 Some(&client),
4225 None,
4226 )
4227 .unwrap();
4228
4229 assert_eq!(
4231 engine.store().edge_count(),
4232 baseline_edges,
4233 "total edges must round-trip to baseline"
4234 );
4235 assert_eq!(
4236 count_references(&engine),
4237 baseline_refs,
4238 "REFERENCES counter must round-trip to baseline"
4239 );
4240
4241 engine.reload_one_mem("specs").unwrap();
4245 assert_eq!(
4246 engine.store().edge_count(),
4247 baseline_edges,
4248 "total edges must match disk after reload"
4249 );
4250 assert_eq!(
4251 count_references(&engine),
4252 baseline_refs,
4253 "REFERENCES must match disk after reload"
4254 );
4255 assert!(engine.store().contains(&foo.id));
4257 assert!(engine.store().contains(&bar.id));
4258 }
4259
4260 #[test]
4261 fn update_entity_returns_write_id_title_modified_date_warnings_shape() {
4262 let tmp = TempDir::new().unwrap();
4263 let (mut engine, seeded) = engine_with_seed(&tmp, "Subject");
4264 let (actor, client) = cli_actor();
4265
4266 let mut sections = IndexMap::new();
4267 sections.insert("identity".to_string(), "edited body".to_string());
4268
4269 let outcome = engine
4270 .update_entity(
4271 UpdateEntityArgs {
4272 anchors: Vec::new(),
4273 id: seeded.id.clone(),
4274 expected_hash: Some(seeded.content_hash.clone()),
4275 sections,
4276 append_sections: IndexMap::new(),
4277 patch_sections: IndexMap::new(),
4278 sections_unset: Vec::new(),
4279 metadata: IndexMap::new(),
4280 metadata_unset: Vec::new(),
4281 declare_relations: Vec::new(),
4282 dry_run: false,
4283 relations_unset: Vec::new(),
4284 anchors_unset: Vec::new(),
4285 },
4286 actor,
4287 Some(&client),
4288 None,
4289 )
4290 .unwrap();
4291
4292 assert!(
4294 !outcome.write_id.is_empty(),
4295 "write_id must be populated on a real update"
4296 );
4297 assert_eq!(outcome.title, "Subject");
4299 assert!(
4304 !outcome.modified_date.is_empty(),
4305 "modified_date must be auto-stamped on update for the default spec schema",
4306 );
4307 assert!(outcome.warnings.is_empty());
4311 assert_eq!(
4313 outcome.modified_sections.replaced,
4314 vec!["identity".to_string()]
4315 );
4316 }
4317
4318 #[test]
4327 fn update_entity_noop_resetting_section_to_current_value_preserves_last_modified() {
4328 let tmp = TempDir::new().unwrap();
4329 let (mut engine, seeded) = engine_with_seed(&tmp, "Section Resetter");
4330 let (actor, client) = cli_actor();
4331
4332 let pre_last_modified = engine
4335 .get_entity(&seeded.id)
4336 .and_then(|e| e.metadata.get("last_modified"))
4337 .map(|v| v.to_frontmatter_string())
4338 .expect("seeded entity has last_modified");
4339
4340 let mut sections = IndexMap::new();
4344 sections.insert("identity".to_string(), "fixture identity body".to_string());
4345 let outcome = engine
4346 .update_entity(
4347 UpdateEntityArgs {
4348 anchors: Vec::new(),
4349 id: seeded.id.clone(),
4350 expected_hash: Some(seeded.content_hash.clone()),
4351 sections,
4352 append_sections: IndexMap::new(),
4353 patch_sections: IndexMap::new(),
4354 sections_unset: Vec::new(),
4355 metadata: IndexMap::new(),
4356 metadata_unset: Vec::new(),
4357 declare_relations: Vec::new(),
4358 dry_run: false,
4359 relations_unset: Vec::new(),
4360 anchors_unset: Vec::new(),
4361 },
4362 actor,
4363 Some(&client),
4364 None,
4365 )
4366 .unwrap();
4367
4368 assert_eq!(outcome.write_id, "", "no-op must not commit");
4369 assert_eq!(
4370 outcome.content_hash, seeded.content_hash,
4371 "no-op must not advance content_hash",
4372 );
4373 assert!(
4374 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4375 "UPDATE_NOOP must fire on bytes-identical re-set",
4376 );
4377 assert_eq!(
4378 outcome.modified_date, pre_last_modified,
4379 "no-op must preserve last_modified at the pre-call value",
4380 );
4381 assert!(
4386 outcome.modified_sections.replaced.is_empty()
4387 && outcome.modified_sections.appended.is_empty()
4388 && outcome.modified_sections.patched.is_empty(),
4389 "no-op must report an empty section delta, got {:?}",
4390 outcome.modified_sections,
4391 );
4392
4393 let post_last_modified = engine
4397 .get_entity(&seeded.id)
4398 .and_then(|e| e.metadata.get("last_modified"))
4399 .map(|v| v.to_frontmatter_string())
4400 .expect("entity still in store");
4401 assert_eq!(post_last_modified, pre_last_modified);
4402 }
4403
4404 #[test]
4416 fn update_entity_empty_payload_refuses_with_typed_code() {
4417 let tmp = TempDir::new().unwrap();
4418 let (mut engine, seeded) = engine_with_seed(&tmp, "Empty Payload");
4419 let (actor, client) = cli_actor();
4420
4421 let err = engine
4422 .update_entity(
4423 UpdateEntityArgs {
4424 anchors: Vec::new(),
4425 id: seeded.id.clone(),
4426 expected_hash: Some(seeded.content_hash.clone()),
4427 sections: IndexMap::new(),
4428 append_sections: IndexMap::new(),
4429 patch_sections: IndexMap::new(),
4430 sections_unset: Vec::new(),
4431 metadata: IndexMap::new(),
4432 metadata_unset: Vec::new(),
4433 declare_relations: Vec::new(),
4434 dry_run: false,
4435 relations_unset: Vec::new(),
4436 anchors_unset: Vec::new(),
4437 },
4438 actor,
4439 Some(&client),
4440 None,
4441 )
4442 .unwrap_err();
4443 match err {
4444 EngineError::EmptyUpdate { id } => {
4445 assert_eq!(id, seeded.id.to_string());
4446 }
4447 other => panic!("expected EMPTY_UPDATE, got {other:?}"),
4448 }
4449 let log_path = tmp.path().join(".memstead/changes.jsonl");
4451 if let Ok(log) = std::fs::read_to_string(&log_path) {
4452 let updates = log.matches("\"kind\":\"update\"").count();
4453 assert_eq!(updates, 0, "EMPTY_UPDATE refusal must not log an update");
4454 }
4455 }
4456
4457 #[test]
4463 fn update_entity_noop_same_content_surfaces_warning() {
4464 let tmp = TempDir::new().unwrap();
4465 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Content Noop");
4466 let (actor, client) = cli_actor();
4467
4468 let mut sections = IndexMap::new();
4470 sections.insert("identity".to_string(), "fixture identity body".to_string());
4471
4472 let outcome = engine
4473 .update_entity(
4474 UpdateEntityArgs {
4475 anchors: Vec::new(),
4476 id: seeded.id.clone(),
4477 expected_hash: Some(seeded.content_hash.clone()),
4478 sections,
4479 append_sections: IndexMap::new(),
4480 patch_sections: IndexMap::new(),
4481 sections_unset: Vec::new(),
4482 metadata: IndexMap::new(),
4483 metadata_unset: Vec::new(),
4484 declare_relations: Vec::new(),
4485 dry_run: false,
4486 relations_unset: Vec::new(),
4487 anchors_unset: Vec::new(),
4488 },
4489 actor,
4490 Some(&client),
4491 None,
4492 )
4493 .unwrap();
4494
4495 assert_eq!(outcome.write_id, "");
4496 assert_eq!(outcome.content_hash, seeded.content_hash);
4497 let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code()).collect();
4498 assert!(
4499 codes.contains(&"UPDATE_NOOP"),
4500 "same-content update must surface UPDATE_NOOP; got {codes:?}",
4501 );
4502 }
4503
4504 #[test]
4505 fn update_entity_noop_metadata_unset_on_absent_key() {
4506 let tmp = TempDir::new().unwrap();
4511 let (mut engine, seeded) = engine_with_seed(&tmp, "Absent Key Noop");
4512 let (actor, client) = cli_actor();
4513
4514 let outcome = engine
4515 .update_entity(
4516 UpdateEntityArgs {
4517 anchors: Vec::new(),
4518 id: seeded.id.clone(),
4519 expected_hash: Some(seeded.content_hash.clone()),
4520 sections: IndexMap::new(),
4521 append_sections: IndexMap::new(),
4522 patch_sections: IndexMap::new(),
4523 sections_unset: Vec::new(),
4524 metadata: IndexMap::new(),
4525 metadata_unset: vec!["tags".to_string()],
4529 declare_relations: Vec::new(),
4530 dry_run: false,
4531 relations_unset: Vec::new(),
4532 anchors_unset: Vec::new(),
4533 },
4534 actor,
4535 Some(&client),
4536 None,
4537 )
4538 .unwrap();
4539
4540 assert_eq!(outcome.write_id, "");
4541 assert_eq!(outcome.content_hash, seeded.content_hash);
4542 assert!(
4543 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4544 "absent-key metadata_unset must surface UPDATE_NOOP",
4545 );
4546 assert!(
4549 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
4550 "no-op must report an empty metadata delta, got {:?}",
4551 outcome.modified_metadata,
4552 );
4553
4554 let mut sections = IndexMap::new();
4557 sections.insert("identity".to_string(), "real change".to_string());
4558 let real = engine
4559 .update_entity(
4560 UpdateEntityArgs {
4561 anchors: Vec::new(),
4562 id: seeded.id.clone(),
4563 expected_hash: Some(seeded.content_hash.clone()),
4564 sections,
4565 append_sections: IndexMap::new(),
4566 patch_sections: IndexMap::new(),
4567 sections_unset: Vec::new(),
4568 metadata: IndexMap::new(),
4569 metadata_unset: Vec::new(),
4570 declare_relations: Vec::new(),
4571 dry_run: false,
4572 relations_unset: Vec::new(),
4573 anchors_unset: Vec::new(),
4574 },
4575 actor,
4576 Some(&client),
4577 None,
4578 )
4579 .unwrap();
4580 assert!(!real.write_id.is_empty());
4581 assert_ne!(real.content_hash, seeded.content_hash);
4582 }
4583
4584 #[test]
4591 fn update_entity_noop_setting_metadata_to_current_value_reports_empty_delta() {
4592 let tmp = TempDir::new().unwrap();
4593 let (mut engine, seeded) = engine_with_seed(&tmp, "Stability Resetter");
4594 let (actor, client) = cli_actor();
4595
4596 let mut metadata = IndexMap::new();
4599 metadata.insert("level".to_string(), "M0".to_string());
4600 let outcome = engine
4601 .update_entity(
4602 UpdateEntityArgs {
4603 anchors: Vec::new(),
4604 id: seeded.id.clone(),
4605 expected_hash: Some(seeded.content_hash.clone()),
4606 sections: IndexMap::new(),
4607 append_sections: IndexMap::new(),
4608 patch_sections: IndexMap::new(),
4609 sections_unset: Vec::new(),
4610 metadata,
4611 metadata_unset: Vec::new(),
4612 declare_relations: Vec::new(),
4613 dry_run: false,
4614 relations_unset: Vec::new(),
4615 anchors_unset: Vec::new(),
4616 },
4617 actor,
4618 Some(&client),
4619 None,
4620 )
4621 .unwrap();
4622
4623 assert_eq!(outcome.write_id, "", "no-op must not commit");
4624 assert_eq!(
4625 outcome.content_hash, seeded.content_hash,
4626 "no-op must not advance hash"
4627 );
4628 assert!(
4629 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4630 "re-set to current value must surface UPDATE_NOOP",
4631 );
4632 assert!(
4633 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
4634 "no-op must not claim `level` was set — applied delta is empty, got {:?}",
4635 outcome.modified_metadata,
4636 );
4637 }
4638
4639 #[test]
4640 fn update_entity_noop_declare_already_related_edge() {
4641 use crate::ops::RelateArg;
4646 let tmp = TempDir::new().unwrap();
4647 let mem_dir = tmp.path().to_path_buf();
4648 let writer = FilesystemMemWriter::new(mem_dir.clone());
4649 let mut engine = Engine::from_mounts(vec![(
4650 folder_mount("specs", mem_dir),
4651 Box::new(writer) as Box<dyn MemBackend>,
4652 )])
4653 .unwrap();
4654 let (actor, client) = cli_actor();
4655 let target = engine
4656 .create_entity(
4657 empty_create_args("specs", "Target Already Related"),
4658 actor,
4659 Some(&client),
4660 None,
4661 )
4662 .unwrap();
4663 let source = engine
4664 .create_entity(
4665 empty_create_args("specs", "Source Already Related"),
4666 actor,
4667 Some(&client),
4668 None,
4669 )
4670 .unwrap();
4671 let after_relate = engine
4672 .relate_entity(
4673 RelateEntityArgs {
4674 source: source.id.clone(),
4675 expected_hash: Some(source.content_hash.clone()),
4676 rel_type: "USES".to_string(),
4677 target: target.id.clone(),
4678 remove: false,
4679 description: None,
4680 dry_run: false,
4681 },
4682 actor,
4683 Some(&client),
4684 None,
4685 )
4686 .unwrap();
4687 let outcome = engine
4689 .update_entity(
4690 UpdateEntityArgs {
4691 anchors: Vec::new(),
4692 relations_unset: Vec::new(),
4693 anchors_unset: Vec::new(),
4694 id: source.id.clone(),
4695 expected_hash: Some(after_relate.content_hash.clone()),
4696 sections: IndexMap::new(),
4697 append_sections: IndexMap::new(),
4698 patch_sections: IndexMap::new(),
4699 sections_unset: Vec::new(),
4700 metadata: IndexMap::new(),
4701 metadata_unset: Vec::new(),
4702 declare_relations: vec![RelateArg {
4703 rel_type: "USES".to_string(),
4704 target: target.id.clone(),
4705 description: None,
4706 }],
4707 dry_run: false,
4708 },
4709 actor,
4710 Some(&client),
4711 None,
4712 )
4713 .unwrap();
4714
4715 assert_eq!(outcome.write_id, "");
4716 assert_eq!(outcome.content_hash, after_relate.content_hash);
4717 assert!(
4718 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4719 "duplicate declare must surface UPDATE_NOOP",
4720 );
4721 assert_eq!(outcome.relations_declared.len(), 1);
4724 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
4725 assert_eq!(outcome.relations_declared[0].target, target.id);
4726 assert!(!outcome.relations_declared[0].target_was_stubbed);
4727 }
4728
4729 #[test]
4730 fn update_entity_real_change_still_commits_and_advances_hash() {
4731 let tmp = TempDir::new().unwrap();
4736 let (mut engine, seeded) = engine_with_seed(&tmp, "Real Change Subject");
4737 let (actor, client) = cli_actor();
4738
4739 let mut sections = IndexMap::new();
4740 sections.insert("identity".to_string(), "definitely new body".to_string());
4741
4742 let outcome = engine
4743 .update_entity(
4744 UpdateEntityArgs {
4745 anchors: Vec::new(),
4746 id: seeded.id.clone(),
4747 expected_hash: Some(seeded.content_hash.clone()),
4748 sections,
4749 append_sections: IndexMap::new(),
4750 patch_sections: IndexMap::new(),
4751 sections_unset: Vec::new(),
4752 metadata: IndexMap::new(),
4753 metadata_unset: Vec::new(),
4754 declare_relations: Vec::new(),
4755 dry_run: false,
4756 relations_unset: Vec::new(),
4757 anchors_unset: Vec::new(),
4758 },
4759 actor,
4760 Some(&client),
4761 None,
4762 )
4763 .unwrap();
4764
4765 assert!(!outcome.write_id.is_empty(), "real change must commit");
4766 assert_ne!(
4767 outcome.content_hash, seeded.content_hash,
4768 "real change must advance content_hash",
4769 );
4770 assert!(
4771 !outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4772 "real change must not surface UPDATE_NOOP",
4773 );
4774 }
4775
4776 #[test]
4777 fn update_entity_noop_preserves_expected_hash_across_chain() {
4778 let tmp = TempDir::new().unwrap();
4783 let (mut engine, seeded) = engine_with_seed(&tmp, "Chained Noops Subject");
4784 let (actor, client) = cli_actor();
4785
4786 let mut noop_sections = IndexMap::new();
4791 noop_sections.insert("identity".to_string(), "fixture identity body".to_string());
4792 for _ in 0..2 {
4793 let outcome = engine
4794 .update_entity(
4795 UpdateEntityArgs {
4796 anchors: Vec::new(),
4797 id: seeded.id.clone(),
4798 expected_hash: Some(seeded.content_hash.clone()),
4799 sections: noop_sections.clone(),
4800 append_sections: IndexMap::new(),
4801 patch_sections: IndexMap::new(),
4802 sections_unset: Vec::new(),
4803 metadata: IndexMap::new(),
4804 metadata_unset: Vec::new(),
4805 declare_relations: Vec::new(),
4806 dry_run: false,
4807 relations_unset: Vec::new(),
4808 anchors_unset: Vec::new(),
4809 },
4810 actor,
4811 Some(&client),
4812 None,
4813 )
4814 .unwrap();
4815 assert_eq!(outcome.write_id, "");
4816 assert_eq!(outcome.content_hash, seeded.content_hash);
4817 }
4818
4819 let mut sections = IndexMap::new();
4822 sections.insert(
4823 "identity".to_string(),
4824 "third call: real change".to_string(),
4825 );
4826 let real = engine
4827 .update_entity(
4828 UpdateEntityArgs {
4829 anchors: Vec::new(),
4830 id: seeded.id.clone(),
4831 expected_hash: Some(seeded.content_hash.clone()),
4832 sections,
4833 append_sections: IndexMap::new(),
4834 patch_sections: IndexMap::new(),
4835 sections_unset: Vec::new(),
4836 metadata: IndexMap::new(),
4837 metadata_unset: Vec::new(),
4838 declare_relations: Vec::new(),
4839 dry_run: false,
4840 relations_unset: Vec::new(),
4841 anchors_unset: Vec::new(),
4842 },
4843 actor,
4844 Some(&client),
4845 None,
4846 )
4847 .unwrap();
4848 assert!(!real.write_id.is_empty());
4849 assert_ne!(real.content_hash, seeded.content_hash);
4850 }
4851
4852 #[test]
4861 fn synthesis_gc_drops_auto_emitted_reference_when_body_link_removed() {
4862 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4866 use indexmap::IndexMap;
4867 use tempfile::TempDir;
4868
4869 let tmp = TempDir::new().unwrap();
4870 let mem_dir = tmp.path().to_path_buf();
4871 let writer = FilesystemMemWriter::new(mem_dir.clone());
4872 let mut engine = Engine::from_mounts(vec![(
4873 folder_mount("specs", mem_dir.clone()),
4874 Box::new(writer) as Box<dyn MemBackend>,
4875 )])
4876 .unwrap();
4877 engine.set_workspace_root(mem_dir.clone());
4878 let (actor, client) = cli_actor();
4879
4880 let target = engine
4881 .create_entity(
4882 empty_create_args("specs", "Target"),
4883 actor,
4884 Some(&client),
4885 None,
4886 )
4887 .unwrap();
4888 let mut sections: IndexMap<String, String> = IndexMap::new();
4891 sections.insert("identity".to_string(), "source identity".to_string());
4892 sections.insert(
4893 "purpose".to_string(),
4894 "see [[target]] for context".to_string(),
4895 );
4896 let source = engine
4897 .create_entity(
4898 CreateEntityArgs {
4899 anchors: Vec::new(),
4900 mem: "specs".to_string(),
4901 title: "Source".to_string(),
4902 entity_type: "spec".to_string(),
4903 sections,
4904 metadata: IndexMap::new(),
4905 relations: Vec::new(),
4906 dry_run: false,
4907 },
4908 actor,
4909 Some(&client),
4910 None,
4911 )
4912 .unwrap();
4913 assert!(
4914 engine
4915 .get_entity(&source.id)
4916 .unwrap()
4917 .relationships
4918 .iter()
4919 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4920 "create-time synthesis must emit REFERENCES → target",
4921 );
4922
4923 let mut new_sections: IndexMap<String, String> = IndexMap::new();
4926 new_sections.insert("purpose".to_string(), "no link any more".to_string());
4927 engine
4928 .update_entity(
4929 UpdateEntityArgs {
4930 anchors: Vec::new(),
4931 id: source.id.clone(),
4932 expected_hash: Some(source.content_hash.clone()),
4933 sections: new_sections,
4934 append_sections: IndexMap::new(),
4935 patch_sections: IndexMap::new(),
4936 sections_unset: Vec::new(),
4937 metadata: IndexMap::new(),
4938 metadata_unset: Vec::new(),
4939 declare_relations: Vec::new(),
4940 dry_run: false,
4941 relations_unset: Vec::new(),
4942 anchors_unset: Vec::new(),
4943 },
4944 actor,
4945 Some(&client),
4946 None,
4947 )
4948 .expect("update must succeed; GC drops the now-orphan REFERENCES");
4949 let in_mem = engine.get_entity(&source.id).unwrap();
4950 assert!(
4951 !in_mem
4952 .relationships
4953 .iter()
4954 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4955 "GC must drop the auto-emitted REFERENCES after body link removal; got {:?}",
4956 in_mem.relationships,
4957 );
4958 }
4959
4960 #[test]
4961 fn update_gc_removes_orphan_stub_when_last_body_link_dropped() {
4962 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4970 use indexmap::IndexMap;
4971 use tempfile::TempDir;
4972
4973 let tmp = TempDir::new().unwrap();
4974 let mem_dir = tmp.path().to_path_buf();
4975 let writer = FilesystemMemWriter::new(mem_dir.clone());
4976 let mut engine = Engine::from_mounts(vec![(
4977 folder_mount("specs", mem_dir.clone()),
4978 Box::new(writer) as Box<dyn MemBackend>,
4979 )])
4980 .unwrap();
4981 engine.set_workspace_root(mem_dir.clone());
4982 let (actor, client) = cli_actor();
4983
4984 let ghost = crate::EntityId::new("specs", "ghost");
4985 let mut sections: IndexMap<String, String> = IndexMap::new();
4986 sections.insert("identity".to_string(), "source identity".to_string());
4987 sections.insert(
4988 "purpose".to_string(),
4989 "see [[ghost]] for context".to_string(),
4990 );
4991 let source = engine
4992 .create_entity(
4993 CreateEntityArgs {
4994 anchors: Vec::new(),
4995 mem: "specs".to_string(),
4996 title: "Source".to_string(),
4997 entity_type: "spec".to_string(),
4998 sections,
4999 metadata: IndexMap::new(),
5000 relations: Vec::new(),
5001 dry_run: false,
5002 },
5003 actor,
5004 Some(&client),
5005 None,
5006 )
5007 .unwrap();
5008 assert!(
5009 engine.store().contains(&ghost) && engine.get_entity(&ghost).unwrap().stub,
5010 "body wiki-link to an absent target must auto-stub it",
5011 );
5012 assert_eq!(
5013 engine.health().stub_count,
5014 1,
5015 "one stub before the link drop"
5016 );
5017
5018 let mut new_sections: IndexMap<String, String> = IndexMap::new();
5019 new_sections.insert("purpose".to_string(), "no link any more".to_string());
5020 let outcome = engine
5021 .update_entity(
5022 UpdateEntityArgs {
5023 anchors: Vec::new(),
5024 id: source.id.clone(),
5025 expected_hash: Some(source.content_hash.clone()),
5026 sections: new_sections,
5027 append_sections: IndexMap::new(),
5028 patch_sections: IndexMap::new(),
5029 sections_unset: Vec::new(),
5030 metadata: IndexMap::new(),
5031 metadata_unset: Vec::new(),
5032 declare_relations: Vec::new(),
5033 dry_run: false,
5034 relations_unset: Vec::new(),
5035 anchors_unset: Vec::new(),
5036 },
5037 actor,
5038 Some(&client),
5039 None,
5040 )
5041 .expect("update must succeed and GC the now-orphan stub");
5042
5043 assert_eq!(
5044 outcome.orphan_stubs_removed,
5045 vec![ghost.clone()],
5046 "the update that dropped the last body link must report the GC'd stub",
5047 );
5048 assert!(
5049 !engine.store().contains(&ghost),
5050 "orphan stub must be gone from the in-memory store",
5051 );
5052 assert_eq!(
5053 engine.health().stub_count,
5054 0,
5055 "stub count decremented in-session"
5056 );
5057
5058 engine.reload_each_writable_mem().unwrap();
5062 assert!(
5063 !engine.store().contains(&ghost),
5064 "stub stays gone after reload-from-disk",
5065 );
5066 assert_eq!(
5067 engine.health().stub_count,
5068 0,
5069 "reloaded-from-disk store carries the same stub count as the in-session post-update state",
5070 );
5071 }
5072
5073 #[test]
5074 fn update_gc_noop_when_section_edit_changes_no_body_link() {
5075 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
5080 use indexmap::IndexMap;
5081 use tempfile::TempDir;
5082
5083 let tmp = TempDir::new().unwrap();
5084 let mem_dir = tmp.path().to_path_buf();
5085 let writer = FilesystemMemWriter::new(mem_dir.clone());
5086 let mut engine = Engine::from_mounts(vec![(
5087 folder_mount("specs", mem_dir.clone()),
5088 Box::new(writer) as Box<dyn MemBackend>,
5089 )])
5090 .unwrap();
5091 engine.set_workspace_root(mem_dir.clone());
5092 let (actor, client) = cli_actor();
5093
5094 let ghost = crate::EntityId::new("specs", "ghost");
5095 let mut sections: IndexMap<String, String> = IndexMap::new();
5096 sections.insert("identity".to_string(), "original identity".to_string());
5097 sections.insert(
5098 "purpose".to_string(),
5099 "see [[ghost]] for context".to_string(),
5100 );
5101 let source = engine
5102 .create_entity(
5103 CreateEntityArgs {
5104 anchors: Vec::new(),
5105 mem: "specs".to_string(),
5106 title: "Source".to_string(),
5107 entity_type: "spec".to_string(),
5108 sections,
5109 metadata: IndexMap::new(),
5110 relations: Vec::new(),
5111 dry_run: false,
5112 },
5113 actor,
5114 Some(&client),
5115 None,
5116 )
5117 .unwrap();
5118 assert!(engine.store().contains(&ghost), "ghost stub materialised");
5119
5120 let mut edit: IndexMap<String, String> = IndexMap::new();
5123 edit.insert("identity".to_string(), "edited identity".to_string());
5124 let outcome = engine
5125 .update_entity(
5126 UpdateEntityArgs {
5127 anchors: Vec::new(),
5128 id: source.id.clone(),
5129 expected_hash: Some(source.content_hash.clone()),
5130 sections: edit,
5131 append_sections: IndexMap::new(),
5132 patch_sections: IndexMap::new(),
5133 sections_unset: Vec::new(),
5134 metadata: IndexMap::new(),
5135 metadata_unset: Vec::new(),
5136 declare_relations: Vec::new(),
5137 dry_run: false,
5138 relations_unset: Vec::new(),
5139 anchors_unset: Vec::new(),
5140 },
5141 actor,
5142 Some(&client),
5143 None,
5144 )
5145 .expect("update must succeed");
5146 assert!(
5147 outcome.orphan_stubs_removed.is_empty(),
5148 "an edit that keeps every body wiki-link orphans nothing; got {:?}",
5149 outcome.orphan_stubs_removed,
5150 );
5151 assert!(
5152 engine.store().contains(&ghost),
5153 "the still-referenced stub survives the unrelated section edit",
5154 );
5155 }
5156
5157 #[test]
5158 fn update_gc_preserves_stub_with_surviving_referrer() {
5159 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
5163 use indexmap::IndexMap;
5164 use tempfile::TempDir;
5165
5166 let tmp = TempDir::new().unwrap();
5167 let mem_dir = tmp.path().to_path_buf();
5168 let writer = FilesystemMemWriter::new(mem_dir.clone());
5169 let mut engine = Engine::from_mounts(vec![(
5170 folder_mount("specs", mem_dir.clone()),
5171 Box::new(writer) as Box<dyn MemBackend>,
5172 )])
5173 .unwrap();
5174 engine.set_workspace_root(mem_dir.clone());
5175 let (actor, client) = cli_actor();
5176
5177 let ghost = crate::EntityId::new("specs", "ghost");
5178 let make_with_link = |title: &str| {
5179 let mut sections: IndexMap<String, String> = IndexMap::new();
5180 sections.insert("identity".to_string(), format!("{title} identity"));
5181 sections.insert("purpose".to_string(), "see [[ghost]]".to_string());
5182 CreateEntityArgs {
5183 anchors: Vec::new(),
5184 mem: "specs".to_string(),
5185 title: title.to_string(),
5186 entity_type: "spec".to_string(),
5187 sections,
5188 metadata: IndexMap::new(),
5189 relations: Vec::new(),
5190 dry_run: false,
5191 }
5192 };
5193 let source_a = engine
5194 .create_entity(make_with_link("Source A"), actor, Some(&client), None)
5195 .unwrap();
5196 engine
5197 .create_entity(make_with_link("Source B"), actor, Some(&client), None)
5198 .unwrap();
5199 assert!(engine.store().contains(&ghost), "ghost stub materialised");
5200
5201 let mut drop_link: IndexMap<String, String> = IndexMap::new();
5203 drop_link.insert("purpose".to_string(), "no link here".to_string());
5204 let outcome = engine
5205 .update_entity(
5206 UpdateEntityArgs {
5207 anchors: Vec::new(),
5208 id: source_a.id.clone(),
5209 expected_hash: Some(source_a.content_hash.clone()),
5210 sections: drop_link,
5211 append_sections: IndexMap::new(),
5212 patch_sections: IndexMap::new(),
5213 sections_unset: Vec::new(),
5214 metadata: IndexMap::new(),
5215 metadata_unset: Vec::new(),
5216 declare_relations: Vec::new(),
5217 dry_run: false,
5218 relations_unset: Vec::new(),
5219 anchors_unset: Vec::new(),
5220 },
5221 actor,
5222 Some(&client),
5223 None,
5224 )
5225 .expect("update must succeed");
5226 assert!(
5227 outcome.orphan_stubs_removed.is_empty(),
5228 "the stub keeps a referrer (source B), so nothing is GC'd; got {:?}",
5229 outcome.orphan_stubs_removed,
5230 );
5231 assert!(
5232 engine.store().contains(&ghost),
5233 "stub survives via the surviving referrer",
5234 );
5235 }
5236
5237 #[test]
5238 fn synthesis_gc_preserves_non_pointer_explicit_relation_across_body_update() {
5239 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
5248 use indexmap::IndexMap;
5249 use tempfile::TempDir;
5250
5251 let tmp = TempDir::new().unwrap();
5252 let mem_dir = tmp.path().to_path_buf();
5253 let writer = FilesystemMemWriter::new(mem_dir.clone());
5254 let mut engine = Engine::from_mounts(vec![(
5255 folder_mount("specs", mem_dir.clone()),
5256 Box::new(writer) as Box<dyn MemBackend>,
5257 )])
5258 .unwrap();
5259 engine.set_workspace_root(mem_dir.clone());
5260 let (actor, client) = cli_actor();
5261
5262 let target = engine
5263 .create_entity(
5264 empty_create_args("specs", "Target"),
5265 actor,
5266 Some(&client),
5267 None,
5268 )
5269 .unwrap();
5270 let source = engine
5271 .create_entity(
5272 empty_create_args("specs", "Source"),
5273 actor,
5274 Some(&client),
5275 None,
5276 )
5277 .unwrap();
5278
5279 let relate = engine
5281 .relate_entity(
5282 RelateEntityArgs {
5283 source: source.id.clone(),
5284 expected_hash: Some(source.content_hash.clone()),
5285 rel_type: "USES".to_string(),
5286 target: target.id.clone(),
5287 remove: false,
5288 description: None,
5289 dry_run: false,
5290 },
5291 actor,
5292 Some(&client),
5293 None,
5294 )
5295 .unwrap();
5296
5297 let mut sections: IndexMap<String, String> = IndexMap::new();
5300 sections.insert("purpose".to_string(), "unrelated edit".to_string());
5301 engine
5302 .update_entity(
5303 UpdateEntityArgs {
5304 anchors: Vec::new(),
5305 id: source.id.clone(),
5306 expected_hash: Some(relate.content_hash.clone()),
5307 sections,
5308 append_sections: IndexMap::new(),
5309 patch_sections: IndexMap::new(),
5310 sections_unset: Vec::new(),
5311 metadata: IndexMap::new(),
5312 metadata_unset: Vec::new(),
5313 declare_relations: Vec::new(),
5314 dry_run: false,
5315 relations_unset: Vec::new(),
5316 anchors_unset: Vec::new(),
5317 },
5318 actor,
5319 Some(&client),
5320 None,
5321 )
5322 .expect("update must succeed");
5323 let in_mem = engine.get_entity(&source.id).unwrap();
5324 assert!(
5325 in_mem
5326 .relationships
5327 .iter()
5328 .any(|r| r.rel_type == "USES" && r.target == target.id),
5329 "explicit USES must survive an unrelated body update; got {:?}",
5330 in_mem.relationships,
5331 );
5332 }
5333
5334 #[test]
5335 fn synthesis_dedupes_repeated_body_links_to_same_target() {
5336 use crate::engine::UpdateEntityArgs;
5339 use indexmap::IndexMap;
5340 use tempfile::TempDir;
5341
5342 let tmp = TempDir::new().unwrap();
5343 let mem_dir = tmp.path().to_path_buf();
5344 let writer = FilesystemMemWriter::new(mem_dir.clone());
5345 let mut engine = Engine::from_mounts(vec![(
5346 folder_mount("specs", mem_dir.clone()),
5347 Box::new(writer) as Box<dyn MemBackend>,
5348 )])
5349 .unwrap();
5350 engine.set_workspace_root(mem_dir.clone());
5351 let (actor, client) = cli_actor();
5352
5353 let target = engine
5354 .create_entity(
5355 empty_create_args("specs", "Target"),
5356 actor,
5357 Some(&client),
5358 None,
5359 )
5360 .unwrap();
5361 let source = engine
5362 .create_entity(
5363 empty_create_args("specs", "Source"),
5364 actor,
5365 Some(&client),
5366 None,
5367 )
5368 .unwrap();
5369
5370 let mut sections: IndexMap<String, String> = IndexMap::new();
5371 sections.insert(
5372 "purpose".to_string(),
5373 "see [[target]] and again [[target]]".to_string(),
5374 );
5375 engine
5376 .update_entity(
5377 UpdateEntityArgs {
5378 anchors: Vec::new(),
5379 id: source.id.clone(),
5380 expected_hash: Some(source.content_hash.clone()),
5381 sections,
5382 append_sections: IndexMap::new(),
5383 patch_sections: IndexMap::new(),
5384 sections_unset: Vec::new(),
5385 metadata: IndexMap::new(),
5386 metadata_unset: Vec::new(),
5387 declare_relations: Vec::new(),
5388 dry_run: false,
5389 relations_unset: Vec::new(),
5390 anchors_unset: Vec::new(),
5391 },
5392 actor,
5393 Some(&client),
5394 None,
5395 )
5396 .unwrap();
5397 let in_mem = engine.get_entity(&source.id).unwrap();
5398 let count = in_mem
5399 .relationships
5400 .iter()
5401 .filter(|r| r.rel_type == "REFERENCES" && r.target == target.id)
5402 .count();
5403 assert_eq!(
5404 count, 1,
5405 "dedupe must leave exactly one REFERENCES → target; got {:?}",
5406 in_mem.relationships,
5407 );
5408 }
5409
5410 #[test]
5411 fn synthesis_coexists_with_explicit_uses_to_same_target() {
5412 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
5417 use indexmap::IndexMap;
5418 use tempfile::TempDir;
5419
5420 let tmp = TempDir::new().unwrap();
5421 let mem_dir = tmp.path().to_path_buf();
5422 let writer = FilesystemMemWriter::new(mem_dir.clone());
5423 let mut engine = Engine::from_mounts(vec![(
5424 folder_mount("specs", mem_dir.clone()),
5425 Box::new(writer) as Box<dyn MemBackend>,
5426 )])
5427 .unwrap();
5428 engine.set_workspace_root(mem_dir.clone());
5429 let (actor, client) = cli_actor();
5430
5431 let target = engine
5432 .create_entity(
5433 empty_create_args("specs", "Target"),
5434 actor,
5435 Some(&client),
5436 None,
5437 )
5438 .unwrap();
5439 let source = engine
5440 .create_entity(
5441 empty_create_args("specs", "Source"),
5442 actor,
5443 Some(&client),
5444 None,
5445 )
5446 .unwrap();
5447 let relate = engine
5449 .relate_entity(
5450 RelateEntityArgs {
5451 source: source.id.clone(),
5452 expected_hash: Some(source.content_hash.clone()),
5453 rel_type: "USES".to_string(),
5454 target: target.id.clone(),
5455 remove: false,
5456 description: None,
5457 dry_run: false,
5458 },
5459 actor,
5460 Some(&client),
5461 None,
5462 )
5463 .unwrap();
5464 let mut sections: IndexMap<String, String> = IndexMap::new();
5466 sections.insert(
5467 "purpose".to_string(),
5468 "we also reference [[target]]".to_string(),
5469 );
5470 engine
5471 .update_entity(
5472 UpdateEntityArgs {
5473 anchors: Vec::new(),
5474 id: source.id.clone(),
5475 expected_hash: Some(relate.content_hash.clone()),
5476 sections,
5477 append_sections: IndexMap::new(),
5478 patch_sections: IndexMap::new(),
5479 sections_unset: Vec::new(),
5480 metadata: IndexMap::new(),
5481 metadata_unset: Vec::new(),
5482 declare_relations: Vec::new(),
5483 dry_run: false,
5484 relations_unset: Vec::new(),
5485 anchors_unset: Vec::new(),
5486 },
5487 actor,
5488 Some(&client),
5489 None,
5490 )
5491 .unwrap();
5492 let in_mem = engine.get_entity(&source.id).unwrap();
5493 assert!(
5494 in_mem
5495 .relationships
5496 .iter()
5497 .any(|r| r.rel_type == "USES" && r.target == target.id),
5498 "USES must survive — synthesis dedupes on (rel_type, target)",
5499 );
5500 assert!(
5501 in_mem
5502 .relationships
5503 .iter()
5504 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
5505 "REFERENCES must be synthesised even though USES already targets the same entity",
5506 );
5507 }
5508
5509 mod alias_synthesis_custom_schema {
5521 use std::path::Path;
5522
5523 use indexmap::IndexMap;
5524 use memstead_schema::SchemaRef;
5525 use tempfile::TempDir;
5526
5527 use crate::backend::MemBackend;
5528 use crate::engine::test_helpers::*;
5529 use crate::engine::{CreateEntityArgs, Engine, EngineError, UpdateEntityArgs};
5530 use crate::storage::FilesystemMemWriter;
5531 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
5532
5533 const TYPE_BODY: &str = r#"description: t
5534when_to_use: tests
5535sections:
5536 - key: body
5537 heading: Body
5538 required: true
5539 search_weight: 10.0
5540 catch_all: true
5541 write_rules: []
5542metadata_fields: []
5543title_weight: 100.0
5544text_fields:
5545 - body
5546hierarchy_relationship: _default
5547no_self_loop_relationships: []
5548updatable_fields:
5549 - title
5550 - body
5551health_required_fields:
5552 - body
5553staleness_threshold_days: 90
5554write_rules: []
5555"#;
5556
5557 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
5558 let dir = root.join(name);
5559 std::fs::create_dir_all(dir.join("types")).unwrap();
5560 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
5561 for (type_name, body) in types {
5562 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
5563 }
5564 }
5565
5566 fn make_type_yaml(name: &str) -> String {
5567 format!("name: {name}\n{TYPE_BODY}")
5568 }
5569
5570 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
5571 Mount {
5572 mem: mem.to_string(),
5573 schema: Some(pin),
5574 storage: MountStorage::Folder { path },
5575 capability: MountCapability::Write,
5576 lifecycle: MountLifecycle::Eager,
5577 cross_linkable: true,
5578 migration_target: None,
5579 }
5580 }
5581
5582 fn engine_with_schema(
5583 manifest: &str,
5584 type_yaml_name: &str,
5585 schema_name: &str,
5586 schema_version: semver::Version,
5587 ) -> (Engine, TempDir) {
5588 let tmp = TempDir::new().unwrap();
5589 let schemas_dir = tmp.path().join("schemas");
5590 std::fs::create_dir_all(&schemas_dir).unwrap();
5591 write_schema_files(
5592 &schemas_dir,
5593 schema_name,
5594 manifest,
5595 &[(type_yaml_name, &make_type_yaml(type_yaml_name))],
5596 );
5597 let mem_dir = tmp.path().join("mem");
5598 std::fs::create_dir_all(&mem_dir).unwrap();
5599 let writer = FilesystemMemWriter::new(mem_dir.clone());
5600 let pin = SchemaRef::new(schema_name, schema_version);
5601 let mount = folder_mount_with_pin("v", mem_dir, pin);
5602 let mut engine = Engine::from_mounts_with_schemas_dir(
5603 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
5604 Some(&schemas_dir),
5605 )
5606 .expect("engine with custom schema constructs");
5607 engine.set_workspace_root(tmp.path().to_path_buf());
5608 (engine, tmp)
5609 }
5610
5611 #[test]
5612 fn non_references_alias_pointer_emits_named_rel_type_from_body_link() {
5613 let manifest = r#"name: aliased
5618version: 0.1.0
5619description: alias-synthesis fixture using a non-REFERENCES pointer
5620when_to_use: tests prove the engine does not hard-code REFERENCES
5621types:
5622 - doc
5623relationships:
5624 mode: strict
5625 definitions:
5626 - name: CITES
5627 description: Citation — auto-emitted from body wiki-links
5628 default_weight: 0.5
5629 - name: PART_OF
5630 description: Hierarchy
5631 default_weight: 3.0
5632 acyclic: true
5633 - name: _default
5634 description: Fallback
5635 default_weight: 1.0
5636alias_target_rel_type: CITES
5637community:
5638 resolution: 1.0
5639 seed: 42
5640"#;
5641 let (mut engine, _tmp) =
5642 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5643 let (actor, client) = cli_actor();
5644
5645 let target = engine
5646 .create_entity(
5647 CreateEntityArgs {
5648 anchors: Vec::new(),
5649 mem: "v".to_string(),
5650 title: "Target".to_string(),
5651 entity_type: "doc".to_string(),
5652 sections: IndexMap::from_iter([(
5653 "body".to_string(),
5654 "target body".to_string(),
5655 )]),
5656 metadata: IndexMap::new(),
5657 relations: Vec::new(),
5658 dry_run: false,
5659 },
5660 actor,
5661 Some(&client),
5662 None,
5663 )
5664 .unwrap();
5665
5666 let mut sections: IndexMap<String, String> = IndexMap::new();
5667 sections.insert("body".to_string(), "see [[target]]".to_string());
5668 let source = engine
5669 .create_entity(
5670 CreateEntityArgs {
5671 anchors: Vec::new(),
5672 mem: "v".to_string(),
5673 title: "Source".to_string(),
5674 entity_type: "doc".to_string(),
5675 sections,
5676 metadata: IndexMap::new(),
5677 relations: Vec::new(),
5678 dry_run: false,
5679 },
5680 actor,
5681 Some(&client),
5682 None,
5683 )
5684 .expect("create must succeed; CITES is auto-emitted by synthesis");
5685
5686 let in_mem = engine.get_entity(&source.id).unwrap();
5687 assert!(
5688 in_mem
5689 .relationships
5690 .iter()
5691 .any(|r| r.rel_type == "CITES" && r.target == target.id),
5692 "synthesis must emit CITES (the pointer rel-type), not REFERENCES; got {:?}",
5693 in_mem.relationships,
5694 );
5695 assert!(
5696 !in_mem
5697 .relationships
5698 .iter()
5699 .any(|r| r.rel_type == "REFERENCES"),
5700 "engine must not hard-code REFERENCES — pointer rel-type is CITES; got {:?}",
5701 in_mem.relationships,
5702 );
5703 }
5704
5705 #[test]
5706 fn no_pointer_schema_refuses_unbacked_body_wiki_link() {
5707 let manifest = r#"name: no-alias
5712version: 0.1.0
5713description: schema without alias_target_rel_type pointer
5714when_to_use: tests prove strict validator still fires for opt-out schemas
5715types:
5716 - doc
5717relationships:
5718 mode: strict
5719 definitions:
5720 - name: USES
5721 description: Use
5722 default_weight: 1.0
5723 - name: PART_OF
5724 description: Hierarchy
5725 default_weight: 3.0
5726 acyclic: true
5727 - name: _default
5728 description: Fallback
5729 default_weight: 1.0
5730community:
5731 resolution: 1.0
5732 seed: 42
5733"#;
5734 let (mut engine, _tmp) =
5735 engine_with_schema(manifest, "doc", "no-alias", semver::Version::new(0, 1, 0));
5736 let (actor, client) = cli_actor();
5737
5738 let target = engine
5739 .create_entity(
5740 CreateEntityArgs {
5741 anchors: Vec::new(),
5742 mem: "v".to_string(),
5743 title: "Target".to_string(),
5744 entity_type: "doc".to_string(),
5745 sections: IndexMap::from_iter([(
5746 "body".to_string(),
5747 "target body".to_string(),
5748 )]),
5749 metadata: IndexMap::new(),
5750 relations: Vec::new(),
5751 dry_run: false,
5752 },
5753 actor,
5754 Some(&client),
5755 None,
5756 )
5757 .unwrap();
5758 let source = engine
5759 .create_entity(
5760 CreateEntityArgs {
5761 anchors: Vec::new(),
5762 mem: "v".to_string(),
5763 title: "Source".to_string(),
5764 entity_type: "doc".to_string(),
5765 sections: IndexMap::from_iter([(
5766 "body".to_string(),
5767 "source body".to_string(),
5768 )]),
5769 metadata: IndexMap::new(),
5770 relations: Vec::new(),
5771 dry_run: false,
5772 },
5773 actor,
5774 Some(&client),
5775 None,
5776 )
5777 .unwrap();
5778
5779 let mut sections: IndexMap<String, String> = IndexMap::new();
5783 sections.insert("body".to_string(), "see [[target]]".to_string());
5784 let err = engine
5785 .update_entity(
5786 UpdateEntityArgs {
5787 anchors: Vec::new(),
5788 id: source.id.clone(),
5789 expected_hash: Some(source.content_hash.clone()),
5790 sections,
5791 append_sections: IndexMap::new(),
5792 patch_sections: IndexMap::new(),
5793 sections_unset: Vec::new(),
5794 metadata: IndexMap::new(),
5795 metadata_unset: Vec::new(),
5796 declare_relations: Vec::new(),
5797 dry_run: false,
5798 relations_unset: Vec::new(),
5799 anchors_unset: Vec::new(),
5800 },
5801 actor,
5802 Some(&client),
5803 None,
5804 )
5805 .unwrap_err();
5806 match err {
5807 EngineError::WikiLinkWithoutRelation { from_id, missing } => {
5808 assert_eq!(from_id, source.id.to_string());
5809 assert_eq!(missing.len(), 1);
5810 assert_eq!(missing[0].section_key, "body");
5811 assert_eq!(missing[0].target_id, target.id.to_string());
5812 }
5813 other => panic!(
5814 "no-pointer schema must refuse with WikiLinkWithoutRelation; got {other:?}"
5815 ),
5816 }
5817 }
5818
5819 #[test]
5828 fn natural_form_body_wiki_link_refuses_with_typed_envelope() {
5829 let manifest = r#"name: aliased
5830version: 0.1.0
5831description: alias-synthesis fixture
5832when_to_use: tests prove strict wiki-link grammar at mutation entry
5833types:
5834 - doc
5835relationships:
5836 mode: strict
5837 definitions:
5838 - name: REFERENCES
5839 description: Reference — auto-emitted from body wiki-links
5840 default_weight: 0.5
5841 - name: PART_OF
5842 description: Hierarchy
5843 default_weight: 3.0
5844 acyclic: true
5845 - name: _default
5846 description: Fallback
5847 default_weight: 1.0
5848alias_target_rel_type: REFERENCES
5849community:
5850 resolution: 1.0
5851 seed: 42
5852"#;
5853 let (mut engine, _tmp) =
5854 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5855 let (actor, client) = cli_actor();
5856
5857 let mut sections: IndexMap<String, String> = IndexMap::new();
5858 sections.insert("body".to_string(), "see [[Knowledge Graph]]".to_string());
5859 let err = engine
5860 .create_entity(
5861 CreateEntityArgs {
5862 anchors: Vec::new(),
5863 mem: "v".to_string(),
5864 title: "Source".to_string(),
5865 entity_type: "doc".to_string(),
5866 sections,
5867 metadata: IndexMap::new(),
5868 relations: Vec::new(),
5869 dry_run: false,
5870 },
5871 actor,
5872 Some(&client),
5873 None,
5874 )
5875 .unwrap_err();
5876 match err {
5877 EngineError::InvalidWikiLinkTarget {
5878 raw,
5879 suggested,
5880 section,
5881 link_source,
5882 ..
5883 } => {
5884 assert_eq!(raw, "Knowledge Graph");
5885 assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
5886 assert_eq!(section, "body");
5887 assert_eq!(link_source, "body_link");
5888 }
5889 other => panic!(
5890 "natural-form body wiki-link must refuse with InvalidWikiLinkTarget; got {other:?}"
5891 ),
5892 }
5893 }
5894
5895 #[test]
5901 fn tier_two_bad_mem_prefix_refuses_with_distinct_envelope() {
5902 let manifest = r#"name: aliased
5903version: 0.1.0
5904description: alias-synthesis fixture
5905when_to_use: tests prove strict mem-prefix grammar at mutation entry
5906types:
5907 - doc
5908relationships:
5909 mode: strict
5910 definitions:
5911 - name: REFERENCES
5912 description: Reference
5913 default_weight: 0.5
5914 - name: PART_OF
5915 description: Hierarchy
5916 default_weight: 3.0
5917 acyclic: true
5918 - name: _default
5919 description: Fallback
5920 default_weight: 1.0
5921alias_target_rel_type: REFERENCES
5922community:
5923 resolution: 1.0
5924 seed: 42
5925"#;
5926 let (mut engine, _tmp) =
5927 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5928 let (actor, client) = cli_actor();
5929
5930 let mut sections: IndexMap<String, String> = IndexMap::new();
5931 sections.insert("body".to_string(), "see [[Other Mem:foo]]".to_string());
5932 let err = engine
5933 .create_entity(
5934 CreateEntityArgs {
5935 anchors: Vec::new(),
5936 mem: "v".to_string(),
5937 title: "Source".to_string(),
5938 entity_type: "doc".to_string(),
5939 sections,
5940 metadata: IndexMap::new(),
5941 relations: Vec::new(),
5942 dry_run: false,
5943 },
5944 actor,
5945 Some(&client),
5946 None,
5947 )
5948 .unwrap_err();
5949 match err {
5950 EngineError::InvalidWikiLinkMem { raw, section, .. } => {
5951 assert_eq!(raw, "Other Mem");
5952 assert_eq!(section, "body");
5953 }
5954 other => panic!(
5955 "Tier-2 bad mem prefix must refuse with InvalidWikiLinkMem; got {other:?}"
5956 ),
5957 }
5958 }
5959
5960 #[test]
5967 fn hierarchical_dash_form_body_link_refuses_with_colon_suggestion() {
5968 let manifest = r#"name: aliased
5969version: 0.1.0
5970description: alias-synthesis fixture
5971when_to_use: tests prove hierarchical dash-form refusal at mutation entry
5972types:
5973 - doc
5974relationships:
5975 mode: strict
5976 definitions:
5977 - name: REFERENCES
5978 description: Reference — auto-emitted from body wiki-links
5979 default_weight: 0.5
5980 - name: PART_OF
5981 description: Hierarchy
5982 default_weight: 3.0
5983 acyclic: true
5984 - name: _default
5985 description: Fallback
5986 default_weight: 1.0
5987alias_target_rel_type: REFERENCES
5988community:
5989 resolution: 1.0
5990 seed: 42
5991"#;
5992 let (mut engine, _tmp) =
5993 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5994 let (actor, client) = cli_actor();
5995
5996 let mut sections: IndexMap<String, String> = IndexMap::new();
5997 sections.insert(
5998 "body".to_string(),
5999 "see [[team/sub-mem--target]]".to_string(),
6000 );
6001 let err = engine
6002 .create_entity(
6003 CreateEntityArgs {
6004 anchors: Vec::new(),
6005 mem: "v".to_string(),
6006 title: "Source".to_string(),
6007 entity_type: "doc".to_string(),
6008 sections,
6009 metadata: IndexMap::new(),
6010 relations: Vec::new(),
6011 dry_run: false,
6012 },
6013 actor,
6014 Some(&client),
6015 None,
6016 )
6017 .unwrap_err();
6018 match err {
6019 EngineError::InvalidWikiLinkTarget {
6020 raw,
6021 suggested,
6022 section,
6023 link_source,
6024 ..
6025 } => {
6026 assert_eq!(raw, "team/sub-mem--target");
6027 assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
6028 assert_eq!(section, "body");
6029 assert_eq!(link_source, "body_link");
6030 }
6031 other => panic!(
6032 "hierarchical dash-form body link must refuse with InvalidWikiLinkTarget; got {other:?}"
6033 ),
6034 }
6035
6036 let listed = engine.store().all_entities().collect::<Vec<_>>();
6039 assert!(
6040 listed.is_empty(),
6041 "refused create must not leave any entity behind, got: {listed:?}"
6042 );
6043 }
6044 }
6045
6046 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";
6055
6056 fn repair_engine() -> (TempDir, Engine) {
6057 let tmp = TempDir::new().unwrap();
6058 let mem_dir = tmp.path().to_path_buf();
6059 std::fs::write(
6060 mem_dir.join("anchor.md"),
6061 "---\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",
6062 )
6063 .unwrap();
6064 std::fs::write(mem_dir.join("drifted.md"), DRIFTED_MD).unwrap();
6065 let writer = FilesystemMemWriter::new(mem_dir.clone());
6066 let engine = Engine::from_mounts(vec![(
6067 folder_mount("specs", mem_dir),
6068 Box::new(writer) as Box<dyn MemBackend>,
6069 )])
6070 .unwrap();
6071 (tmp, engine)
6072 }
6073
6074 fn repair_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
6075 UpdateEntityArgs {
6076 anchors: Vec::new(),
6077 id,
6078 expected_hash: hash,
6079 sections: IndexMap::new(),
6080 append_sections: IndexMap::new(),
6081 patch_sections: IndexMap::new(),
6082 sections_unset: Vec::new(),
6083 metadata: IndexMap::new(),
6084 metadata_unset: Vec::new(),
6085 declare_relations: Vec::new(),
6086 dry_run: false,
6087 relations_unset: vec![crate::ops::RelationUnsetArg {
6088 rel_type: "USES".to_string(),
6089 target: EntityId::new("specs", "anchor"),
6090 }],
6091 anchors_unset: Vec::new(),
6092 }
6093 }
6094
6095 #[test]
6100 fn relations_unset_on_conformant_entity_refuses_repair_not_needed() {
6101 let (_tmp, mut engine) = repair_engine();
6102 let anchor = EntityId::new("specs", "anchor");
6105 let drifted = EntityId::new("specs", "drifted");
6106 engine
6107 .relate_entity(
6108 RelateEntityArgs {
6109 source: anchor.clone(),
6110 expected_hash: None,
6111 rel_type: "USES".to_string(),
6112 target: drifted.clone(),
6113 remove: false,
6114 description: None,
6115 dry_run: false,
6116 },
6117 Actor::Cli,
6118 None,
6119 None,
6120 )
6121 .expect("relate on conformant entity works");
6122 let mut args = repair_args(anchor.clone(), None);
6123 args.relations_unset[0].target = drifted.clone();
6124 let err = engine
6125 .update_entity(args, Actor::Cli, None, None)
6126 .unwrap_err();
6127 match err {
6128 EngineError::RepairNotNeeded { id, recovery } => {
6129 assert_eq!(id, anchor.to_string());
6130 assert!(
6131 recovery.contains("memstead_relate"),
6132 "recovery must point at the focused tool; got {recovery}"
6133 );
6134 }
6135 other => panic!("expected RepairNotNeeded, got {other:?}"),
6136 }
6137 let entity = engine.store().get(&anchor).unwrap();
6139 assert!(
6140 entity.relationships.iter().any(|r| r.target == drifted),
6141 "gate must not modify the entity"
6142 );
6143 }
6144
6145 #[test]
6150 fn relations_unset_repairs_non_conformant_entity_atomically() {
6151 let (_tmp, mut engine) = repair_engine();
6152 let drifted = EntityId::new("specs", "drifted");
6153 let pre = engine.conformance_findings("specs", None).unwrap();
6155 assert!(
6156 pre.iter().any(|f| f.id == drifted.to_string()),
6157 "fixture must lint non-conformant; got {pre:?}"
6158 );
6159 let mut args = repair_args(drifted.clone(), None);
6160 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
6161 engine
6162 .update_entity(args, Actor::Cli, None, None)
6163 .expect("repair update lands");
6164 let entity = engine.store().get(&drifted).unwrap();
6165 assert!(
6166 entity.relationships.is_empty(),
6167 "relation must be removed; got {:?}",
6168 entity.relationships
6169 );
6170 assert!(
6171 !entity.metadata.contains_key("zzz_bogus_field"),
6172 "conformance break must be repaired in the same update"
6173 );
6174 let post = engine.conformance_findings("specs", None).unwrap();
6175 assert!(
6176 post.iter().all(|f| f.id != drifted.to_string()),
6177 "post-repair entity must be conformant; got {post:?}"
6178 );
6179 }
6180
6181 #[test]
6185 fn relations_unset_post_state_must_still_validate() {
6186 let (_tmp, mut engine) = repair_engine();
6187 let drifted = EntityId::new("specs", "drifted");
6188 let mut args = repair_args(drifted.clone(), None);
6189 args.sections = IndexMap::from_iter([("nonexistent_section".to_string(), "x".to_string())]);
6192 let err = engine
6193 .update_entity(args, Actor::Cli, None, None)
6194 .unwrap_err();
6195 assert_eq!(
6196 err.code(),
6197 "UNKNOWN_SECTION",
6198 "strict-write post-condition must hold during repair; got {err:?}"
6199 );
6200 let entity = engine.store().get(&drifted).unwrap();
6202 assert!(
6203 !entity.relationships.is_empty(),
6204 "refused repair must not partially apply"
6205 );
6206 }
6207
6208 #[test]
6211 fn relations_unset_absent_pair_is_silent_noop() {
6212 let (_tmp, mut engine) = repair_engine();
6213 let drifted = EntityId::new("specs", "drifted");
6214 let mut args = repair_args(drifted.clone(), None);
6215 args.relations_unset[0].rel_type = "NEVER_DECLARED".to_string();
6216 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
6218 engine
6219 .update_entity(args, Actor::Cli, None, None)
6220 .expect("absent pair no-ops, update lands");
6221 let entity = engine.store().get(&drifted).unwrap();
6222 assert_eq!(
6223 entity.relationships.len(),
6224 1,
6225 "the USES relation must survive an unmatched unset"
6226 );
6227 }
6228
6229 fn anchor_input(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
6232 crate::anchor::AnchorInput {
6233 artifact: Some(artifact.to_string()),
6234 grain: Some("file".to_string()),
6235 class: Some("anchored".to_string()),
6236 hash: Some(hash.to_string()),
6237 hash_stability: Some("stable".to_string()),
6238 ..Default::default()
6239 }
6240 }
6241
6242 fn anchor_unset(artifact: &str) -> crate::anchor::AnchorUnsetInput {
6243 crate::anchor::AnchorUnsetInput {
6244 artifact: Some(artifact.to_string()),
6245 grain: None,
6246 class: None,
6247 }
6248 }
6249
6250 fn anchor_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
6252 UpdateEntityArgs {
6253 anchors: Vec::new(),
6254 anchors_unset: Vec::new(),
6255 id,
6256 expected_hash: hash,
6257 sections: IndexMap::new(),
6258 append_sections: IndexMap::new(),
6259 patch_sections: IndexMap::new(),
6260 sections_unset: Vec::new(),
6261 metadata: IndexMap::new(),
6262 metadata_unset: Vec::new(),
6263 declare_relations: Vec::new(),
6264 dry_run: false,
6265 relations_unset: Vec::new(),
6266 }
6267 }
6268
6269 fn anchored_engine() -> (Engine, TempDir, EntityId, String) {
6272 let tmp = TempDir::new().unwrap();
6273 let mem_dir = tmp.path().to_path_buf();
6274 let writer = FilesystemMemWriter::new(mem_dir.clone());
6275 let mut engine = Engine::from_mounts(vec![(
6276 folder_mount("specs", mem_dir),
6277 Box::new(writer) as Box<dyn MemBackend>,
6278 )])
6279 .unwrap();
6280 let (actor, client) = cli_actor();
6281 let mut args = empty_create_args("specs", "Anchored");
6282 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
6283 let created = engine
6284 .create_entity(args, actor, Some(&client), None)
6285 .unwrap();
6286 let id = EntityId::new("specs", "anchored");
6287 assert_eq!(engine.entity_anchors(&id).len(), 2);
6288 (engine, tmp, id, created.content_hash)
6289 }
6290
6291 #[test]
6296 fn update_anchors_merge_appends_and_replaces_by_triple() {
6297 let (mut engine, _tmp, id, hash) = anchored_engine();
6298 let (actor, client) = cli_actor();
6299
6300 let mut args = anchor_args(id.clone(), Some(hash));
6302 args.anchors = vec![anchor_input("c.rs", "h-c")];
6303 let out = engine
6304 .update_entity(args, actor, Some(&client), None)
6305 .unwrap();
6306 let anchors = engine.entity_anchors(&id);
6307 assert_eq!(anchors.len(), 3, "N existing + 1 new = N+1");
6308 assert_eq!(anchors[0].artifact, "a.rs");
6309 assert_eq!(anchors[0].hash.as_deref(), Some("h-a"));
6310 assert_eq!(anchors[1].artifact, "b.rs");
6311 assert_eq!(anchors[2].artifact, "c.rs");
6312 assert!(!engine.anchors_referencing_artifact("a.rs").is_empty());
6313 assert!(!engine.anchors_referencing_artifact("c.rs").is_empty());
6314
6315 let mut args = anchor_args(id.clone(), Some(out.content_hash));
6317 args.anchors = vec![anchor_input("a.rs", "h-a2")];
6318 engine
6319 .update_entity(args, actor, Some(&client), None)
6320 .unwrap();
6321 let anchors = engine.entity_anchors(&id);
6322 assert_eq!(anchors.len(), 3);
6323 assert_eq!(anchors[0].artifact, "a.rs");
6324 assert_eq!(anchors[0].hash.as_deref(), Some("h-a2"));
6325 assert_eq!(anchors[1].hash.as_deref(), Some("h-b"), "b untouched");
6326 assert_eq!(anchors[2].hash.as_deref(), Some("h-c"), "c untouched");
6327 }
6328
6329 #[test]
6333 fn update_anchors_full_resend_and_absent_are_noops_on_stored_set() {
6334 let (mut engine, tmp, id, hash) = anchored_engine();
6335 let (actor, client) = cli_actor();
6336 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
6337 let before = std::fs::read(&sidecar_path).unwrap();
6338
6339 let mut args = anchor_args(id.clone(), Some(hash));
6341 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
6342 let out = engine
6343 .update_entity(args, actor, Some(&client), None)
6344 .unwrap();
6345 assert_eq!(
6346 std::fs::read(&sidecar_path).unwrap(),
6347 before,
6348 "full re-send keeps the stored bytes"
6349 );
6350
6351 let mut args = anchor_args(id.clone(), Some(out.content_hash));
6353 args.sections
6354 .insert("identity".to_string(), "changed body".to_string());
6355 engine
6356 .update_entity(args, actor, Some(&client), None)
6357 .unwrap();
6358 assert_eq!(
6359 std::fs::read(&sidecar_path).unwrap(),
6360 before,
6361 "an anchorless update never touches the stored set"
6362 );
6363 }
6364
6365 #[test]
6370 fn update_anchors_unset_bare_narrowed_idempotent_and_unset_first() {
6371 let (mut engine, _tmp, id, hash) = anchored_engine();
6372 let (actor, client) = cli_actor();
6373
6374 let mut span = anchor_input("a.rs", "h-span");
6376 span.grain = Some("span".to_string());
6377 let mut args = anchor_args(id.clone(), Some(hash));
6378 args.anchors = vec![span];
6379 let out = engine
6380 .update_entity(args, actor, Some(&client), None)
6381 .unwrap();
6382 assert_eq!(engine.entity_anchors(&id).len(), 3);
6383
6384 let mut narrowed = anchor_unset("a.rs");
6386 narrowed.grain = Some("span".to_string());
6387 let mut args = anchor_args(id.clone(), Some(out.content_hash));
6388 args.anchors_unset = vec![narrowed];
6389 let out = engine
6390 .update_entity(args, actor, Some(&client), None)
6391 .unwrap();
6392 let anchors = engine.entity_anchors(&id);
6393 assert_eq!(anchors.len(), 2);
6394 assert!(
6395 anchors
6396 .iter()
6397 .all(|a| a.grain == crate::anchor::AnchorGrain::File)
6398 );
6399
6400 let mut args = anchor_args(id.clone(), Some(out.content_hash.clone()));
6402 args.anchors_unset = vec![anchor_unset("never-there.rs")];
6403 engine
6404 .update_entity(args, actor, Some(&client), None)
6405 .expect("unset of a nonexistent target is a no-op, not an error");
6406 assert_eq!(engine.entity_anchors(&id).len(), 2);
6407
6408 let mut args = anchor_args(id.clone(), Some(out.content_hash));
6411 args.anchors_unset = vec![anchor_unset("a.rs")];
6412 args.anchors = vec![anchor_input("a.rs", "h-a-fresh")];
6413 engine
6414 .update_entity(args, actor, Some(&client), None)
6415 .unwrap();
6416 let anchors = engine.entity_anchors(&id);
6417 assert_eq!(anchors.len(), 2);
6418 assert_eq!(anchors[0].artifact, "b.rs", "b.rs untouched throughout");
6419 assert_eq!(anchors[1].hash.as_deref(), Some("h-a-fresh"));
6420 }
6421
6422 #[test]
6429 fn a_payload_naming_one_triple_twice_is_refused() {
6430 let (mut engine, _tmp, id, hash) = anchored_engine();
6431 let (actor, client) = cli_actor();
6432
6433 let mut args = anchor_args(id.clone(), Some(hash.clone()));
6434 args.anchors = vec![
6435 anchor_input("a.rs", "h-first"),
6436 anchor_input("a.rs", "h-second"),
6437 ];
6438 let err = engine
6439 .update_entity(args, actor, Some(&client), None)
6440 .expect_err("the repeated triple must refuse");
6441 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
6442 assert!(
6443 format!("{err}").contains("more than once"),
6444 "the refusal names the collapse: {err}"
6445 );
6446
6447 assert_eq!(engine.entity_anchors(&id).len(), 2);
6449
6450 let mut span = anchor_input("a.rs", "h-span");
6453 span.grain = Some("span".to_string());
6454 let mut args = anchor_args(id.clone(), Some(hash));
6455 args.anchors = vec![anchor_input("a.rs", "h-file"), span];
6456 engine
6457 .update_entity(args, actor, Some(&client), None)
6458 .expect("two grains on one artifact are two rows");
6459 assert_eq!(engine.entity_anchors(&id).len(), 3);
6460 }
6461
6462 #[test]
6466 fn a_re_pin_without_a_hash_keeps_the_stored_baseline() {
6467 let (mut engine, _tmp, id, hash) = anchored_engine();
6468 let (actor, client) = cli_actor();
6469
6470 let mut hashless = anchor_input("a.rs", "");
6471 hashless.hash = None;
6472 let mut args = anchor_args(id.clone(), Some(hash));
6473 args.anchors = vec![hashless];
6474 engine
6475 .update_entity(args, actor, Some(&client), None)
6476 .unwrap();
6477
6478 let kept = engine
6479 .entity_anchors(&id)
6480 .into_iter()
6481 .find(|a| a.artifact == "a.rs")
6482 .expect("the row is still there");
6483 assert_eq!(
6484 kept.hash.as_deref(),
6485 Some("h-a"),
6486 "the baseline the re-pin did not mention survives it"
6487 );
6488 }
6489
6490 #[test]
6494 fn update_anchor_only_and_unset_only_commit_without_hash_movement() {
6495 let (mut engine, _tmp, id, hash) = anchored_engine();
6496 let (actor, client) = cli_actor();
6497
6498 let mut args = anchor_args(id.clone(), Some(hash.clone()));
6499 args.anchors_unset = vec![anchor_unset("b.rs")];
6500 let out = engine
6501 .update_entity(args, actor, Some(&client), None)
6502 .unwrap();
6503 assert!(
6504 !out.write_id.is_empty(),
6505 "unset-only update commits the sidecar"
6506 );
6507 assert_eq!(out.content_hash, hash, "anchors never move `_hash`");
6508 assert_eq!(engine.entity_anchors(&id).len(), 1);
6509
6510 let err = engine
6513 .update_entity(
6514 anchor_args(id.clone(), Some(hash)),
6515 actor,
6516 Some(&client),
6517 None,
6518 )
6519 .unwrap_err();
6520 assert!(matches!(err, EngineError::EmptyUpdate { .. }));
6521 }
6522
6523 #[test]
6531 fn anchor_only_update_across_second_boundary_never_moves_hash() {
6532 let (mut engine, _tmp, id, hash) = anchored_engine();
6533 let (actor, client) = cli_actor();
6534
6535 let t0 = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_778_243_696);
6536 engine.set_mutation_clock(std::sync::Arc::new(move || t0));
6537 let mut args = anchor_args(id.clone(), Some(hash));
6540 args.metadata = [("level".to_string(), "M1".to_string())]
6541 .into_iter()
6542 .collect();
6543 let restamped = engine
6544 .update_entity(args, actor, Some(&client), None)
6545 .unwrap();
6546
6547 let t1 = t0 + std::time::Duration::from_secs(1);
6549 engine.set_mutation_clock(std::sync::Arc::new(move || t1));
6550 let mut args = anchor_args(id.clone(), Some(restamped.content_hash.clone()));
6551 args.anchors = vec![anchor_input("c.rs", "h-c")];
6552 let out = engine
6553 .update_entity(args, actor, Some(&client), None)
6554 .unwrap();
6555 assert!(!out.write_id.is_empty(), "anchor-only update commits");
6556 assert_eq!(
6557 out.content_hash, restamped.content_hash,
6558 "anchors never move `_hash`, even across a second boundary"
6559 );
6560 let entity = engine.store().get(&id).unwrap();
6562 assert_eq!(
6563 entity
6564 .metadata
6565 .get("last_modified")
6566 .and_then(|v| v.as_str()),
6567 Some("2026-05-08T12:34:56Z"),
6568 "anchor-only update must not restamp last_modified"
6569 );
6570 }
6571
6572 #[test]
6576 fn malformed_anchor_unset_refuses_and_nothing_is_written() {
6577 let (mut engine, tmp, id, hash) = anchored_engine();
6578 let (actor, client) = cli_actor();
6579 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
6580 let before = std::fs::read(&sidecar_path).unwrap();
6581
6582 let mut bad = anchor_unset("a.rs");
6583 bad.grain = Some("paragraph".to_string()); let mut args = anchor_args(id.clone(), Some(hash));
6585 args.anchors_unset = vec![bad];
6586 args.anchors = vec![anchor_input("c.rs", "h-c")];
6588 let err = engine
6589 .update_entity(args, actor, Some(&client), None)
6590 .unwrap_err();
6591 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
6592 assert_eq!(engine.entity_anchors(&id).len(), 2, "no partial apply");
6593 assert_eq!(std::fs::read(&sidecar_path).unwrap(), before);
6594 }
6595
6596 fn bare_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
6600 UpdateEntityArgs {
6601 anchors: Vec::new(),
6602 anchors_unset: Vec::new(),
6603 id,
6604 expected_hash: hash,
6605 sections: IndexMap::new(),
6606 append_sections: IndexMap::new(),
6607 patch_sections: IndexMap::new(),
6608 sections_unset: Vec::new(),
6609 metadata: IndexMap::new(),
6610 metadata_unset: Vec::new(),
6611 declare_relations: Vec::new(),
6612 dry_run: false,
6613 relations_unset: Vec::new(),
6614 }
6615 }
6616
6617 #[test]
6625 fn reserved_key_unset_repairs_smuggled_entity_and_set_stays_refused() {
6626 let tmp = TempDir::new().unwrap();
6627 let mem_dir = tmp.path().to_path_buf();
6628 std::fs::write(
6630 mem_dir.join("smuggled.md"),
6631 "---\ntype: spec\nmem: wrong-mem\nid: bogus-id\n---\n# Smuggled\n\n## Identity\n\nsmuggled identity.\n\n## Purpose\n\nsmuggled purpose.\n",
6632 )
6633 .unwrap();
6634 let writer = FilesystemMemWriter::new(mem_dir.clone());
6635 let mut engine = Engine::from_mounts(vec![(
6636 folder_mount("specs", mem_dir.clone()),
6637 Box::new(writer) as Box<dyn MemBackend>,
6638 )])
6639 .unwrap();
6640 let (actor, client) = cli_actor();
6641 let id = EntityId::new("specs", "smuggled");
6642 let entity = engine.get_entity(&id).expect("fixture boots");
6643 assert!(
6644 entity.metadata.contains_key("mem") && entity.metadata.contains_key("id"),
6645 "fixture must carry the smuggled keys after boot"
6646 );
6647 let hash = entity.content_hash.clone();
6648
6649 for reserved in ["type", "mem", "id"] {
6651 let mut args = bare_args(id.clone(), Some(hash.clone()));
6652 args.metadata
6653 .insert(reserved.to_string(), "resmuggled".to_string());
6654 let err = engine
6655 .update_entity(args, actor, Some(&client), None)
6656 .expect_err("reserved-key set must refuse on update");
6657 assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
6658 }
6659 let mut batch_item = bare_args(id.clone(), Some(hash.clone()));
6662 batch_item
6663 .metadata
6664 .insert("id".to_string(), "resmuggled".to_string());
6665 let batch = engine
6666 .batch_update(vec![(batch_item, None)], actor, Some(&client), false)
6667 .expect("batch returns a result envelope");
6668 assert!(
6669 !batch.applied,
6670 "batch with a reserved-key set must not apply"
6671 );
6672 assert_eq!(batch.failed, 1);
6673
6674 let mut args = bare_args(id.clone(), Some(hash));
6676 args.metadata_unset = vec!["mem".to_string(), "id".to_string()];
6677 let out = engine
6678 .update_entity(args, actor, Some(&client), None)
6679 .expect("reserved-key unset is the sanctioned repair");
6680 assert!(!out.write_id.is_empty(), "repair is a real commit");
6681 assert_eq!(
6682 out.modified_metadata.unset,
6683 vec!["mem".to_string(), "id".to_string()]
6684 );
6685
6686 let entity = engine.get_entity(&id).expect("entity survives repair");
6689 assert!(
6690 !entity.metadata.contains_key("mem") && !entity.metadata.contains_key("id"),
6691 "smuggled keys must be gone from the store"
6692 );
6693 let on_disk = std::fs::read_to_string(mem_dir.join("smuggled.md")).unwrap();
6694 assert!(
6695 !on_disk.contains("wrong-mem") && !on_disk.contains("bogus-id"),
6696 "smuggled keys must be gone from the file: {on_disk}"
6697 );
6698 let mut args = bare_args(id.clone(), Some(entity.content_hash.clone()));
6699 args.sections
6700 .insert("identity".to_string(), "repaired identity".to_string());
6701 engine
6702 .update_entity(args, actor, Some(&client), None)
6703 .expect("post-repair entity round-trips cleanly");
6704 }
6705
6706 #[test]
6714 fn reserved_type_unset_reseeds_and_is_a_noop_on_healthy_entities() {
6715 let tmp = TempDir::new().unwrap();
6716 let mem_dir = tmp.path().to_path_buf();
6717 let writer = FilesystemMemWriter::new(mem_dir.clone());
6718 let mut engine = Engine::from_mounts(vec![(
6719 folder_mount("specs", mem_dir.clone()),
6720 Box::new(writer) as Box<dyn MemBackend>,
6721 )])
6722 .unwrap();
6723 let (actor, client) = cli_actor();
6724 let created = engine
6725 .create_entity(
6726 empty_create_args("specs", "Healthy"),
6727 actor,
6728 Some(&client),
6729 None,
6730 )
6731 .unwrap();
6732 let id = EntityId::new("specs", "healthy");
6733
6734 for key in ["type", "mem", "id"] {
6735 let mut args = bare_args(id.clone(), Some(created.content_hash.clone()));
6736 args.metadata_unset = vec![key.to_string()];
6737 let out = engine
6738 .update_entity(args, actor, Some(&client), None)
6739 .unwrap_or_else(|e| panic!("unset '{key}' on a healthy entity must no-op: {e:?}"));
6740 assert!(
6741 out.write_id.is_empty(),
6742 "unset '{key}' on a healthy entity is a no-op, not a commit"
6743 );
6744 assert!(
6745 out.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
6746 "no-op must carry the UPDATE_NOOP warning for '{key}'"
6747 );
6748 }
6749 let entity = engine.get_entity(&id).unwrap();
6750 assert_eq!(entity.entity_type, "spec");
6751 assert_eq!(
6752 entity.metadata.get("type").and_then(|v| v.as_str()),
6753 Some("spec"),
6754 "the discriminator survives a type unset"
6755 );
6756 }
6757
6758 #[test]
6767 fn declare_relations_refuses_cycle_and_self_loop_like_relate() {
6768 let tmp = TempDir::new().unwrap();
6769 let mem_dir = tmp.path().to_path_buf();
6770 let writer = FilesystemMemWriter::new(mem_dir.clone());
6771 let mut engine = Engine::from_mounts(vec![(
6772 folder_mount("specs", mem_dir),
6773 Box::new(writer) as Box<dyn MemBackend>,
6774 )])
6775 .unwrap();
6776 let (actor, client) = cli_actor();
6777
6778 let alpha = engine
6780 .create_entity(
6781 empty_create_args("specs", "Alpha"),
6782 actor,
6783 Some(&client),
6784 None,
6785 )
6786 .unwrap();
6787 let beta = engine
6788 .create_entity(
6789 empty_create_args("specs", "Beta"),
6790 actor,
6791 Some(&client),
6792 None,
6793 )
6794 .unwrap();
6795 engine
6796 .relate_entity(
6797 crate::engine::RelateEntityArgs {
6798 source: alpha.id.clone(),
6799 target: beta.id.clone(),
6800 rel_type: "PART_OF".to_string(),
6801 remove: false,
6802 expected_hash: None,
6803 description: None,
6804 dry_run: false,
6805 },
6806 actor,
6807 Some(&client),
6808 None,
6809 )
6810 .unwrap();
6811
6812 let declare = |rel_type: &str, from: &EntityId, to: &EntityId, hash: String| {
6813 let mut args = bare_args(from.clone(), Some(hash));
6814 args.declare_relations = vec![crate::ops::RelateArg {
6815 target: to.clone(),
6816 rel_type: rel_type.to_string(),
6817 description: None,
6818 }];
6819 args
6820 };
6821
6822 let err = engine
6824 .update_entity(
6825 declare("PART_OF", &beta.id, &alpha.id, beta.content_hash.clone()),
6826 actor,
6827 Some(&client),
6828 None,
6829 )
6830 .expect_err("cycle-closing declare_relations must refuse");
6831 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
6832 let details = err.details();
6833 assert_eq!(details["rel_type"], "PART_OF");
6834 assert!(details["existing_path"].is_array());
6835 assert!(
6836 engine
6837 .get_entity(&beta.id)
6838 .unwrap()
6839 .relationships
6840 .is_empty(),
6841 "the refused edge must not land"
6842 );
6843
6844 let alpha_hash = engine.get_entity(&alpha.id).unwrap().content_hash.clone();
6847 let err = engine
6848 .update_entity(
6849 declare("USES", &alpha.id, &alpha.id, alpha_hash),
6850 actor,
6851 Some(&client),
6852 None,
6853 )
6854 .expect_err("self-loop declare_relations must refuse");
6855 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
6856
6857 engine
6859 .update_entity(
6860 declare(
6861 "PART_OF",
6862 &beta.id,
6863 &EntityId::new("specs", "gamma"),
6864 beta.content_hash.clone(),
6865 ),
6866 actor,
6867 Some(&client),
6868 None,
6869 )
6870 .expect("a non-cycle PART_OF declare must land as today");
6871 }
6872}