1use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::EntityId;
8use crate::entity::parser::parse_markdown;
9use crate::entity::store_builder::push_entities_into_store;
10use crate::ops::{ModifiedMetadata, ModifiedSections, WarningHint};
11use crate::provenance::{Provenance, ProvenanceKind};
12use crate::runtime_validator::{
13 parse_metadata_value, validate_section_content, validate_section_keys,
14 validate_unsettable_metadata_key, validate_updatable_section, validate_writable_metadata_key,
15};
16use crate::vcs::{Actor, ClientId, CommitContext};
17use crate::workspace::MountCapability;
18
19use super::super::{Engine, EngineError, UpdateEntityArgs, UpdateEntityOutcome};
20use super::{
21 PATCH_OLD_NOT_FOUND_CONTENT_CAP, make_stub, unknown_type_error,
22 validate_relation_target_grammar,
23};
24use crate::engine::outcomes::RelationDeclared;
25use crate::entity::{Entity, Relationship};
26
27use std::sync::Arc;
28
29enum PrepareOutcome {
33 Done(UpdateEntityOutcome),
36 Prepared(PreparedUpdate),
39}
40
41struct PreparedUpdate {
45 mount_idx: usize,
46 id: EntityId,
47 mem: String,
48 type_def: Arc<memstead_schema::TypeDefinition>,
49 file_path: String,
50 markdown: String,
51 prev_body_targets: std::collections::HashSet<EntityId>,
54 modified_date: String,
55 modified_sections: ModifiedSections,
56 modified_metadata: ModifiedMetadata,
57 warnings: Vec<WarningHint>,
58 relations_declared: Vec<RelationDeclared>,
59 anchors: Vec<crate::anchor::Anchor>,
63 anchor_unsets: Vec<crate::anchor::AnchorUnset>,
67 anchor_only: bool,
78}
79
80struct AppliedWrite {
83 content_hash: String,
84 title: String,
85 orphan_stubs_removed: Vec<EntityId>,
86}
87
88impl Engine {
89 pub fn update_entity(
105 &mut self,
106 args: UpdateEntityArgs,
107 actor: Actor,
108 client: Option<&ClientId>,
109 note: Option<&str>,
110 ) -> Result<UpdateEntityOutcome, EngineError> {
111 let mut drift_warnings = self.reload_if_stale(Some(args.id.mem()));
118 if args.declare_relations.iter().any(|r| {
128 self.schemas.get(args.id.mem()).is_some_and(|s| {
129 s.relationship_acyclic(&r.rel_type)
130 || s.acyclic_set_containing(&r.rel_type).is_some()
131 }) || self
132 .schemas
133 .get(r.target.mem())
134 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
135 }) || self
136 .schemas
137 .get(args.id.mem())
138 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
139 {
140 self.ensure_mems_loaded(None);
141 }
142 let mut outcome = match self.prepare_update(args)? {
143 PrepareOutcome::Done(outcome) => outcome,
144 PrepareOutcome::Prepared(prepared) => {
145 self.commit_prepared_update(prepared, actor, client, note)?
146 }
147 };
148 drift_warnings.append(&mut outcome.warnings);
149 outcome.warnings = drift_warnings;
150 Ok(outcome)
151 }
152
153 fn commit_prepared_update(
158 &mut self,
159 prepared: PreparedUpdate,
160 actor: Actor,
161 client: Option<&ClientId>,
162 note: Option<&str>,
163 ) -> Result<UpdateEntityOutcome, EngineError> {
164 let signal_snapshot = {
173 let mut candidates: Vec<EntityId> = vec![prepared.id.clone()];
174 candidates.extend(
175 self.store
176 .outgoing(&prepared.id)
177 .iter()
178 .map(|e| e.target.clone()),
179 );
180 candidates.extend(
181 self.store
182 .incoming(&prepared.id)
183 .iter()
184 .map(|e| e.from.clone()),
185 );
186 if let Ok(parsed) = parse_markdown(
187 &prepared.markdown,
188 &prepared.file_path,
189 prepared.type_def.as_ref(),
190 &prepared.mem,
191 ) {
192 candidates.extend(parsed.entity.relationships.iter().map(|r| r.target.clone()));
193 }
194 crate::ops::signals::snapshot_levels(&self.store, &self.schemas, candidates.iter())
195 };
196 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
197 backend.write_entity(Path::new(&prepared.file_path), prepared.markdown.as_bytes())?;
198 if !prepared.anchors.is_empty() || !prepared.anchor_unsets.is_empty() {
201 super::stage_anchors_sidecar(
202 backend,
203 &prepared.id,
204 &prepared.anchor_unsets,
205 prepared.anchors.clone(),
206 )?;
207 }
208 if let Some(schema) = self.schemas.get(prepared.id.mem()) {
212 for r in prepared
213 .relations_declared
214 .iter()
215 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
216 {
217 let hash = self
218 .store
219 .get(&r.target)
220 .map(|e| e.content_hash.clone())
221 .unwrap_or_default();
222 let (from, rel, to) = (
223 prepared.id.to_string(),
224 r.rel_type.clone(),
225 r.target.to_string(),
226 );
227 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
228 }
229 }
230 let commit_subject = if prepared.anchor_only {
236 format!("memstead: anchor {}", prepared.id)
237 } else {
238 format!("memstead: update {}", prepared.id)
239 };
240 let ctx = CommitContext {
241 actor,
242 client: client.cloned(),
243 tool: Some("update_entity"),
244 note: note.map(String::from),
245 role: self.current_role,
246 logical_operation_id: None,
247 entity_ids: None,
248 };
249 let write_id = backend.commit(&commit_subject, &ctx)?;
250 backend.append_provenance(
251 &Provenance::new(
252 std::time::SystemTime::now(),
253 ProvenanceKind::Update,
254 Some(prepared.id.to_string()),
255 actor,
256 client.cloned(),
257 note.map(String::from),
258 )
259 .with_role(self.current_role),
260 )?;
261 self.record_self_write(prepared.mount_idx, &write_id);
262 let stamp_warnings = self.stamp_mutation_versions(prepared.mount_idx);
263
264 let applied = self.apply_prepared_to_store(&prepared)?;
265
266 self.invalidate_communities();
267 self.maintain_search_indexes(std::slice::from_ref(&prepared.id));
271
272 let mut warnings = prepared.warnings;
276 warnings.extend(stamp_warnings);
277 warnings.extend(crate::ops::signals::crossing_warnings(
280 &self.store,
281 &self.schemas,
282 &signal_snapshot,
283 ));
284 if let Some(w) = self.note_missing_warning("update_entity", note) {
285 warnings.push(w);
286 }
287
288 Ok(UpdateEntityOutcome {
289 id: prepared.id.clone(),
290 title: applied.title,
291 file_path: prepared.file_path,
292 content_hash: applied.content_hash,
293 write_id,
294 modified_date: prepared.modified_date,
295 orphan_stubs_removed: applied.orphan_stubs_removed,
296 modified_sections: prepared.modified_sections,
297 modified_metadata: prepared.modified_metadata,
298 prospective_hash: None,
299 warnings,
300 relations_declared: prepared.relations_declared,
301 })
302 }
303
304 fn apply_prepared_to_store(
311 &mut self,
312 prepared: &PreparedUpdate,
313 ) -> Result<AppliedWrite, EngineError> {
314 let parse_result = parse_markdown(
315 &prepared.markdown,
316 &prepared.file_path,
317 prepared.type_def.as_ref(),
318 &prepared.mem,
319 )
320 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
321 let content_hash = parse_result.entity.content_hash.clone();
322 let title = parse_result.entity.title.clone();
323 let fallback = engine_fallback_type();
324 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
325 crate::entity::store_builder::remap_alias_target_edge_sources(
326 &mut self.store,
327 &self.schemas,
328 );
329 let orphan_stubs_removed =
330 super::gc_orphan_stubs_among(&mut self.store, &prepared.prev_body_targets);
331 Ok(AppliedWrite {
332 content_hash,
333 title,
334 orphan_stubs_removed,
335 })
336 }
337
338 fn prepare_update(&mut self, args: UpdateEntityArgs) -> Result<PrepareOutcome, EngineError> {
346 let id = &args.id;
347 let mem = id.mem().to_string();
348
349 let mount_idx = self
350 .mounts
351 .iter()
352 .position(|m| m.mount.mem == mem)
353 .ok_or_else(|| self.unknown_mem_error(&mem))?;
354 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
355 return Err(EngineError::ReadOnlyMount(mem));
356 }
357
358 let entity = self
359 .store
360 .get(id)
361 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
362
363 let prev_body_targets = super::collect_body_link_targets(entity);
369
370 if entity.stub {
378 return Err(EngineError::StubNotUpdatable { id: id.to_string() });
379 }
380
381 if !args.dry_run
387 && let Some(expected) = args.expected_hash.as_deref()
388 && entity.content_hash != expected
389 {
390 return Err(EngineError::HashMismatch {
391 id: id.to_string(),
392 current: entity.content_hash.clone(),
393 is_stub: entity.stub,
394 });
395 }
396
397 if args.sections.is_empty()
409 && args.append_sections.is_empty()
410 && args.patch_sections.is_empty()
411 && args.metadata.is_empty()
412 && args.metadata_unset.is_empty()
413 && args.declare_relations.is_empty()
414 && args.relations_unset.is_empty()
415 && args.anchors.is_empty()
416 && args.anchors_unset.is_empty()
417 {
418 return Err(EngineError::EmptyUpdate { id: id.to_string() });
419 }
420
421 let validated_anchors = self.validate_anchor_inputs(&mem, &args.anchors)?;
427 let validated_anchor_unsets = Self::validate_anchor_unsets(&args.anchors_unset)?;
428
429 let schema = self
430 .schemas
431 .get(&mem)
432 .expect("schema present for every registered mount")
433 .clone();
434 let type_def = schema
435 .get_type(&entity.entity_type)
436 .ok_or_else(|| unknown_type_error(schema.as_ref(), &entity.entity_type))?;
437
438 for key in args.sections.keys() {
445 let mut modes = vec!["sections".to_string()];
446 if args.append_sections.contains_key(key) {
447 modes.push("append_sections".to_string());
448 }
449 if args.patch_sections.contains_key(key) {
450 modes.push("patch_sections".to_string());
451 }
452 if modes.len() > 1 {
453 return Err(EngineError::ConflictingSectionModes {
454 section: key.clone(),
455 modes,
456 });
457 }
458 }
459 for key in args.append_sections.keys() {
460 if args.patch_sections.contains_key(key) {
461 return Err(EngineError::ConflictingSectionModes {
462 section: key.clone(),
463 modes: vec!["append_sections".to_string(), "patch_sections".to_string()],
464 });
465 }
466 }
467
468 validate_section_keys(
469 args.sections
470 .keys()
471 .chain(args.append_sections.keys())
472 .chain(args.patch_sections.keys())
473 .map(String::as_str),
474 type_def.as_ref(),
475 )?;
476 let mut heading_buf: Vec<&str> = Vec::new();
477 #[allow(unused_assignments)]
478 let mut catch_all = None;
479 validate_section_content(
485 args.sections
486 .iter()
487 .map(|(k, v)| (k.as_str(), v.as_str()))
488 .chain(
489 args.append_sections
490 .iter()
491 .map(|(k, v)| (k.as_str(), v.as_str())),
492 )
493 .chain(
494 args.patch_sections
495 .iter()
496 .map(|(k, p)| (k.as_str(), p.new.as_str())),
497 ),
498 {
499 let t: &memstead_schema::TypeDefinition = type_def.as_ref();
500 catch_all = crate::runtime_validator::catch_all_context(t, &mut heading_buf);
501 catch_all
502 },
503 )?;
504 for key in args.sections.keys() {
505 validate_updatable_section(key.as_str(), type_def.as_ref())?;
506 }
507 for key in args.append_sections.keys() {
508 validate_updatable_section(key.as_str(), type_def.as_ref())?;
509 }
510 for key in args.patch_sections.keys() {
511 validate_updatable_section(key.as_str(), type_def.as_ref())?;
512 }
513 for key in args.metadata.keys() {
514 validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
515 }
516 for key in &args.metadata_unset {
522 validate_unsettable_metadata_key(key.as_str(), type_def.as_ref())?;
523 }
524
525 let mut overlap: Vec<String> = args
532 .metadata
533 .keys()
534 .filter(|k| args.metadata_unset.iter().any(|u| u == k.as_str()))
535 .cloned()
536 .collect();
537 if !overlap.is_empty() {
538 overlap.sort();
539 overlap.dedup();
540 return Err(EngineError::SetAndUnsetConflict { keys: overlap });
541 }
542
543 if !args.relations_unset.is_empty() {
552 let findings = crate::ops::integrity::entity_conformance_findings(
553 &self.store,
554 entity,
555 schema.as_ref(),
556 &self.schemas,
557 );
558 if findings.is_empty() {
559 return Err(EngineError::RepairNotNeeded {
560 id: id.to_string(),
561 recovery: "use memstead_relate(remove=true) to detach an edge from a conformant entity, or the additive memstead_update params to evolve it"
562 .to_string(),
563 });
564 }
565 }
566
567 let mut next = entity.clone();
568
569 for unset in &args.relations_unset {
576 let canonical = crate::entity::id::validate_rel_type(&unset.rel_type)
577 .unwrap_or_else(|_| unset.rel_type.clone());
578 next.relationships
579 .retain(|r| !(r.rel_type == canonical && r.target == unset.target));
580 }
581
582 let relations_declared = apply_declare_relations(
592 self,
593 &mut next,
594 &args.declare_relations,
595 &mem,
596 mount_idx,
597 type_def.as_ref(),
598 schema.as_ref(),
599 )?;
600
601 let format_touched: std::collections::HashSet<String> = args
605 .sections
606 .keys()
607 .chain(args.append_sections.keys())
608 .chain(args.patch_sections.keys())
609 .cloned()
610 .collect();
611
612 let mut modified_sections: Vec<String> = Vec::new();
613 for (key, body) in args.sections {
614 modified_sections.push(key.clone());
615 next.sections.insert(key, body);
616 }
617
618 let mut modified_sections_appended: Vec<String> = Vec::new();
622 for (key, value) in args.append_sections {
623 let existing = next.sections.get(&key).cloned().unwrap_or_default();
624 let new_content = if existing.trim().is_empty() {
625 value
626 } else {
627 format!("{existing}\n{value}")
628 };
629 next.sections.insert(key.clone(), new_content);
630 modified_sections_appended.push(key);
631 }
632
633 let mut modified_sections_patched: Vec<String> = Vec::new();
641 for (key, patch) in args.patch_sections {
642 let existing = next
643 .sections
644 .get(&key)
645 .ok_or_else(|| EngineError::PatchSectionEmpty {
646 section: key.clone(),
647 })?
648 .clone();
649 if !existing.contains(&patch.old) {
650 let cap = PATCH_OLD_NOT_FOUND_CONTENT_CAP;
651 let truncated = existing.len() > cap;
652 let mut cut = cap.min(existing.len());
655 while cut > 0 && !existing.is_char_boundary(cut) {
656 cut -= 1;
657 }
658 let current_content = if truncated {
659 existing[..cut].to_string()
660 } else {
661 existing.clone()
662 };
663 return Err(EngineError::PatchOldNotFound {
664 section: key,
665 current_content,
666 truncated,
667 });
668 }
669 let patched = if patch.all {
670 existing.replace(&patch.old, &patch.new)
671 } else {
672 existing.replacen(&patch.old, &patch.new, 1)
673 };
674 next.sections.insert(key.clone(), patched);
675 modified_sections_patched.push(key);
676 }
677
678 let mut modified_metadata_set: Vec<String> = Vec::new();
679 for (key, value) in &args.metadata {
680 let parsed = parse_metadata_value(key.as_str(), value.as_str(), type_def.as_ref())?;
681 modified_metadata_set.push(key.clone());
682 next.metadata.insert(key.clone(), parsed);
683 }
684
685 let mut modified_metadata_unset: Vec<String> = Vec::new();
686 for key in args.metadata_unset {
687 if crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str()) {
699 if key == "type" {
700 let authoritative =
701 crate::entity::MetadataValue::String(next.entity_type.clone());
702 if next
703 .metadata
704 .shift_remove("type")
705 .is_some_and(|removed| removed != authoritative)
706 {
707 modified_metadata_unset.push(key);
708 }
709 next.metadata.insert("type".to_string(), authoritative);
710 } else if next.metadata.shift_remove(&key).is_some() {
711 modified_metadata_unset.push(key);
712 }
713 continue;
714 }
715 let field_def = type_def.metadata_field(&key);
720 let is_required = field_def.map(|f| f.is_required()).unwrap_or(false);
721 if is_required {
722 let (field_description, enum_values) = match field_def {
723 Some(f) => (
724 Some(f.description.clone()),
725 f.enum_values.clone().unwrap_or_default(),
726 ),
727 None => (None, Vec::new()),
728 };
729 return Err(EngineError::RequiredFieldUnset {
730 field: key,
731 entity_type: type_def.name.clone(),
732 field_description,
733 enum_values,
734 type_write_rules: type_def.write_rules.clone(),
735 on_create: false,
741 missing: Vec::new(),
746 });
747 }
748 if next.metadata.shift_remove(&key).is_some() {
749 modified_metadata_unset.push(key);
750 }
751 }
752
753 let today = self.now_iso();
762
763 let (synthesised_relations, self_link_ignored) =
774 super::synthesise_alias_relations(self, &prev_body_targets, &mut next)?;
775
776 let missing = super::scan_wikilinks_without_relation(&next)?;
782 if !missing.is_empty() {
783 return Err(EngineError::WikiLinkWithoutRelation {
784 from_id: id.to_string(),
785 missing: missing
786 .into_iter()
787 .map(|(section_key, target)| crate::engine::MissingWikiLink {
788 section_key,
789 target_id: target.to_string(),
790 })
791 .collect(),
792 });
793 }
794
795 let file_path = next.file_path.clone();
796
797 let markdown_pre_stamp = super::render_for_write(&next, type_def.as_ref())?;
806
807 let content_unchanged =
818 crate::entity::parser::compute_hash(&markdown_pre_stamp) == next.content_hash;
819
820 if !args.dry_run {
835 if content_unchanged
840 && validated_anchors.is_empty()
841 && validated_anchor_unsets.is_empty()
842 {
843 let modified_date = next
848 .metadata
849 .get("last_modified")
850 .and_then(|v| v.as_str().map(str::to_string))
851 .unwrap_or_default();
852 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
853 id: id.clone(),
854 title: next.title.clone(),
855 file_path,
856 content_hash: next.content_hash.clone(),
857 write_id: String::new(),
858 modified_date,
859 modified_sections: ModifiedSections::default(),
868 modified_metadata: ModifiedMetadata::default(),
869 prospective_hash: None,
870 orphan_stubs_removed: Vec::new(),
873 warnings: vec![WarningHint::UpdateNoop { id: id.clone() }],
874 relations_declared,
875 }));
876 }
877 }
878
879 if !content_unchanged {
889 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
890 }
891 let markdown = super::render_for_write(&next, type_def.as_ref())?;
892
893 let mut warnings: Vec<WarningHint> = Vec::new();
894
895 for key in modified_sections
904 .iter()
905 .chain(modified_sections_appended.iter())
906 .chain(modified_sections_patched.iter())
907 {
908 let Some(def) = type_def.section(key) else {
909 continue;
910 };
911 if let Some(existing) = next.raw_section_headings.iter().find(|h| {
912 h.as_str() != def.heading && memstead_schema::derive_section_key(h) == *key
913 }) {
914 warnings.push(WarningHint::SectionHeadingDivergence {
915 entity_id: id.clone(),
916 section_key: key.clone(),
917 writing_heading: def.heading.clone(),
918 existing_heading: existing.clone(),
919 });
920 }
921 }
922
923 for def in &type_def.sections {
937 if def.format_severity != memstead_schema::ConstraintSeverity::Block {
938 continue;
939 }
940 if !format_touched.contains(def.key.as_str()) {
941 continue;
942 }
943 let Some(body) = next.sections.get(def.key.as_str()) else {
944 continue;
945 };
946 if let Some(first) = crate::section_format::check_section_format(def, body)
947 .into_iter()
948 .next()
949 {
950 return Err(EngineError::SectionFormatRefused {
951 entity_type: next.entity_type.clone(),
952 entity_id: id.to_string(),
953 violation: first,
954 });
955 }
956 }
957
958 let unsatisfied =
959 crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref());
960 if !unsatisfied.is_empty() {
961 let blocked: Vec<_> = unsatisfied
965 .iter()
966 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
967 .cloned()
968 .collect();
969 if !blocked.is_empty() {
970 return Err(EngineError::RequiredOutgoingUnsatisfied {
971 entity_type: next.entity_type.clone(),
972 entity_id: id.to_string(),
973 missing: blocked,
974 });
975 }
976 warnings.push(WarningHint::MissingRequiredOutgoing {
977 entity_type: next.entity_type.clone(),
978 entity_id: id.clone(),
979 missing: unsatisfied,
980 });
981 }
982
983 let violated = crate::ops::health::unsatisfied_constraints(
987 &self.store,
988 &next,
989 type_def.as_ref(),
990 Some(id),
991 );
992 if !violated.is_empty() {
993 let blocked: Vec<_> = violated
994 .iter()
995 .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
996 .cloned()
997 .collect();
998 if !blocked.is_empty() {
999 return Err(EngineError::ConstraintUnsatisfied {
1000 entity_type: next.entity_type.clone(),
1001 entity_id: id.to_string(),
1002 violations: blocked,
1003 });
1004 }
1005 warnings.push(WarningHint::ConstraintUnsatisfied {
1006 entity_type: next.entity_type.clone(),
1007 entity_id: id.clone(),
1008 violations: violated,
1009 });
1010 }
1011
1012 let auto_stubbed: Vec<EntityId> = synthesised_relations
1020 .iter()
1021 .filter_map(|rel| {
1022 if !self.store.contains(&rel.target) {
1023 Some(rel.target.clone())
1024 } else {
1025 None
1026 }
1027 })
1028 .collect();
1029 if !auto_stubbed.is_empty() {
1030 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
1031 from: id.clone(),
1032 stubs: auto_stubbed,
1033 });
1034 }
1035 if self_link_ignored {
1038 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
1039 }
1040
1041 if args.dry_run {
1048 let prospective = crate::entity::parser::compute_hash(&markdown);
1049 let current_hash = next.content_hash.clone();
1053 let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1054 today.clone()
1055 } else {
1056 String::new()
1057 };
1058 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
1059 id: id.clone(),
1060 title: next.title.clone(),
1061 file_path,
1062 content_hash: current_hash,
1063 write_id: String::new(),
1064 modified_date,
1065 modified_sections: ModifiedSections {
1066 replaced: modified_sections,
1067 appended: modified_sections_appended,
1068 patched: modified_sections_patched,
1069 },
1070 modified_metadata: ModifiedMetadata {
1071 set: modified_metadata_set,
1072 unset: modified_metadata_unset,
1073 },
1074 prospective_hash: Some(prospective),
1075 orphan_stubs_removed: Vec::new(),
1078 warnings,
1079 relations_declared: relations_declared.clone(),
1080 }));
1081 }
1082
1083 let modified_date = if content_unchanged {
1090 next.metadata
1093 .get("last_modified")
1094 .and_then(|v| v.as_str().map(str::to_string))
1095 .unwrap_or_default()
1096 } else if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1097 today.clone()
1098 } else {
1099 String::new()
1100 };
1101
1102 Ok(PrepareOutcome::Prepared(PreparedUpdate {
1103 mount_idx,
1104 id: id.clone(),
1105 mem,
1106 type_def,
1107 file_path,
1108 markdown,
1109 prev_body_targets,
1110 modified_date,
1111 modified_sections: ModifiedSections {
1112 replaced: modified_sections,
1113 appended: modified_sections_appended,
1114 patched: modified_sections_patched,
1115 },
1116 modified_metadata: ModifiedMetadata {
1117 set: modified_metadata_set,
1118 unset: modified_metadata_unset,
1119 },
1120 warnings,
1123 relations_declared,
1124 anchor_only: content_unchanged
1132 && (!validated_anchors.is_empty() || !validated_anchor_unsets.is_empty()),
1133 anchors: validated_anchors,
1134 anchor_unsets: validated_anchor_unsets,
1135 }))
1136 }
1137
1138 pub fn batch_update(
1180 &mut self,
1181 updates: Vec<(UpdateEntityArgs, Option<String>)>,
1182 actor: Actor,
1183 client: Option<&ClientId>,
1184 dry_run: bool,
1185 ) -> Result<crate::ops::BatchResult, EngineError> {
1186 if updates.is_empty() {
1187 return Ok(crate::ops::BatchResult {
1188 warnings: Vec::new(),
1189 orphan_stubs_removed: Vec::new(),
1190 errors_suppressed: 0,
1191 applied: true,
1192 results: Vec::new(),
1193 succeeded: 0,
1194 failed: 0,
1195 write_id: String::new(),
1196 });
1197 }
1198
1199 let mut touched_mems: Vec<String> = updates
1206 .iter()
1207 .map(|(a, _)| a.id.mem().to_string())
1208 .collect();
1209 touched_mems.sort();
1210 touched_mems.dedup();
1211 for v in &touched_mems {
1212 self.reload_if_stale(Some(v));
1213 }
1214 if updates.iter().any(|(a, _)| {
1221 a.declare_relations.iter().any(|r| {
1222 self.schemas.get(a.id.mem()).is_some_and(|s| {
1223 s.relationship_acyclic(&r.rel_type)
1224 || s.acyclic_set_containing(&r.rel_type).is_some()
1225 }) || self
1226 .schemas
1227 .get(r.target.mem())
1228 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1229 }) || self
1230 .schemas
1231 .get(a.id.mem())
1232 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1233 }) {
1234 self.ensure_mems_loaded(None);
1235 }
1236
1237 let store_snapshot = self.store.clone();
1243
1244 enum Item {
1250 Prepared,
1251 Noop,
1252 Error,
1253 }
1254 let mut items: Vec<(EntityId, Item)> = Vec::with_capacity(updates.len());
1255 let mut prepared: Vec<PreparedUpdate> = Vec::new();
1256 let mut notes: Vec<Option<String>> = Vec::new();
1257 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1258
1259 for (i, (args, note)) in updates.into_iter().enumerate() {
1264 let id = args.id.clone();
1265 let mut args = args;
1270 args.dry_run = false;
1271 match self.prepare_update(args) {
1272 Ok(PrepareOutcome::Done(_)) => {
1273 items.push((id, Item::Noop));
1275 }
1276 Ok(PrepareOutcome::Prepared(p)) => {
1277 prepared.push(p);
1278 notes.push(note);
1279 items.push((id, Item::Prepared));
1280 }
1281 Err(e) => {
1282 items.push((id, Item::Error));
1283 errors.push((i, e));
1284 }
1285 }
1286 }
1287
1288 if !errors.is_empty() {
1289 self.store = store_snapshot;
1294 self.discard_all_pending();
1295 let failed = errors.len();
1296 let mut error_map: std::collections::HashMap<usize, EngineError> =
1297 errors.into_iter().collect();
1298 let mut reported = 0usize;
1299 let mut suppressed = 0usize;
1300 let results: Vec<crate::ops::BatchEntry> = items
1301 .into_iter()
1302 .enumerate()
1303 .map(|(i, (id, _))| match error_map.remove(&i) {
1304 Some(e) => {
1305 if reported < Self::BATCH_ERROR_REPORT_CAP {
1306 reported += 1;
1307 crate::ops::BatchEntry {
1308 id,
1309 action: "error".to_string(),
1310 error: Some(batch_error_envelope(&e)),
1311 }
1312 } else {
1313 suppressed += 1;
1314 crate::ops::BatchEntry {
1315 id,
1316 action: "error".to_string(),
1317 error: None,
1318 }
1319 }
1320 }
1321 None => crate::ops::BatchEntry {
1322 id,
1323 action: "not_applied".to_string(),
1324 error: None,
1325 },
1326 })
1327 .collect();
1328 return Ok(crate::ops::BatchResult {
1329 warnings: Vec::new(),
1330 orphan_stubs_removed: Vec::new(),
1331 errors_suppressed: suppressed,
1332 applied: false,
1333 results,
1334 succeeded: 0,
1335 failed,
1336 write_id: String::new(),
1337 });
1338 }
1339
1340 if dry_run {
1346 self.store = store_snapshot;
1347 self.discard_all_pending();
1348 let succeeded = items.len();
1349 let results: Vec<crate::ops::BatchEntry> = items
1350 .into_iter()
1351 .map(|(id, item)| crate::ops::BatchEntry {
1352 id,
1353 action: match item {
1354 Item::Prepared => "updated".to_string(),
1355 Item::Noop => "noop".to_string(),
1356 Item::Error => unreachable!("refusal path returned above"),
1357 },
1358 error: None,
1359 })
1360 .collect();
1361 return Ok(crate::ops::BatchResult {
1362 warnings: Vec::new(),
1363 orphan_stubs_removed: Vec::new(),
1364 errors_suppressed: 0,
1365 applied: true,
1366 results,
1367 succeeded,
1368 failed: 0,
1369 write_id: String::new(),
1370 });
1371 }
1372
1373 for p in &prepared {
1376 if let Err(e) = self.mounts[p.mount_idx]
1377 .backend
1378 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1379 {
1380 self.store = store_snapshot;
1381 self.discard_all_pending();
1382 return Err(e.into());
1383 }
1384 if (!p.anchors.is_empty() || !p.anchor_unsets.is_empty())
1387 && let Err(e) = super::stage_anchors_sidecar(
1388 self.mounts[p.mount_idx].backend.as_ref(),
1389 &p.id,
1390 &p.anchor_unsets,
1391 p.anchors.clone(),
1392 )
1393 {
1394 self.store = store_snapshot;
1395 self.discard_all_pending();
1396 return Err(e);
1397 }
1398 if let Some(schema) = self.schemas.get(p.id.mem()) {
1401 for r in p
1402 .relations_declared
1403 .iter()
1404 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1405 {
1406 let hash = self
1407 .store
1408 .get(&r.target)
1409 .map(|e| e.content_hash.clone())
1410 .unwrap_or_default();
1411 let (from, rel, to) =
1412 (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1413 if let Err(e) = super::stage_derivation_sidecar(
1414 self.mounts[p.mount_idx].backend.as_ref(),
1415 |s| s.set(&from, &rel, &to, &hash),
1416 ) {
1417 self.store = store_snapshot;
1418 self.discard_all_pending();
1419 return Err(e);
1420 }
1421 }
1422 }
1423 }
1424
1425 let mut distinct_mounts: Vec<usize> = Vec::new();
1427 for p in &prepared {
1428 if !distinct_mounts.contains(&p.mount_idx) {
1429 distinct_mounts.push(p.mount_idx);
1430 }
1431 }
1432 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1433 for &m in &distinct_mounts {
1434 let entity_ids: Vec<String> = prepared
1435 .iter()
1436 .filter(|p| p.mount_idx == m)
1437 .map(|p| p.id.to_string())
1438 .collect();
1439 let count = entity_ids.len();
1440 let subject = format!("memstead: batch-update ({count} entities)");
1441 let note_lines: Vec<String> = prepared
1446 .iter()
1447 .zip(notes.iter())
1448 .filter(|(p, _)| p.mount_idx == m)
1449 .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.id)))
1450 .collect();
1451 let ctx = CommitContext {
1452 actor,
1453 client: client.cloned(),
1454 tool: Some("batch_update"),
1455 note: if note_lines.is_empty() {
1456 None
1457 } else {
1458 Some(note_lines.join("\n"))
1459 },
1460 role: self.current_role,
1461 logical_operation_id: None,
1462 entity_ids: Some(entity_ids),
1466 };
1467 match self.mounts[m].backend.commit(&subject, &ctx) {
1468 Ok(sha) => mount_commits.push((m, sha)),
1469 Err(e) => {
1470 self.store = store_snapshot;
1474 self.discard_all_pending();
1475 return Err(e.into());
1476 }
1477 }
1478 }
1479
1480 let mut batch_warnings: Vec<WarningHint> = Vec::new();
1484 for (p, note) in prepared.iter().zip(notes.iter()) {
1485 let write_id = mount_commits
1486 .iter()
1487 .find(|(m, _)| *m == p.mount_idx)
1488 .map(|(_, s)| s.clone())
1489 .unwrap_or_default();
1490 self.mounts[p.mount_idx].backend.append_provenance(
1491 &Provenance::new(
1492 std::time::SystemTime::now(),
1493 ProvenanceKind::Update,
1494 Some(p.id.to_string()),
1495 actor,
1496 client.cloned(),
1497 note.clone(),
1498 )
1499 .with_role(self.current_role),
1500 )?;
1501 self.record_self_write(p.mount_idx, &write_id);
1502 batch_warnings.extend(self.stamp_mutation_versions(p.mount_idx));
1503 self.apply_prepared_to_store(p)?;
1504 }
1505
1506 self.invalidate_communities();
1507 self.invalidate_search_indexes();
1508
1509 let write_id = mount_commits
1512 .last()
1513 .map(|(_, s)| s.clone())
1514 .unwrap_or_default();
1515 let succeeded = items.len();
1516 let results: Vec<crate::ops::BatchEntry> = items
1517 .into_iter()
1518 .map(|(id, item)| crate::ops::BatchEntry {
1519 id,
1520 action: match item {
1521 Item::Prepared => "updated".to_string(),
1522 Item::Noop => "noop".to_string(),
1523 Item::Error => unreachable!("refusal path returned above"),
1524 },
1525 error: None,
1526 })
1527 .collect();
1528
1529 Ok(crate::ops::BatchResult {
1530 warnings: batch_warnings,
1531 orphan_stubs_removed: Vec::new(),
1532 errors_suppressed: 0,
1533 applied: true,
1534 results,
1535 succeeded,
1536 failed: 0,
1537 write_id,
1538 })
1539 }
1540
1541 pub(super) fn discard_all_pending(&self) {
1546 for mount in &self.mounts {
1547 let _ = mount.backend.discard_pending();
1548 }
1549 }
1550
1551 pub fn update_entity_with_ctx(
1554 &mut self,
1555 args: UpdateEntityArgs,
1556 ctx: &CommitContext<'_>,
1557 ) -> Result<UpdateEntityOutcome, EngineError> {
1558 self.update_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1559 }
1560}
1561
1562pub(super) fn batch_error_envelope(err: &EngineError) -> crate::ops::BatchError {
1569 let code = err.code().to_string();
1575 let message = err.to_string();
1576 let details = err.details();
1577 crate::ops::BatchError {
1578 code,
1579 message,
1580 details,
1581 }
1582}
1583
1584fn apply_declare_relations(
1599 engine: &mut Engine,
1600 next: &mut Entity,
1601 declarations: &[crate::ops::RelateArg],
1602 source_mem: &str,
1603 source_mount_idx: usize,
1604 type_def: &memstead_schema::TypeDefinition,
1605 schema: &memstead_schema::Schema,
1606) -> Result<Vec<RelationDeclared>, EngineError> {
1607 let _ = type_def; let _ = source_mount_idx; let mut declared: Vec<RelationDeclared> = Vec::with_capacity(declarations.len());
1610 for rel in declarations {
1611 let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
1614 .unwrap_or_else(|_| rel.rel_type.clone());
1615
1616 validate_relation_target_grammar(&rel.target)?;
1617
1618 let target_mem = rel.target.mem().to_string();
1619 super::validate_cross_mem_add_policy(engine, source_mem, &rel.target)?;
1622
1623 let target_type = engine
1632 .store
1633 .get(&rel.target)
1634 .map(|e| e.entity_type.clone())
1635 .filter(|t| !t.is_empty());
1636 let target_type = match target_type {
1639 Some(t) => Some(t),
1640 None => super::peek_deferred_target_type(engine, &rel.target)?,
1641 };
1642 let _ = super::route_edge_validation(
1643 engine,
1644 &canonical,
1645 next.entity_type.as_str(),
1646 target_type.as_deref(),
1647 source_mem,
1648 &target_mem,
1649 &next.id,
1650 &rel.target,
1651 true,
1652 )?;
1653
1654 let normalised_description =
1659 crate::entity::normalise_description(rel.description.as_deref());
1660 super::validate_description_posture(
1661 engine,
1662 &canonical,
1663 normalised_description.as_deref(),
1664 source_mem,
1665 &target_mem,
1666 &next.id,
1667 &rel.target,
1668 )?;
1669 super::validate_manual_authoring_posture(
1672 engine,
1673 &canonical,
1674 source_mem,
1675 &next.id,
1676 &rel.target,
1677 )?;
1678
1679 super::validate_edge_acyclicity(
1683 &engine.store,
1684 schema,
1685 &next.id,
1686 next.entity_type.as_str(),
1687 &rel.target,
1688 &canonical,
1689 )?;
1690
1691 let exists = next
1696 .relationships
1697 .iter()
1698 .any(|r| r.rel_type == canonical && r.target == rel.target);
1699 if !exists {
1700 next.relationships.push(Relationship {
1701 rel_type: canonical.clone(),
1702 target: rel.target.clone(),
1703 description: normalised_description,
1704 });
1705 }
1706
1707 let target_was_stubbed = !engine.store.contains(&rel.target);
1712 if target_was_stubbed && !exists {
1713 let kind = super::deferred_verified_stub_kind(engine, &rel.target)?;
1714 engine
1715 .store
1716 .upsert(rel.target.clone(), make_stub(&rel.target, kind));
1717 }
1718
1719 declared.push(RelationDeclared {
1720 rel_type: canonical,
1721 target: rel.target.clone(),
1722 target_was_stubbed,
1723 });
1724 }
1725 Ok(declared)
1726}
1727
1728#[cfg(test)]
1729mod tests {
1730
1731 use indexmap::IndexMap;
1732 use tempfile::TempDir;
1733
1734 use crate::backend::MemBackend;
1735 use crate::engine::test_helpers::*;
1736 use crate::engine::{
1737 CreateEntityArgs, Engine, EngineError, RelateEntityArgs, UpdateEntityArgs,
1738 };
1739 use crate::entity::EntityId;
1740
1741 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1742 use crate::vcs::Actor;
1743
1744 #[test]
1750 fn update_warns_on_section_heading_divergence_and_still_commits() {
1751 let tmp = TempDir::new().unwrap();
1752 let mem_dir = tmp.path().to_path_buf();
1753 std::fs::write(
1756 mem_dir.join("diverged.md"),
1757 "---\ntype: spec\n---\n# Diverged\n\n## IDENTITY\n\nold text.\n",
1758 )
1759 .unwrap();
1760 let writer = FilesystemMemWriter::new(mem_dir.clone());
1761 let mut engine = Engine::from_mounts(vec![(
1762 folder_mount("specs", mem_dir),
1763 Box::new(writer) as Box<dyn MemBackend>,
1764 )])
1765 .unwrap();
1766 let (actor, client) = cli_actor();
1767 let id = EntityId::new("specs", "diverged");
1768
1769 let update_identity = |engine: &mut Engine, body: &str| {
1770 let current = engine.get_entity(&id).unwrap().content_hash.clone();
1771 let mut sections = IndexMap::new();
1772 sections.insert("identity".to_string(), body.to_string());
1773 engine
1774 .update_entity(
1775 UpdateEntityArgs {
1776 anchors: Vec::new(),
1777 id: id.clone(),
1778 expected_hash: Some(current),
1779 sections,
1780 append_sections: IndexMap::new(),
1781 patch_sections: IndexMap::new(),
1782 metadata: IndexMap::new(),
1783 metadata_unset: Vec::new(),
1784 declare_relations: Vec::new(),
1785 dry_run: false,
1786 relations_unset: Vec::new(),
1787 anchors_unset: Vec::new(),
1788 },
1789 actor,
1790 Some(&client),
1791 None,
1792 )
1793 .unwrap()
1794 };
1795
1796 let outcome = update_identity(&mut engine, "new text.");
1797 assert!(!outcome.write_id.is_empty(), "the mutation still commits");
1798 let divergences: Vec<_> = outcome
1799 .warnings
1800 .iter()
1801 .filter_map(|w| match w {
1802 crate::ops::WarningHint::SectionHeadingDivergence {
1803 section_key,
1804 writing_heading,
1805 existing_heading,
1806 ..
1807 } => Some((
1808 section_key.clone(),
1809 writing_heading.clone(),
1810 existing_heading.clone(),
1811 )),
1812 _ => None,
1813 })
1814 .collect();
1815 assert_eq!(
1816 divergences,
1817 vec![(
1818 "identity".to_string(),
1819 "Identity".to_string(),
1820 "IDENTITY".to_string()
1821 )],
1822 "warning names both headings; all warnings = {:?}",
1823 outcome.warnings
1824 );
1825
1826 let outcome2 = update_identity(&mut engine, "third text.");
1829 assert!(
1830 !outcome2
1831 .warnings
1832 .iter()
1833 .any(|w| matches!(w, crate::ops::WarningHint::SectionHeadingDivergence { .. })),
1834 "matching heading emits no divergence warning: {:?}",
1835 outcome2.warnings
1836 );
1837 }
1838
1839 #[test]
1840 fn batch_update_empty_batch_returns_zero_counts() {
1841 let tmp = TempDir::new().unwrap();
1844 let mem_dir = tmp.path().to_path_buf();
1845 let writer = FilesystemMemWriter::new(mem_dir.clone());
1846 let mut engine = Engine::from_mounts(vec![(
1847 folder_mount("specs", mem_dir),
1848 Box::new(writer) as Box<dyn MemBackend>,
1849 )])
1850 .unwrap();
1851
1852 let result = engine
1853 .batch_update(Vec::new(), Actor::Cli, None, false)
1854 .unwrap();
1855 assert!(result.applied, "empty batch is a vacuous success");
1856 assert_eq!(result.results.len(), 0);
1857 assert_eq!(result.succeeded, 0);
1858 assert_eq!(result.failed, 0);
1859 assert_eq!(result.write_id, "");
1860 }
1861
1862 #[test]
1863 fn batch_update_refuses_whole_batch_when_one_item_fails() {
1864 let tmp = TempDir::new().unwrap();
1871 let mem_dir = tmp.path().to_path_buf();
1872 let writer = FilesystemMemWriter::new(mem_dir.clone());
1873 let mut engine = Engine::from_mounts(vec![(
1874 folder_mount("specs", mem_dir),
1875 Box::new(writer) as Box<dyn MemBackend>,
1876 )])
1877 .unwrap();
1878
1879 let create_args = CreateEntityArgs {
1881 anchors: Vec::new(),
1882 mem: "specs".to_string(),
1883 title: "Seed".to_string(),
1884 entity_type: "spec".to_string(),
1885 sections: IndexMap::from_iter([
1886 ("identity".to_string(), "seed identity".to_string()),
1887 ("purpose".to_string(), "seed purpose".to_string()),
1888 ]),
1889 metadata: IndexMap::new(),
1890 relations: Vec::new(),
1891 dry_run: false,
1892 };
1893 let created = engine
1894 .create_entity(create_args, Actor::Cli, None, None)
1895 .unwrap();
1896
1897 let valid_update = UpdateEntityArgs {
1899 anchors: Vec::new(),
1900 id: created.id.clone(),
1901 expected_hash: Some(created.content_hash.clone()),
1902 sections: IndexMap::from_iter([("identity".to_string(), "updated body".to_string())]),
1903 append_sections: IndexMap::new(),
1904 patch_sections: IndexMap::new(),
1905 metadata: IndexMap::new(),
1906 metadata_unset: Vec::new(),
1907 declare_relations: Vec::new(),
1908 dry_run: false,
1909 relations_unset: Vec::new(),
1910 anchors_unset: Vec::new(),
1911 };
1912 let missing_update = UpdateEntityArgs {
1913 anchors: Vec::new(),
1914 id: EntityId("specs--nonexistent".to_string()),
1915 expected_hash: None,
1916 sections: IndexMap::new(),
1917 append_sections: IndexMap::new(),
1918 patch_sections: IndexMap::new(),
1919 metadata: IndexMap::new(),
1920 metadata_unset: Vec::new(),
1921 declare_relations: Vec::new(),
1922 dry_run: false,
1923 relations_unset: Vec::new(),
1924 anchors_unset: Vec::new(),
1925 };
1926
1927 let result = engine
1928 .batch_update(
1929 vec![(valid_update, None), (missing_update, None)],
1930 Actor::Cli,
1931 None,
1932 false,
1933 )
1934 .unwrap();
1935 assert!(!result.applied, "a failing item must refuse the batch");
1937 assert_eq!(result.results.len(), 2);
1938 assert_eq!(result.succeeded, 0);
1939 assert_eq!(result.failed, 1);
1940 assert_eq!(result.write_id, "", "refused batch must not commit");
1941 assert_eq!(result.results[0].action, "not_applied");
1944 assert!(result.results[0].error.is_none());
1945 assert_eq!(result.results[1].action, "error");
1947 let err = result.results[1]
1948 .error
1949 .as_ref()
1950 .expect("failed entry must carry a structured error envelope");
1951 assert_eq!(err.code, "ENTITY_NOT_FOUND");
1952 assert!(err.message.contains("not found"), "got: {}", err.message);
1953
1954 let seed = engine.get_entity(&created.id).unwrap();
1957 assert_eq!(
1958 seed.sections.get("identity").map(String::as_str),
1959 Some("seed identity"),
1960 "refused batch must leave the in-memory store untouched",
1961 );
1962 assert_eq!(
1963 seed.content_hash, created.content_hash,
1964 "refused batch must not change the entity's content hash",
1965 );
1966 }
1967
1968 #[test]
1969 fn batch_update_applies_all_valid_items_as_one_commit() {
1970 let tmp = TempDir::new().unwrap();
1974 let mem_dir = tmp.path().to_path_buf();
1975 let writer = FilesystemMemWriter::new(mem_dir.clone());
1976 let mut engine = Engine::from_mounts(vec![(
1977 folder_mount("specs", mem_dir),
1978 Box::new(writer) as Box<dyn MemBackend>,
1979 )])
1980 .unwrap();
1981
1982 let mk = |title: &str| CreateEntityArgs {
1983 anchors: Vec::new(),
1984 mem: "specs".to_string(),
1985 title: title.to_string(),
1986 entity_type: "spec".to_string(),
1987 sections: IndexMap::from_iter([
1988 ("identity".to_string(), "id".to_string()),
1989 ("purpose".to_string(), "purp".to_string()),
1990 ]),
1991 metadata: IndexMap::new(),
1992 relations: Vec::new(),
1993 dry_run: false,
1994 };
1995 let a = engine
1996 .create_entity(mk("A"), Actor::Cli, None, None)
1997 .unwrap();
1998 let b = engine
1999 .create_entity(mk("B"), Actor::Cli, None, None)
2000 .unwrap();
2001
2002 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
2003 anchors: Vec::new(),
2004 id,
2005 expected_hash: Some(hash),
2006 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
2007 append_sections: IndexMap::new(),
2008 patch_sections: IndexMap::new(),
2009 metadata: IndexMap::new(),
2010 metadata_unset: Vec::new(),
2011 declare_relations: Vec::new(),
2012 dry_run: false,
2013 relations_unset: Vec::new(),
2014 anchors_unset: Vec::new(),
2015 };
2016
2017 let result = engine
2018 .batch_update(
2019 vec![
2020 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
2021 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
2022 ],
2023 Actor::Cli,
2024 None,
2025 false,
2026 )
2027 .unwrap();
2028 assert!(result.applied);
2029 assert_eq!(result.succeeded, 2);
2030 assert_eq!(result.failed, 0);
2031 assert!(
2032 !result.write_id.is_empty(),
2033 "applied batch carries the commit"
2034 );
2035 assert!(result.results.iter().all(|e| e.action == "updated"));
2036 assert_eq!(
2038 engine
2039 .get_entity(&a.id)
2040 .unwrap()
2041 .sections
2042 .get("identity")
2043 .map(String::as_str),
2044 Some("A body"),
2045 );
2046 assert_eq!(
2047 engine
2048 .get_entity(&b.id)
2049 .unwrap()
2050 .sections
2051 .get("identity")
2052 .map(String::as_str),
2053 Some("B body"),
2054 );
2055 }
2056
2057 #[test]
2066 fn batch_update_dry_run_reports_receipt_and_writes_nothing() {
2067 let tmp = TempDir::new().unwrap();
2068 let mem_dir = tmp.path().to_path_buf();
2069 let writer = FilesystemMemWriter::new(mem_dir.clone());
2070 let mut engine = Engine::from_mounts(vec![(
2071 folder_mount("specs", mem_dir),
2072 Box::new(writer) as Box<dyn MemBackend>,
2073 )])
2074 .unwrap();
2075
2076 let mk = |title: &str| CreateEntityArgs {
2077 anchors: Vec::new(),
2078 mem: "specs".to_string(),
2079 title: title.to_string(),
2080 entity_type: "spec".to_string(),
2081 sections: IndexMap::from_iter([
2082 ("identity".to_string(), "id".to_string()),
2083 ("purpose".to_string(), "purp".to_string()),
2084 ]),
2085 metadata: IndexMap::new(),
2086 relations: Vec::new(),
2087 dry_run: false,
2088 };
2089 let a = engine
2090 .create_entity(mk("A"), Actor::Cli, None, None)
2091 .unwrap();
2092 let b = engine
2093 .create_entity(mk("B"), Actor::Cli, None, None)
2094 .unwrap();
2095
2096 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
2097 anchors: Vec::new(),
2098 id,
2099 expected_hash: Some(hash),
2100 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
2101 append_sections: IndexMap::new(),
2102 patch_sections: IndexMap::new(),
2103 metadata: IndexMap::new(),
2104 metadata_unset: Vec::new(),
2105 declare_relations: Vec::new(),
2106 dry_run: false,
2107 relations_unset: Vec::new(),
2108 anchors_unset: Vec::new(),
2109 };
2110 let batch = || {
2111 vec![
2112 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
2113 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
2114 ]
2115 };
2116
2117 let rehearsed = engine
2118 .batch_update(batch(), Actor::Cli, None, true)
2119 .unwrap();
2120 assert!(rehearsed.applied, "{rehearsed:?}");
2121 assert_eq!(rehearsed.succeeded, 2);
2122 assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
2123 assert!(rehearsed.results.iter().all(|e| e.action == "updated"));
2124 let a_now = engine.get_entity(&a.id).unwrap();
2126 assert_eq!(
2127 a_now.sections.get("identity").map(String::as_str),
2128 Some("id")
2129 );
2130 assert_eq!(a_now.content_hash, a.content_hash);
2131
2132 let real = engine
2134 .batch_update(batch(), Actor::Cli, None, false)
2135 .unwrap();
2136 assert!(real.applied, "{real:?}");
2137 assert!(!real.write_id.is_empty());
2138 assert_eq!(
2139 engine
2140 .get_entity(&a.id)
2141 .unwrap()
2142 .sections
2143 .get("identity")
2144 .map(String::as_str),
2145 Some("A body"),
2146 );
2147 }
2148
2149 #[test]
2153 fn batch_update_dry_run_refuses_identically_to_real() {
2154 let tmp = TempDir::new().unwrap();
2155 let mem_dir = tmp.path().to_path_buf();
2156 let writer = FilesystemMemWriter::new(mem_dir.clone());
2157 let mut engine = Engine::from_mounts(vec![(
2158 folder_mount("specs", mem_dir),
2159 Box::new(writer) as Box<dyn MemBackend>,
2160 )])
2161 .unwrap();
2162 let created = engine
2163 .create_entity(
2164 CreateEntityArgs {
2165 anchors: Vec::new(),
2166 mem: "specs".to_string(),
2167 title: "Valid".to_string(),
2168 entity_type: "spec".to_string(),
2169 sections: IndexMap::from_iter([
2170 ("identity".to_string(), "x".to_string()),
2171 ("purpose".to_string(), "p".to_string()),
2172 ]),
2173 metadata: IndexMap::new(),
2174 relations: Vec::new(),
2175 dry_run: false,
2176 },
2177 Actor::Cli,
2178 None,
2179 None,
2180 )
2181 .unwrap();
2182 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2183 anchors: Vec::new(),
2184 id,
2185 expected_hash: hash,
2186 sections: IndexMap::from_iter([("identity".to_string(), "new".to_string())]),
2187 append_sections: IndexMap::new(),
2188 patch_sections: IndexMap::new(),
2189 metadata: IndexMap::new(),
2190 metadata_unset: Vec::new(),
2191 declare_relations: Vec::new(),
2192 dry_run: false,
2193 relations_unset: Vec::new(),
2194 anchors_unset: Vec::new(),
2195 };
2196 let batch = || {
2197 vec![
2198 (
2199 upd(created.id.clone(), Some("wrong-hash".to_string())),
2200 None,
2201 ),
2202 (upd(EntityId("specs--missing".to_string()), None), None),
2203 ]
2204 };
2205
2206 let rehearsed = engine
2207 .batch_update(batch(), Actor::Cli, None, true)
2208 .unwrap();
2209 let real = engine
2210 .batch_update(batch(), Actor::Cli, None, false)
2211 .unwrap();
2212 assert!(!rehearsed.applied && !real.applied);
2213 let envelope = |r: &crate::ops::BatchResult| {
2214 r.results
2215 .iter()
2216 .map(|e| {
2217 (
2218 e.id.to_string(),
2219 e.action.clone(),
2220 e.error.as_ref().map(|err| {
2221 (err.code.clone(), err.message.clone(), err.details.clone())
2222 }),
2223 )
2224 })
2225 .collect::<Vec<_>>()
2226 };
2227 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
2228 assert_eq!(
2230 engine
2231 .get_entity(&created.id)
2232 .unwrap()
2233 .sections
2234 .get("identity")
2235 .map(String::as_str),
2236 Some("x"),
2237 );
2238 }
2239
2240 #[test]
2244 fn batch_update_reports_every_failing_item() {
2245 let tmp = TempDir::new().unwrap();
2246 let mem_dir = tmp.path().to_path_buf();
2247 let writer = FilesystemMemWriter::new(mem_dir.clone());
2248 let mut engine = Engine::from_mounts(vec![(
2249 folder_mount("specs", mem_dir),
2250 Box::new(writer) as Box<dyn MemBackend>,
2251 )])
2252 .unwrap();
2253 let created = engine
2254 .create_entity(
2255 CreateEntityArgs {
2256 anchors: Vec::new(),
2257 mem: "specs".to_string(),
2258 title: "Seed".to_string(),
2259 entity_type: "spec".to_string(),
2260 sections: IndexMap::from_iter([
2261 ("identity".to_string(), "seed identity".to_string()),
2262 ("purpose".to_string(), "seed purpose".to_string()),
2263 ]),
2264 metadata: IndexMap::new(),
2265 relations: Vec::new(),
2266 dry_run: false,
2267 },
2268 Actor::Cli,
2269 None,
2270 None,
2271 )
2272 .unwrap();
2273
2274 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2275 anchors: Vec::new(),
2276 id,
2277 expected_hash: hash,
2278 sections: IndexMap::from_iter([("identity".to_string(), "new body".to_string())]),
2279 append_sections: IndexMap::new(),
2280 patch_sections: IndexMap::new(),
2281 metadata: IndexMap::new(),
2282 metadata_unset: Vec::new(),
2283 declare_relations: Vec::new(),
2284 dry_run: false,
2285 relations_unset: Vec::new(),
2286 anchors_unset: Vec::new(),
2287 };
2288 let result = engine
2289 .batch_update(
2290 vec![
2291 (upd(created.id.clone(), None), None),
2292 (upd(EntityId("specs--missing-one".to_string()), None), None),
2293 (upd(EntityId("specs--missing-two".to_string()), None), None),
2294 ],
2295 Actor::Cli,
2296 None,
2297 false,
2298 )
2299 .unwrap();
2300 assert!(!result.applied);
2301 assert_eq!(result.failed, 2, "{result:?}");
2302 assert_eq!(result.write_id, "");
2303 let codes: Vec<(usize, &str)> = result
2304 .results
2305 .iter()
2306 .enumerate()
2307 .filter(|(_, r)| r.action == "error")
2308 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
2309 .collect();
2310 assert_eq!(
2311 codes,
2312 vec![(1, "ENTITY_NOT_FOUND"), (2, "ENTITY_NOT_FOUND")],
2313 "BOTH failing items named, not just the first: {result:?}"
2314 );
2315 assert_eq!(result.results[0].action, "not_applied");
2316 assert_eq!(
2318 engine
2319 .get_entity(&created.id)
2320 .unwrap()
2321 .sections
2322 .get("identity")
2323 .map(String::as_str),
2324 Some("seed identity"),
2325 );
2326 }
2327
2328 #[test]
2329 fn batch_update_rolls_back_in_memory_store_auto_stub_on_refusal() {
2330 let tmp = TempDir::new().unwrap();
2338 let mem_dir = tmp.path().to_path_buf();
2339 let writer = FilesystemMemWriter::new(mem_dir.clone());
2340 let mut engine = Engine::from_mounts(vec![(
2341 folder_mount("specs", mem_dir.clone()),
2342 Box::new(writer) as Box<dyn MemBackend>,
2343 )])
2344 .unwrap();
2345 engine.set_workspace_root(mem_dir);
2346 let (actor, client) = cli_actor();
2347
2348 let a = engine
2349 .create_entity(
2350 empty_create_args("specs", "Anchor"),
2351 actor,
2352 Some(&client),
2353 None,
2354 )
2355 .unwrap();
2356
2357 let stub_target = EntityId::new("specs", "would-be-stub");
2358 let item1 = UpdateEntityArgs {
2359 anchors: Vec::new(),
2360 relations_unset: Vec::new(),
2361 anchors_unset: Vec::new(),
2362 id: a.id.clone(),
2363 expected_hash: Some(a.content_hash.clone()),
2364 sections: IndexMap::new(),
2365 append_sections: IndexMap::new(),
2366 patch_sections: IndexMap::new(),
2367 metadata: IndexMap::new(),
2368 metadata_unset: Vec::new(),
2369 declare_relations: vec![crate::ops::RelateArg {
2370 rel_type: "USES".to_string(),
2371 target: stub_target.clone(),
2372 description: None,
2373 }],
2374 dry_run: false,
2375 };
2376 let item2 = UpdateEntityArgs {
2377 anchors: Vec::new(),
2378 id: EntityId::new("specs", "nonexistent"),
2379 expected_hash: None,
2380 sections: IndexMap::from_iter([("identity".to_string(), "x".to_string())]),
2381 append_sections: IndexMap::new(),
2382 patch_sections: IndexMap::new(),
2383 metadata: IndexMap::new(),
2384 metadata_unset: Vec::new(),
2385 declare_relations: Vec::new(),
2386 dry_run: false,
2387 relations_unset: Vec::new(),
2388 anchors_unset: Vec::new(),
2389 };
2390
2391 assert!(engine.get_entity(&stub_target).is_none());
2393
2394 let result = engine
2395 .batch_update(
2396 vec![(item1, None), (item2, None)],
2397 actor,
2398 Some(&client),
2399 false,
2400 )
2401 .unwrap();
2402 assert!(!result.applied, "missing item 2 must refuse the batch");
2403
2404 assert!(
2407 engine.get_entity(&stub_target).is_none(),
2408 "refused batch must roll the in-memory auto-stub back out of the store",
2409 );
2410 let anchor = engine.get_entity(&a.id).unwrap();
2412 assert!(
2413 !anchor.relationships.iter().any(|r| r.target == stub_target),
2414 "refused batch must not leave the declared relation on the anchor",
2415 );
2416 }
2417
2418 #[test]
2419 fn update_entity_replaces_a_section_and_logs_provenance() {
2420 let tmp = TempDir::new().unwrap();
2421 let (mut engine, seeded) = engine_with_seed(&tmp, "Updatable");
2422 let (actor, client) = cli_actor();
2423
2424 let mut sections = IndexMap::new();
2425 sections.insert("identity".to_string(), "Updated body.".to_string());
2426
2427 let outcome = engine
2428 .update_entity(
2429 UpdateEntityArgs {
2430 anchors: Vec::new(),
2431 id: seeded.id.clone(),
2432 expected_hash: Some(seeded.content_hash.clone()),
2433 sections,
2434 append_sections: IndexMap::new(),
2435 patch_sections: IndexMap::new(),
2436 metadata: IndexMap::new(),
2437 metadata_unset: Vec::new(),
2438 declare_relations: Vec::new(),
2439 dry_run: false,
2440 relations_unset: Vec::new(),
2441 anchors_unset: Vec::new(),
2442 },
2443 actor,
2444 Some(&client),
2445 Some("section update"),
2446 )
2447 .unwrap();
2448
2449 assert_eq!(
2450 outcome.modified_sections.replaced,
2451 vec!["identity".to_string()]
2452 );
2453 assert_ne!(
2454 outcome.content_hash, seeded.content_hash,
2455 "hash must change"
2456 );
2457 let entity = engine.get_entity(&seeded.id).unwrap();
2459 assert!(
2460 entity
2461 .sections
2462 .get("identity")
2463 .unwrap()
2464 .contains("Updated body.")
2465 );
2466 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
2468 assert!(log.contains("\"kind\":\"update\""));
2469 assert!(log.contains("\"note\":\"section update\""));
2470 }
2471
2472 #[test]
2473 fn update_entity_rejects_hash_mismatch() {
2474 let tmp = TempDir::new().unwrap();
2475 let (mut engine, seeded) = engine_with_seed(&tmp, "Hash Guarded");
2476 let (actor, client) = cli_actor();
2477 let err = engine
2478 .update_entity(
2479 UpdateEntityArgs {
2480 anchors: Vec::new(),
2481 id: seeded.id.clone(),
2482 expected_hash: Some("wrong-hash".to_string()),
2483 sections: IndexMap::new(),
2484 append_sections: IndexMap::new(),
2485 patch_sections: IndexMap::new(),
2486 metadata: IndexMap::new(),
2487 metadata_unset: Vec::new(),
2488 declare_relations: Vec::new(),
2489 dry_run: false,
2490 relations_unset: Vec::new(),
2491 anchors_unset: Vec::new(),
2492 },
2493 actor,
2494 Some(&client),
2495 None,
2496 )
2497 .unwrap_err();
2498 match err {
2499 EngineError::HashMismatch {
2500 id,
2501 current,
2502 is_stub,
2503 } => {
2504 assert_eq!(id, seeded.id.to_string());
2505 assert_eq!(current, seeded.content_hash);
2506 assert!(!is_stub, "real entity must not flag as stub");
2507 }
2508 other => panic!("expected HashMismatch, got {other:?}"),
2509 }
2510 }
2511
2512 #[test]
2513 fn update_entity_rejects_unknown_id() {
2514 let tmp = TempDir::new().unwrap();
2515 let (mut engine, _) = engine_with_seed(&tmp, "Anchor");
2516 let (actor, client) = cli_actor();
2517 let err = engine
2518 .update_entity(
2519 UpdateEntityArgs {
2520 anchors: Vec::new(),
2521 id: crate::EntityId::new("specs", "ghost"),
2522 expected_hash: None,
2523 sections: IndexMap::new(),
2524 append_sections: IndexMap::new(),
2525 patch_sections: IndexMap::new(),
2526 metadata: IndexMap::new(),
2527 metadata_unset: Vec::new(),
2528 declare_relations: Vec::new(),
2529 dry_run: false,
2530 relations_unset: Vec::new(),
2531 anchors_unset: Vec::new(),
2532 },
2533 actor,
2534 Some(&client),
2535 None,
2536 )
2537 .unwrap_err();
2538 assert!(matches!(err, EngineError::NotFound { .. }));
2539 }
2540
2541 #[test]
2542 fn update_entity_rejects_read_only_mount() {
2543 let tmp = TempDir::new().unwrap();
2544 let archive_path = build_archive(
2545 tmp.path(),
2546 "ext",
2547 &[(
2548 "a.md",
2549 b"---\ntype: spec\n---\n# A\n\n## Identity\n\nbody.\n",
2550 )],
2551 );
2552 let mut engine = Engine::from_mounts(vec![(
2553 archive_mount("external", archive_path.clone()),
2554 Box::new(ArchiveBackend::new(archive_path)),
2555 )])
2556 .unwrap();
2557 let (actor, client) = cli_actor();
2558 let id = crate::EntityId::new("external", "a");
2559 let err = engine
2560 .update_entity(
2561 UpdateEntityArgs {
2562 anchors: Vec::new(),
2563 id,
2564 expected_hash: None,
2565 sections: IndexMap::new(),
2566 append_sections: IndexMap::new(),
2567 patch_sections: IndexMap::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 actor,
2576 Some(&client),
2577 None,
2578 )
2579 .unwrap_err();
2580 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "external"));
2581 }
2582
2583 #[test]
2584 fn update_entity_patches_section_with_find_and_replace() {
2585 let tmp = TempDir::new().unwrap();
2586 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Subject");
2587 let (actor, client) = cli_actor();
2588
2589 let mut replace = IndexMap::new();
2592 replace.insert("identity".to_string(), "hello world hello".to_string());
2593 let replaced = engine
2594 .update_entity(
2595 UpdateEntityArgs {
2596 anchors: Vec::new(),
2597 id: seeded.id.clone(),
2598 expected_hash: Some(seeded.content_hash.clone()),
2599 sections: replace,
2600 append_sections: IndexMap::new(),
2601 patch_sections: IndexMap::new(),
2602 metadata: IndexMap::new(),
2603 metadata_unset: Vec::new(),
2604 declare_relations: Vec::new(),
2605 dry_run: false,
2606 relations_unset: Vec::new(),
2607 anchors_unset: Vec::new(),
2608 },
2609 actor,
2610 Some(&client),
2611 None,
2612 )
2613 .unwrap();
2614
2615 let mut patches = IndexMap::new();
2617 patches.insert(
2618 "identity".to_string(),
2619 crate::ops::PatchArg {
2620 old: "hello".to_string(),
2621 new: "HI".to_string(),
2622 all: false,
2623 },
2624 );
2625 let outcome = engine
2626 .update_entity(
2627 UpdateEntityArgs {
2628 anchors: Vec::new(),
2629 id: seeded.id.clone(),
2630 expected_hash: Some(replaced.content_hash.clone()),
2631 sections: IndexMap::new(),
2632 append_sections: IndexMap::new(),
2633 patch_sections: patches,
2634 metadata: IndexMap::new(),
2635 metadata_unset: Vec::new(),
2636 declare_relations: Vec::new(),
2637 dry_run: false,
2638 relations_unset: Vec::new(),
2639 anchors_unset: Vec::new(),
2640 },
2641 actor,
2642 Some(&client),
2643 None,
2644 )
2645 .unwrap();
2646 assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
2647 let body = engine
2648 .get_entity(&seeded.id)
2649 .unwrap()
2650 .sections
2651 .get("identity")
2652 .unwrap()
2653 .clone();
2654 assert!(body.contains("HI world hello"), "first-only: {body:?}");
2655 }
2656
2657 #[test]
2658 fn update_entity_patch_rejects_missing_old_substring() {
2659 let tmp = TempDir::new().unwrap();
2660 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Miss");
2661 let (actor, client) = cli_actor();
2662 let mut patches = IndexMap::new();
2663 patches.insert(
2664 "identity".to_string(),
2665 crate::ops::PatchArg {
2666 old: "this-substring-does-not-exist".to_string(),
2667 new: "nope".to_string(),
2668 all: false,
2669 },
2670 );
2671 let err = engine
2672 .update_entity(
2673 UpdateEntityArgs {
2674 anchors: Vec::new(),
2675 id: seeded.id.clone(),
2676 expected_hash: Some(seeded.content_hash.clone()),
2677 sections: IndexMap::new(),
2678 append_sections: IndexMap::new(),
2679 patch_sections: patches,
2680 metadata: IndexMap::new(),
2681 metadata_unset: Vec::new(),
2682 declare_relations: Vec::new(),
2683 dry_run: false,
2684 relations_unset: Vec::new(),
2685 anchors_unset: Vec::new(),
2686 },
2687 actor,
2688 Some(&client),
2689 None,
2690 )
2691 .unwrap_err();
2692 match err {
2693 EngineError::PatchOldNotFound { section, .. } => {
2694 assert_eq!(section, "identity");
2695 }
2696 other => panic!("expected PatchOldNotFound, got {other:?}"),
2697 }
2698 }
2699
2700 #[test]
2701 fn update_entity_appends_to_existing_section_with_newline_separator() {
2702 let tmp = TempDir::new().unwrap();
2703 let (mut engine, seeded) = engine_with_seed(&tmp, "Append Subject");
2704 let (actor, client) = cli_actor();
2705
2706 let mut appends = IndexMap::new();
2707 appends.insert("identity".to_string(), "appended tail.".to_string());
2708
2709 let outcome = engine
2710 .update_entity(
2711 UpdateEntityArgs {
2712 anchors: Vec::new(),
2713 id: seeded.id.clone(),
2714 expected_hash: Some(seeded.content_hash.clone()),
2715 sections: IndexMap::new(),
2716 append_sections: appends,
2717 patch_sections: IndexMap::new(),
2718 metadata: IndexMap::new(),
2719 metadata_unset: Vec::new(),
2720 declare_relations: Vec::new(),
2721 dry_run: false,
2722 relations_unset: Vec::new(),
2723 anchors_unset: Vec::new(),
2724 },
2725 actor,
2726 Some(&client),
2727 None,
2728 )
2729 .unwrap();
2730
2731 assert_eq!(outcome.modified_sections.appended, vec!["identity"]);
2734 assert!(outcome.modified_sections.replaced.is_empty());
2735
2736 let updated = engine.get_entity(&seeded.id).unwrap();
2738 let body = updated.sections.get("identity").expect("identity section");
2739 assert!(
2740 body.contains("appended tail."),
2741 "appended body missing: {body:?}"
2742 );
2743 }
2744
2745 fn engine_with_open_fence(tmp: &TempDir) -> (Engine, crate::EntityId) {
2749 let (_engine, seeded) = engine_with_seed(tmp, "Fenced");
2750 let id = seeded.id.clone();
2751 let path = tmp.path().join(&seeded.file_path);
2752 let raw = std::fs::read_to_string(&path).expect("seeded file");
2753 let doctored = raw.replace("fixture identity body", "intro\n\n```rust\nfn main() {}");
2756 assert_ne!(doctored, raw, "the seeded body must be there to doctor");
2757 std::fs::write(&path, doctored).unwrap();
2758 let mem_dir = tmp.path().to_path_buf();
2759 let writer = FilesystemMemWriter::new(mem_dir.clone());
2760 let engine = Engine::from_mounts(vec![(
2761 folder_mount("specs", mem_dir),
2762 Box::new(writer) as Box<dyn MemBackend>,
2763 )])
2764 .unwrap();
2765 drop(seeded);
2766 (engine, id)
2767 }
2768
2769 #[test]
2770 fn a_write_that_does_not_resolve_an_open_fence_is_refused() {
2771 let tmp = TempDir::new().unwrap();
2772 let (mut engine, id) = engine_with_open_fence(&tmp);
2773 let (actor, client) = cli_actor();
2774 let stored = engine.get_entity(&id).expect("entity loads");
2780 assert!(
2781 stored
2782 .sections
2783 .get("purpose")
2784 .is_some_and(|v| v.trim().is_empty()),
2785 "purpose should read as empty: {:?}",
2786 stored.sections.get("purpose")
2787 );
2788 assert!(
2789 stored.sections["identity"].contains("## Purpose"),
2790 "its content is inside identity: {:?}",
2791 stored.sections.get("identity")
2792 );
2793 let hash = stored.content_hash.clone();
2794
2795 let err = engine
2796 .update_entity(
2797 UpdateEntityArgs {
2798 anchors: Vec::new(),
2799 id: id.clone(),
2800 expected_hash: Some(hash),
2801 sections: IndexMap::from_iter([(
2802 "purpose".to_string(),
2803 "a new purpose".to_string(),
2804 )]),
2805 append_sections: IndexMap::new(),
2806 patch_sections: IndexMap::new(),
2807 metadata: IndexMap::new(),
2808 metadata_unset: Vec::new(),
2809 declare_relations: Vec::new(),
2810 dry_run: false,
2811 relations_unset: Vec::new(),
2812 anchors_unset: Vec::new(),
2813 },
2814 actor,
2815 Some(&client),
2816 None,
2817 )
2818 .unwrap_err();
2819 match err {
2820 EngineError::UnterminatedFenceInStoredBody {
2821 ref section,
2822 ref fence,
2823 ref swallowed,
2824 ..
2825 } => {
2826 assert_eq!(section, "identity");
2827 assert_eq!(fence, "```");
2828 assert_eq!(
2832 swallowed,
2833 &vec![
2834 "Purpose".to_string(),
2835 "Specifies".to_string(),
2836 "Constraints".to_string(),
2837 "Rationale".to_string(),
2838 ]
2839 );
2840 }
2841 other => panic!("expected UnterminatedFenceInStoredBody, got {other:?}"),
2842 }
2843 assert_eq!(err.code(), "UNTERMINATED_FENCE_IN_STORED_BODY");
2844 }
2845
2846 #[test]
2847 fn replacing_the_absorbing_section_is_the_way_out() {
2848 let tmp = TempDir::new().unwrap();
2853 let (mut engine, id) = engine_with_open_fence(&tmp);
2854 let (actor, client) = cli_actor();
2855 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
2856 let outcome = engine
2857 .update_entity(
2858 UpdateEntityArgs {
2859 anchors: Vec::new(),
2860 id: id.clone(),
2861 expected_hash: Some(hash),
2862 sections: IndexMap::from_iter([
2863 (
2864 "identity".to_string(),
2865 "intro\n\n```rust\nfn main() {}\n```".to_string(),
2866 ),
2867 ("purpose".to_string(), "the recovered purpose".to_string()),
2868 ]),
2869 append_sections: IndexMap::new(),
2870 patch_sections: IndexMap::new(),
2871 metadata: IndexMap::new(),
2872 metadata_unset: Vec::new(),
2873 declare_relations: Vec::new(),
2874 dry_run: false,
2875 relations_unset: Vec::new(),
2876 anchors_unset: Vec::new(),
2877 },
2878 actor,
2879 Some(&client),
2880 None,
2881 )
2882 .expect("a corrected body for the absorbing section is admitted");
2883 assert!(
2884 outcome
2885 .modified_sections
2886 .replaced
2887 .contains(&"identity".to_string())
2888 );
2889 let fixed = engine.get_entity(&id).unwrap();
2890 assert_eq!(
2891 fixed.sections.get("purpose").map(String::as_str),
2892 Some("the recovered purpose"),
2893 "the swallowed section is a section again"
2894 );
2895 assert!(
2896 crate::markdown::closing_fence_if_unterminated(fixed.sections.get("identity").unwrap())
2897 .is_none()
2898 );
2899 }
2900
2901 #[test]
2907 fn every_verb_that_regenerates_the_file_is_gated_not_only_update() {
2908 let tmp = TempDir::new().unwrap();
2909 let (mut engine, id) = engine_with_open_fence(&tmp);
2910 let (actor, client) = cli_actor();
2911 let before =
2912 std::fs::read_to_string(tmp.path().join(&engine.get_entity(&id).unwrap().file_path))
2913 .unwrap();
2914 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
2915
2916 let err = engine
2917 .relate_entity(
2918 RelateEntityArgs {
2919 source: id.clone(),
2920 expected_hash: Some(hash),
2921 rel_type: "USES".to_string(),
2922 target: crate::EntityId::new("specs", "some-target"),
2923 remove: false,
2924 description: None,
2925 dry_run: false,
2926 },
2927 actor,
2928 Some(&client),
2929 None,
2930 )
2931 .expect_err("relate must not be able to freeze the absorption");
2932 assert_eq!(err.code(), "UNTERMINATED_FENCE_IN_STORED_BODY");
2933
2934 let err = engine
2935 .rename_entity(
2936 crate::engine::RenameEntityArgs {
2937 id: id.clone(),
2938 new_title: "Renamed Fenced".to_string(),
2939 expected_hash: Some(engine.get_entity(&id).unwrap().content_hash.clone()),
2940 },
2941 actor,
2942 Some(&client),
2943 None,
2944 )
2945 .expect_err("rename must not be able to freeze the absorption either");
2946 assert_eq!(err.code(), "UNTERMINATED_FENCE_IN_STORED_BODY");
2947
2948 let after =
2950 std::fs::read_to_string(tmp.path().join(&engine.get_entity(&id).unwrap().file_path))
2951 .unwrap();
2952 assert_eq!(before, after, "a refused write must not touch the file");
2953 }
2954
2955 #[test]
2956 fn an_entity_with_no_open_fence_updates_exactly_as_before() {
2957 let tmp = TempDir::new().unwrap();
2960 let (mut engine, seeded) = engine_with_seed(&tmp, "Ordinary");
2961 let (actor, client) = cli_actor();
2962 engine
2963 .update_entity(
2964 UpdateEntityArgs {
2965 anchors: Vec::new(),
2966 id: seeded.id.clone(),
2967 expected_hash: Some(seeded.content_hash.clone()),
2968 sections: IndexMap::from_iter([(
2969 "purpose".to_string(),
2970 "a new purpose".to_string(),
2971 )]),
2972 append_sections: IndexMap::new(),
2973 patch_sections: IndexMap::new(),
2974 metadata: IndexMap::new(),
2975 metadata_unset: Vec::new(),
2976 declare_relations: Vec::new(),
2977 dry_run: false,
2978 relations_unset: Vec::new(),
2979 anchors_unset: Vec::new(),
2980 },
2981 actor,
2982 Some(&client),
2983 None,
2984 )
2985 .expect("an ordinary update is untouched by the fence gate");
2986 }
2987
2988 #[test]
2995 fn update_entity_against_stub_surfaces_typed_stub_not_updatable() {
2996 let tmp = TempDir::new().unwrap();
2997 let (mut engine, source) = engine_with_seed(&tmp, "Source");
2998 let (actor, client) = cli_actor();
2999 let stub_id = crate::EntityId::new("specs", "stub-update-target");
3002 engine
3003 .relate_entity(
3004 RelateEntityArgs {
3005 source: source.id.clone(),
3006 expected_hash: Some(source.content_hash.clone()),
3007 rel_type: "USES".to_string(),
3008 target: stub_id.clone(),
3009 remove: false,
3010 description: None,
3011 dry_run: false,
3012 },
3013 actor,
3014 Some(&client),
3015 None,
3016 )
3017 .unwrap();
3018
3019 let err = engine
3020 .update_entity(
3021 UpdateEntityArgs {
3022 anchors: Vec::new(),
3023 id: stub_id.clone(),
3024 expected_hash: Some(String::new()),
3025 sections: IndexMap::from_iter([("identity".to_string(), "body".to_string())]),
3026 append_sections: IndexMap::new(),
3027 patch_sections: IndexMap::new(),
3028 metadata: IndexMap::new(),
3029 metadata_unset: Vec::new(),
3030 declare_relations: Vec::new(),
3031 dry_run: false,
3032 relations_unset: Vec::new(),
3033 anchors_unset: Vec::new(),
3034 },
3035 actor,
3036 Some(&client),
3037 None,
3038 )
3039 .unwrap_err();
3040 match err {
3041 EngineError::StubNotUpdatable { id } => assert_eq!(id, stub_id.to_string()),
3042 other => panic!("expected StubNotUpdatable, got {other:?}"),
3043 }
3044 }
3045
3046 #[test]
3047 fn update_entity_rejects_conflicting_section_modes() {
3048 let tmp = TempDir::new().unwrap();
3049 let (mut engine, seeded) = engine_with_seed(&tmp, "Conflict");
3050 let (actor, client) = cli_actor();
3051
3052 let mut sections = IndexMap::new();
3053 sections.insert("identity".to_string(), "replace".to_string());
3054 let mut appends = IndexMap::new();
3055 appends.insert("identity".to_string(), "append".to_string());
3056
3057 let err = engine
3058 .update_entity(
3059 UpdateEntityArgs {
3060 anchors: Vec::new(),
3061 id: seeded.id.clone(),
3062 expected_hash: Some(seeded.content_hash.clone()),
3063 sections,
3064 append_sections: appends,
3065 patch_sections: IndexMap::new(),
3066 metadata: IndexMap::new(),
3067 metadata_unset: Vec::new(),
3068 declare_relations: Vec::new(),
3069 dry_run: false,
3070 relations_unset: Vec::new(),
3071 anchors_unset: Vec::new(),
3072 },
3073 actor,
3074 Some(&client),
3075 None,
3076 )
3077 .unwrap_err();
3078
3079 match err {
3080 EngineError::ConflictingSectionModes { section, modes } => {
3081 assert_eq!(section, "identity");
3082 assert_eq!(modes, vec!["sections", "append_sections"]);
3083 }
3084 other => panic!("expected ConflictingSectionModes, got {other:?}"),
3085 }
3086 }
3087
3088 #[test]
3089 fn update_entity_rejects_overlapping_metadata_and_metadata_unset_keys() {
3090 let tmp = TempDir::new().unwrap();
3095 let (mut engine, seeded) = engine_with_seed(&tmp, "Overlap Subject");
3096 let (actor, client) = cli_actor();
3097
3098 let mut metadata = IndexMap::new();
3099 metadata.insert("tags".to_string(), "foo".to_string());
3103
3104 let err = engine
3105 .update_entity(
3106 UpdateEntityArgs {
3107 anchors: Vec::new(),
3108 id: seeded.id.clone(),
3109 expected_hash: Some(seeded.content_hash.clone()),
3110 sections: IndexMap::new(),
3111 append_sections: IndexMap::new(),
3112 patch_sections: IndexMap::new(),
3113 metadata,
3114 metadata_unset: vec!["tags".to_string()],
3115 declare_relations: Vec::new(),
3116 dry_run: false,
3117 relations_unset: Vec::new(),
3118 anchors_unset: Vec::new(),
3119 },
3120 actor,
3121 Some(&client),
3122 None,
3123 )
3124 .unwrap_err();
3125 match err {
3126 EngineError::SetAndUnsetConflict { keys } => {
3127 assert_eq!(keys, vec!["tags".to_string()]);
3128 }
3129 other => panic!("expected SetAndUnsetConflict, got {other:?}"),
3130 }
3131 }
3132
3133 #[test]
3134 fn update_entity_pointer_schema_auto_synthesises_references_from_body_link() {
3135 use crate::EntityId;
3143 use crate::engine::UpdateEntityArgs;
3144 use indexmap::IndexMap;
3145 use tempfile::TempDir;
3146
3147 let tmp = TempDir::new().unwrap();
3148 let mem_dir = tmp.path().to_path_buf();
3149 let writer = FilesystemMemWriter::new(mem_dir.clone());
3150 let mut engine = Engine::from_mounts(vec![(
3151 folder_mount("specs", mem_dir.clone()),
3152 Box::new(writer) as Box<dyn MemBackend>,
3153 )])
3154 .unwrap();
3155 engine.set_workspace_root(mem_dir.clone());
3156 let (actor, client) = cli_actor();
3157
3158 let target = engine
3159 .create_entity(
3160 empty_create_args("specs", "Target"),
3161 actor,
3162 Some(&client),
3163 None,
3164 )
3165 .unwrap();
3166 let source = engine
3167 .create_entity(
3168 empty_create_args("specs", "Source"),
3169 actor,
3170 Some(&client),
3171 None,
3172 )
3173 .unwrap();
3174
3175 let mut sections: IndexMap<String, String> = IndexMap::new();
3176 sections.insert(
3177 "purpose".to_string(),
3178 "see [[target]] for context".to_string(),
3179 );
3180 let outcome = engine
3181 .update_entity(
3182 UpdateEntityArgs {
3183 anchors: Vec::new(),
3184 id: source.id.clone(),
3185 expected_hash: Some(source.content_hash.clone()),
3186 sections,
3187 append_sections: IndexMap::new(),
3188 patch_sections: IndexMap::new(),
3189 metadata: IndexMap::new(),
3190 metadata_unset: Vec::new(),
3191 declare_relations: Vec::new(),
3192 dry_run: false,
3193 relations_unset: Vec::new(),
3194 anchors_unset: Vec::new(),
3195 },
3196 actor,
3197 Some(&client),
3198 None,
3199 )
3200 .expect("auto-synthesis must satisfy the alias-existence invariant");
3201 assert!(
3203 outcome
3204 .modified_sections
3205 .replaced
3206 .iter()
3207 .any(|s| s == "purpose"),
3208 );
3209 let in_mem = engine.get_entity(&source.id).unwrap();
3210 assert_eq!(
3211 in_mem
3212 .sections
3213 .get("purpose")
3214 .map(String::as_str)
3215 .unwrap_or(""),
3216 "see [[target]] for context",
3217 );
3218 assert!(
3220 in_mem
3221 .relationships
3222 .iter()
3223 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3224 "synthesis must emit REFERENCES → target; relationships: {:?}",
3225 in_mem.relationships,
3226 );
3227 let _ = EntityId::new("specs", "x");
3229 }
3230
3231 #[test]
3232 fn update_entity_declare_relations_passes_strict_validator_in_one_call() {
3233 use crate::engine::UpdateEntityArgs;
3240 use crate::ops::RelateArg;
3241 use indexmap::IndexMap;
3242 use tempfile::TempDir;
3243
3244 let tmp = TempDir::new().unwrap();
3245 let mem_dir = tmp.path().to_path_buf();
3246 let writer = FilesystemMemWriter::new(mem_dir.clone());
3247 let mut engine = Engine::from_mounts(vec![(
3248 folder_mount("specs", mem_dir.clone()),
3249 Box::new(writer) as Box<dyn MemBackend>,
3250 )])
3251 .unwrap();
3252 engine.set_workspace_root(mem_dir.clone());
3253 let (actor, client) = cli_actor();
3254
3255 let target = engine
3256 .create_entity(
3257 empty_create_args("specs", "Target"),
3258 actor,
3259 Some(&client),
3260 None,
3261 )
3262 .unwrap();
3263 let source = engine
3264 .create_entity(
3265 empty_create_args("specs", "Source"),
3266 actor,
3267 Some(&client),
3268 None,
3269 )
3270 .unwrap();
3271
3272 let mut sections: IndexMap<String, String> = IndexMap::new();
3280 sections.insert(
3281 "purpose".to_string(),
3282 "see [[target]] for context".to_string(),
3283 );
3284 let outcome = engine
3285 .update_entity(
3286 UpdateEntityArgs {
3287 anchors: Vec::new(),
3288 relations_unset: Vec::new(),
3289 anchors_unset: Vec::new(),
3290 id: source.id.clone(),
3291 expected_hash: Some(source.content_hash.clone()),
3292 sections,
3293 append_sections: IndexMap::new(),
3294 patch_sections: IndexMap::new(),
3295 metadata: IndexMap::new(),
3296 metadata_unset: Vec::new(),
3297 dry_run: false,
3298 declare_relations: vec![RelateArg {
3299 rel_type: "USES".to_string(),
3300 target: target.id.clone(),
3301 description: None,
3302 }],
3303 },
3304 actor,
3305 Some(&client),
3306 None,
3307 )
3308 .expect("declare_relations + body update must succeed in one call");
3309
3310 assert_eq!(outcome.relations_declared.len(), 1);
3311 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
3312 assert_eq!(outcome.relations_declared[0].target, target.id);
3313 assert!(
3314 !outcome.relations_declared[0].target_was_stubbed,
3315 "target was already present in store; target_was_stubbed must be false"
3316 );
3317
3318 let in_mem = engine.get_entity(&source.id).unwrap();
3319 assert!(
3320 in_mem.relationships.iter().any(|r| r.target == target.id),
3321 "declared relation must land in entity.relationships; got {:?}",
3322 in_mem.relationships
3323 );
3324 }
3325
3326 #[test]
3327 fn update_entity_declare_relations_auto_stubs_absent_target() {
3328 use crate::EntityId;
3332 use crate::engine::UpdateEntityArgs;
3333 use crate::ops::RelateArg;
3334 use indexmap::IndexMap;
3335
3336 let tmp = TempDir::new().unwrap();
3337 let (mut engine, source) = engine_with_seed(&tmp, "Source");
3338 let (actor, client) = cli_actor();
3339 let absent_target = EntityId::new("specs", "not-yet-existing");
3340 assert!(!engine.store().contains(&absent_target));
3341
3342 let outcome = engine
3343 .update_entity(
3344 UpdateEntityArgs {
3345 anchors: Vec::new(),
3346 relations_unset: Vec::new(),
3347 anchors_unset: Vec::new(),
3348 id: source.id.clone(),
3349 expected_hash: Some(source.content_hash.clone()),
3350 sections: IndexMap::new(),
3351 append_sections: IndexMap::new(),
3352 patch_sections: IndexMap::new(),
3353 metadata: IndexMap::new(),
3354 metadata_unset: Vec::new(),
3355 dry_run: false,
3356 declare_relations: vec![RelateArg {
3357 rel_type: "USES".to_string(),
3358 target: absent_target.clone(),
3359 description: None,
3360 }],
3361 },
3362 actor,
3363 Some(&client),
3364 None,
3365 )
3366 .unwrap();
3367
3368 assert_eq!(outcome.relations_declared.len(), 1);
3369 assert!(
3370 outcome.relations_declared[0].target_was_stubbed,
3371 "absent target must be auto-stubbed; got target_was_stubbed=false"
3372 );
3373 assert!(engine.store().contains(&absent_target));
3375 let stub = engine.get_entity(&absent_target).unwrap();
3376 assert!(stub.stub);
3377 }
3378
3379 #[test]
3380 fn update_entity_alias_synthesis_runs_unconditionally_for_pointer_schemas() {
3381 use crate::engine::UpdateEntityArgs;
3387 use indexmap::IndexMap;
3388 use tempfile::TempDir;
3389
3390 let tmp = TempDir::new().unwrap();
3391 let mem_dir = tmp.path().to_path_buf();
3392 let writer = FilesystemMemWriter::new(mem_dir.clone());
3393 let mut engine = Engine::from_mounts(vec![(
3394 folder_mount("specs", mem_dir.clone()),
3395 Box::new(writer) as Box<dyn MemBackend>,
3396 )])
3397 .unwrap();
3398 engine.set_workspace_root(mem_dir.clone());
3399 let (actor, client) = cli_actor();
3400 let target = engine
3401 .create_entity(
3402 empty_create_args("specs", "Target"),
3403 actor,
3404 Some(&client),
3405 None,
3406 )
3407 .unwrap();
3408 let source = engine
3409 .create_entity(
3410 empty_create_args("specs", "Source"),
3411 actor,
3412 Some(&client),
3413 None,
3414 )
3415 .unwrap();
3416
3417 let mut sections: IndexMap<String, String> = IndexMap::new();
3418 sections.insert(
3419 "purpose".to_string(),
3420 "see [[target]] for context".to_string(),
3421 );
3422 engine
3423 .update_entity(
3424 UpdateEntityArgs {
3425 anchors: Vec::new(),
3426 id: source.id.clone(),
3427 expected_hash: Some(source.content_hash.clone()),
3428 sections,
3429 append_sections: IndexMap::new(),
3430 patch_sections: IndexMap::new(),
3431 metadata: IndexMap::new(),
3432 metadata_unset: Vec::new(),
3433 declare_relations: Vec::new(),
3434 dry_run: false,
3435 relations_unset: Vec::new(),
3436 anchors_unset: Vec::new(),
3437 },
3438 actor,
3439 Some(&client),
3440 None,
3441 )
3442 .expect("synthesis must back the wiki-link and let the body land");
3443 let in_mem = engine.get_entity(&source.id).unwrap();
3444 assert!(
3445 in_mem
3446 .relationships
3447 .iter()
3448 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3449 "synthesis must emit REFERENCES → target; relationships: {:?}",
3450 in_mem.relationships,
3451 );
3452 }
3453
3454 #[test]
3455 fn update_entity_dry_run_returns_prospective_hash_without_writing() {
3456 let tmp = TempDir::new().unwrap();
3457 let (mut engine, seeded) = engine_with_seed(&tmp, "Preview Subject");
3458 let (actor, client) = cli_actor();
3459 let original_hash = seeded.content_hash.clone();
3460
3461 let mut sections = IndexMap::new();
3462 sections.insert("identity".to_string(), "preview body".to_string());
3463
3464 let outcome = engine
3465 .update_entity(
3466 UpdateEntityArgs {
3467 anchors: Vec::new(),
3468 id: seeded.id.clone(),
3469 expected_hash: Some("wrong-hash".to_string()),
3472 sections,
3473 append_sections: IndexMap::new(),
3474 patch_sections: IndexMap::new(),
3475 metadata: IndexMap::new(),
3476 metadata_unset: Vec::new(),
3477 declare_relations: Vec::new(),
3478 dry_run: true,
3479 relations_unset: Vec::new(),
3480 anchors_unset: Vec::new(),
3481 },
3482 actor,
3483 Some(&client),
3484 None,
3485 )
3486 .unwrap();
3487
3488 assert_eq!(outcome.content_hash, original_hash);
3491 let prospective = outcome
3492 .prospective_hash
3493 .expect("prospective_hash populated on dry_run");
3494 assert_ne!(prospective, original_hash);
3495 assert!(outcome.write_id.is_empty());
3496 let store_entity = engine.get_entity(&seeded.id).unwrap();
3498 assert_eq!(store_entity.content_hash, original_hash);
3499 }
3500
3501 #[test]
3520 fn references_edges_round_trip_across_full_crud_cycle() {
3521 let tmp = TempDir::new().unwrap();
3522 let mem_dir = tmp.path().to_path_buf();
3523 let writer = FilesystemMemWriter::new(mem_dir.clone());
3524 let mut engine = Engine::from_mounts(vec![(
3525 folder_mount("specs", mem_dir),
3526 Box::new(writer) as Box<dyn MemBackend>,
3527 )])
3528 .unwrap();
3529 let (actor, client) = cli_actor();
3530
3531 let foo = engine
3535 .create_entity(
3536 empty_create_args("specs", "Foo"),
3537 actor,
3538 Some(&client),
3539 None,
3540 )
3541 .unwrap();
3542 let bar = engine
3543 .create_entity(
3544 empty_create_args("specs", "Bar"),
3545 actor,
3546 Some(&client),
3547 None,
3548 )
3549 .unwrap();
3550
3551 let count_references = |engine: &Engine| -> usize {
3552 engine
3553 .store()
3554 .all_ids()
3555 .flat_map(|id| engine.store().outgoing(id))
3556 .filter(|e| e.rel_type == "REFERENCES")
3557 .count()
3558 };
3559
3560 let baseline_edges = engine.store().edge_count();
3561 let baseline_refs = count_references(&engine);
3562
3563 let mut sections = IndexMap::new();
3569 sections.insert(
3570 "identity".to_string(),
3571 "See [[foo]] and [[bar]] inline.".to_string(),
3572 );
3573 sections.insert("purpose".to_string(), "probe purpose".to_string());
3574 let probe = engine
3575 .create_entity(
3576 CreateEntityArgs {
3577 anchors: Vec::new(),
3578 mem: "specs".to_string(),
3579 title: "Probe".to_string(),
3580 entity_type: "spec".to_string(),
3581 sections,
3582 metadata: IndexMap::new(),
3583 relations: Vec::new(),
3584 dry_run: false,
3585 },
3586 actor,
3587 Some(&client),
3588 None,
3589 )
3590 .unwrap();
3591 assert_eq!(count_references(&engine), baseline_refs + 2);
3592
3593 let relate1 = engine
3598 .relate_entity(
3599 RelateEntityArgs {
3600 source: probe.id.clone(),
3601 expected_hash: Some(probe.content_hash.clone()),
3602 rel_type: "INFORMED_BY".to_string(),
3603 target: foo.id.clone(),
3604 remove: false,
3605 description: None,
3606 dry_run: false,
3607 },
3608 actor,
3609 Some(&client),
3610 None,
3611 )
3612 .unwrap();
3613 assert_eq!(
3614 count_references(&engine),
3615 baseline_refs + 2,
3616 "set-membership aliasing — adding INFORMED_BY does not \
3617 absorb the REFERENCES relation"
3618 );
3619
3620 let mut sections = IndexMap::new();
3624 sections.insert("identity".to_string(), "See [[foo]] inline.".to_string());
3625 let updated = engine
3626 .update_entity(
3627 UpdateEntityArgs {
3628 anchors: Vec::new(),
3629 id: probe.id.clone(),
3630 expected_hash: Some(relate1.content_hash.clone()),
3631 sections,
3632 append_sections: IndexMap::new(),
3633 patch_sections: IndexMap::new(),
3634 metadata: IndexMap::new(),
3635 metadata_unset: Vec::new(),
3636 declare_relations: Vec::new(),
3637 dry_run: false,
3638 relations_unset: Vec::new(),
3639 anchors_unset: Vec::new(),
3640 },
3641 actor,
3642 Some(&client),
3643 None,
3644 )
3645 .unwrap();
3646 assert_eq!(
3647 count_references(&engine),
3648 baseline_refs + 1,
3649 "REFERENCES → bar must be auto-GC'd when its body link drops"
3650 );
3651
3652 let renamed = engine
3654 .rename_entity(
3655 crate::engine::RenameEntityArgs {
3656 id: probe.id.clone(),
3657 expected_hash: Some(updated.content_hash.clone()),
3658 new_title: "Probe Renamed".to_string(),
3659 },
3660 actor,
3661 Some(&client),
3662 None,
3663 )
3664 .unwrap();
3665 assert_eq!(count_references(&engine), baseline_refs + 1);
3666
3667 engine
3670 .delete_entity(
3671 crate::engine::DeleteEntityArgs {
3672 id: renamed.new_id.clone(),
3673 expected_hash: Some(renamed.content_hash.clone()),
3674 },
3675 actor,
3676 Some(&client),
3677 None,
3678 )
3679 .unwrap();
3680
3681 assert_eq!(
3683 engine.store().edge_count(),
3684 baseline_edges,
3685 "total edges must round-trip to baseline"
3686 );
3687 assert_eq!(
3688 count_references(&engine),
3689 baseline_refs,
3690 "REFERENCES counter must round-trip to baseline"
3691 );
3692
3693 engine.reload_one_mem("specs").unwrap();
3697 assert_eq!(
3698 engine.store().edge_count(),
3699 baseline_edges,
3700 "total edges must match disk after reload"
3701 );
3702 assert_eq!(
3703 count_references(&engine),
3704 baseline_refs,
3705 "REFERENCES must match disk after reload"
3706 );
3707 assert!(engine.store().contains(&foo.id));
3709 assert!(engine.store().contains(&bar.id));
3710 }
3711
3712 #[test]
3713 fn update_entity_returns_write_id_title_modified_date_warnings_shape() {
3714 let tmp = TempDir::new().unwrap();
3715 let (mut engine, seeded) = engine_with_seed(&tmp, "Subject");
3716 let (actor, client) = cli_actor();
3717
3718 let mut sections = IndexMap::new();
3719 sections.insert("identity".to_string(), "edited body".to_string());
3720
3721 let outcome = engine
3722 .update_entity(
3723 UpdateEntityArgs {
3724 anchors: Vec::new(),
3725 id: seeded.id.clone(),
3726 expected_hash: Some(seeded.content_hash.clone()),
3727 sections,
3728 append_sections: IndexMap::new(),
3729 patch_sections: IndexMap::new(),
3730 metadata: IndexMap::new(),
3731 metadata_unset: Vec::new(),
3732 declare_relations: Vec::new(),
3733 dry_run: false,
3734 relations_unset: Vec::new(),
3735 anchors_unset: Vec::new(),
3736 },
3737 actor,
3738 Some(&client),
3739 None,
3740 )
3741 .unwrap();
3742
3743 assert!(
3745 !outcome.write_id.is_empty(),
3746 "write_id must be populated on a real update"
3747 );
3748 assert_eq!(outcome.title, "Subject");
3750 assert!(
3755 !outcome.modified_date.is_empty(),
3756 "modified_date must be auto-stamped on update for the default spec schema",
3757 );
3758 assert!(outcome.warnings.is_empty());
3762 assert_eq!(
3764 outcome.modified_sections.replaced,
3765 vec!["identity".to_string()]
3766 );
3767 }
3768
3769 #[test]
3778 fn update_entity_noop_resetting_section_to_current_value_preserves_last_modified() {
3779 let tmp = TempDir::new().unwrap();
3780 let (mut engine, seeded) = engine_with_seed(&tmp, "Section Resetter");
3781 let (actor, client) = cli_actor();
3782
3783 let pre_last_modified = engine
3786 .get_entity(&seeded.id)
3787 .and_then(|e| e.metadata.get("last_modified"))
3788 .map(|v| v.to_frontmatter_string())
3789 .expect("seeded entity has last_modified");
3790
3791 let mut sections = IndexMap::new();
3795 sections.insert("identity".to_string(), "fixture identity body".to_string());
3796 let outcome = engine
3797 .update_entity(
3798 UpdateEntityArgs {
3799 anchors: Vec::new(),
3800 id: seeded.id.clone(),
3801 expected_hash: Some(seeded.content_hash.clone()),
3802 sections,
3803 append_sections: IndexMap::new(),
3804 patch_sections: IndexMap::new(),
3805 metadata: IndexMap::new(),
3806 metadata_unset: Vec::new(),
3807 declare_relations: Vec::new(),
3808 dry_run: false,
3809 relations_unset: Vec::new(),
3810 anchors_unset: Vec::new(),
3811 },
3812 actor,
3813 Some(&client),
3814 None,
3815 )
3816 .unwrap();
3817
3818 assert_eq!(outcome.write_id, "", "no-op must not commit");
3819 assert_eq!(
3820 outcome.content_hash, seeded.content_hash,
3821 "no-op must not advance content_hash",
3822 );
3823 assert!(
3824 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3825 "UPDATE_NOOP must fire on bytes-identical re-set",
3826 );
3827 assert_eq!(
3828 outcome.modified_date, pre_last_modified,
3829 "no-op must preserve last_modified at the pre-call value",
3830 );
3831 assert!(
3836 outcome.modified_sections.replaced.is_empty()
3837 && outcome.modified_sections.appended.is_empty()
3838 && outcome.modified_sections.patched.is_empty(),
3839 "no-op must report an empty section delta, got {:?}",
3840 outcome.modified_sections,
3841 );
3842
3843 let post_last_modified = engine
3847 .get_entity(&seeded.id)
3848 .and_then(|e| e.metadata.get("last_modified"))
3849 .map(|v| v.to_frontmatter_string())
3850 .expect("entity still in store");
3851 assert_eq!(post_last_modified, pre_last_modified);
3852 }
3853
3854 #[test]
3866 fn update_entity_empty_payload_refuses_with_typed_code() {
3867 let tmp = TempDir::new().unwrap();
3868 let (mut engine, seeded) = engine_with_seed(&tmp, "Empty Payload");
3869 let (actor, client) = cli_actor();
3870
3871 let err = engine
3872 .update_entity(
3873 UpdateEntityArgs {
3874 anchors: Vec::new(),
3875 id: seeded.id.clone(),
3876 expected_hash: Some(seeded.content_hash.clone()),
3877 sections: IndexMap::new(),
3878 append_sections: IndexMap::new(),
3879 patch_sections: IndexMap::new(),
3880 metadata: IndexMap::new(),
3881 metadata_unset: Vec::new(),
3882 declare_relations: Vec::new(),
3883 dry_run: false,
3884 relations_unset: Vec::new(),
3885 anchors_unset: Vec::new(),
3886 },
3887 actor,
3888 Some(&client),
3889 None,
3890 )
3891 .unwrap_err();
3892 match err {
3893 EngineError::EmptyUpdate { id } => {
3894 assert_eq!(id, seeded.id.to_string());
3895 }
3896 other => panic!("expected EMPTY_UPDATE, got {other:?}"),
3897 }
3898 let log_path = tmp.path().join(".memstead/changes.jsonl");
3900 if let Ok(log) = std::fs::read_to_string(&log_path) {
3901 let updates = log.matches("\"kind\":\"update\"").count();
3902 assert_eq!(updates, 0, "EMPTY_UPDATE refusal must not log an update");
3903 }
3904 }
3905
3906 #[test]
3912 fn update_entity_noop_same_content_surfaces_warning() {
3913 let tmp = TempDir::new().unwrap();
3914 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Content Noop");
3915 let (actor, client) = cli_actor();
3916
3917 let mut sections = IndexMap::new();
3919 sections.insert("identity".to_string(), "fixture identity body".to_string());
3920
3921 let outcome = engine
3922 .update_entity(
3923 UpdateEntityArgs {
3924 anchors: Vec::new(),
3925 id: seeded.id.clone(),
3926 expected_hash: Some(seeded.content_hash.clone()),
3927 sections,
3928 append_sections: IndexMap::new(),
3929 patch_sections: IndexMap::new(),
3930 metadata: IndexMap::new(),
3931 metadata_unset: Vec::new(),
3932 declare_relations: Vec::new(),
3933 dry_run: false,
3934 relations_unset: Vec::new(),
3935 anchors_unset: Vec::new(),
3936 },
3937 actor,
3938 Some(&client),
3939 None,
3940 )
3941 .unwrap();
3942
3943 assert_eq!(outcome.write_id, "");
3944 assert_eq!(outcome.content_hash, seeded.content_hash);
3945 let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code()).collect();
3946 assert!(
3947 codes.contains(&"UPDATE_NOOP"),
3948 "same-content update must surface UPDATE_NOOP; got {codes:?}",
3949 );
3950 }
3951
3952 #[test]
3953 fn update_entity_noop_metadata_unset_on_absent_key() {
3954 let tmp = TempDir::new().unwrap();
3959 let (mut engine, seeded) = engine_with_seed(&tmp, "Absent Key Noop");
3960 let (actor, client) = cli_actor();
3961
3962 let outcome = engine
3963 .update_entity(
3964 UpdateEntityArgs {
3965 anchors: Vec::new(),
3966 id: seeded.id.clone(),
3967 expected_hash: Some(seeded.content_hash.clone()),
3968 sections: IndexMap::new(),
3969 append_sections: IndexMap::new(),
3970 patch_sections: IndexMap::new(),
3971 metadata: IndexMap::new(),
3972 metadata_unset: vec!["tags".to_string()],
3976 declare_relations: Vec::new(),
3977 dry_run: false,
3978 relations_unset: Vec::new(),
3979 anchors_unset: Vec::new(),
3980 },
3981 actor,
3982 Some(&client),
3983 None,
3984 )
3985 .unwrap();
3986
3987 assert_eq!(outcome.write_id, "");
3988 assert_eq!(outcome.content_hash, seeded.content_hash);
3989 assert!(
3990 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3991 "absent-key metadata_unset must surface UPDATE_NOOP",
3992 );
3993 assert!(
3996 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
3997 "no-op must report an empty metadata delta, got {:?}",
3998 outcome.modified_metadata,
3999 );
4000
4001 let mut sections = IndexMap::new();
4004 sections.insert("identity".to_string(), "real change".to_string());
4005 let real = engine
4006 .update_entity(
4007 UpdateEntityArgs {
4008 anchors: Vec::new(),
4009 id: seeded.id.clone(),
4010 expected_hash: Some(seeded.content_hash.clone()),
4011 sections,
4012 append_sections: IndexMap::new(),
4013 patch_sections: IndexMap::new(),
4014 metadata: IndexMap::new(),
4015 metadata_unset: Vec::new(),
4016 declare_relations: Vec::new(),
4017 dry_run: false,
4018 relations_unset: Vec::new(),
4019 anchors_unset: Vec::new(),
4020 },
4021 actor,
4022 Some(&client),
4023 None,
4024 )
4025 .unwrap();
4026 assert!(!real.write_id.is_empty());
4027 assert_ne!(real.content_hash, seeded.content_hash);
4028 }
4029
4030 #[test]
4037 fn update_entity_noop_setting_metadata_to_current_value_reports_empty_delta() {
4038 let tmp = TempDir::new().unwrap();
4039 let (mut engine, seeded) = engine_with_seed(&tmp, "Stability Resetter");
4040 let (actor, client) = cli_actor();
4041
4042 let mut metadata = IndexMap::new();
4045 metadata.insert("level".to_string(), "M0".to_string());
4046 let outcome = engine
4047 .update_entity(
4048 UpdateEntityArgs {
4049 anchors: Vec::new(),
4050 id: seeded.id.clone(),
4051 expected_hash: Some(seeded.content_hash.clone()),
4052 sections: IndexMap::new(),
4053 append_sections: IndexMap::new(),
4054 patch_sections: IndexMap::new(),
4055 metadata,
4056 metadata_unset: Vec::new(),
4057 declare_relations: Vec::new(),
4058 dry_run: false,
4059 relations_unset: Vec::new(),
4060 anchors_unset: Vec::new(),
4061 },
4062 actor,
4063 Some(&client),
4064 None,
4065 )
4066 .unwrap();
4067
4068 assert_eq!(outcome.write_id, "", "no-op must not commit");
4069 assert_eq!(
4070 outcome.content_hash, seeded.content_hash,
4071 "no-op must not advance hash"
4072 );
4073 assert!(
4074 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4075 "re-set to current value must surface UPDATE_NOOP",
4076 );
4077 assert!(
4078 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
4079 "no-op must not claim `level` was set — applied delta is empty, got {:?}",
4080 outcome.modified_metadata,
4081 );
4082 }
4083
4084 #[test]
4085 fn update_entity_noop_declare_already_related_edge() {
4086 use crate::ops::RelateArg;
4091 let tmp = TempDir::new().unwrap();
4092 let mem_dir = tmp.path().to_path_buf();
4093 let writer = FilesystemMemWriter::new(mem_dir.clone());
4094 let mut engine = Engine::from_mounts(vec![(
4095 folder_mount("specs", mem_dir),
4096 Box::new(writer) as Box<dyn MemBackend>,
4097 )])
4098 .unwrap();
4099 let (actor, client) = cli_actor();
4100 let target = engine
4101 .create_entity(
4102 empty_create_args("specs", "Target Already Related"),
4103 actor,
4104 Some(&client),
4105 None,
4106 )
4107 .unwrap();
4108 let source = engine
4109 .create_entity(
4110 empty_create_args("specs", "Source Already Related"),
4111 actor,
4112 Some(&client),
4113 None,
4114 )
4115 .unwrap();
4116 let after_relate = engine
4117 .relate_entity(
4118 RelateEntityArgs {
4119 source: source.id.clone(),
4120 expected_hash: Some(source.content_hash.clone()),
4121 rel_type: "USES".to_string(),
4122 target: target.id.clone(),
4123 remove: false,
4124 description: None,
4125 dry_run: false,
4126 },
4127 actor,
4128 Some(&client),
4129 None,
4130 )
4131 .unwrap();
4132 let outcome = engine
4134 .update_entity(
4135 UpdateEntityArgs {
4136 anchors: Vec::new(),
4137 relations_unset: Vec::new(),
4138 anchors_unset: Vec::new(),
4139 id: source.id.clone(),
4140 expected_hash: Some(after_relate.content_hash.clone()),
4141 sections: IndexMap::new(),
4142 append_sections: IndexMap::new(),
4143 patch_sections: IndexMap::new(),
4144 metadata: IndexMap::new(),
4145 metadata_unset: Vec::new(),
4146 declare_relations: vec![RelateArg {
4147 rel_type: "USES".to_string(),
4148 target: target.id.clone(),
4149 description: None,
4150 }],
4151 dry_run: false,
4152 },
4153 actor,
4154 Some(&client),
4155 None,
4156 )
4157 .unwrap();
4158
4159 assert_eq!(outcome.write_id, "");
4160 assert_eq!(outcome.content_hash, after_relate.content_hash);
4161 assert!(
4162 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4163 "duplicate declare must surface UPDATE_NOOP",
4164 );
4165 assert_eq!(outcome.relations_declared.len(), 1);
4168 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
4169 assert_eq!(outcome.relations_declared[0].target, target.id);
4170 assert!(!outcome.relations_declared[0].target_was_stubbed);
4171 }
4172
4173 #[test]
4174 fn update_entity_real_change_still_commits_and_advances_hash() {
4175 let tmp = TempDir::new().unwrap();
4180 let (mut engine, seeded) = engine_with_seed(&tmp, "Real Change Subject");
4181 let (actor, client) = cli_actor();
4182
4183 let mut sections = IndexMap::new();
4184 sections.insert("identity".to_string(), "definitely new body".to_string());
4185
4186 let outcome = engine
4187 .update_entity(
4188 UpdateEntityArgs {
4189 anchors: Vec::new(),
4190 id: seeded.id.clone(),
4191 expected_hash: Some(seeded.content_hash.clone()),
4192 sections,
4193 append_sections: IndexMap::new(),
4194 patch_sections: IndexMap::new(),
4195 metadata: IndexMap::new(),
4196 metadata_unset: Vec::new(),
4197 declare_relations: Vec::new(),
4198 dry_run: false,
4199 relations_unset: Vec::new(),
4200 anchors_unset: Vec::new(),
4201 },
4202 actor,
4203 Some(&client),
4204 None,
4205 )
4206 .unwrap();
4207
4208 assert!(!outcome.write_id.is_empty(), "real change must commit");
4209 assert_ne!(
4210 outcome.content_hash, seeded.content_hash,
4211 "real change must advance content_hash",
4212 );
4213 assert!(
4214 !outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
4215 "real change must not surface UPDATE_NOOP",
4216 );
4217 }
4218
4219 #[test]
4220 fn update_entity_noop_preserves_expected_hash_across_chain() {
4221 let tmp = TempDir::new().unwrap();
4226 let (mut engine, seeded) = engine_with_seed(&tmp, "Chained Noops Subject");
4227 let (actor, client) = cli_actor();
4228
4229 let mut noop_sections = IndexMap::new();
4234 noop_sections.insert("identity".to_string(), "fixture identity body".to_string());
4235 for _ in 0..2 {
4236 let outcome = engine
4237 .update_entity(
4238 UpdateEntityArgs {
4239 anchors: Vec::new(),
4240 id: seeded.id.clone(),
4241 expected_hash: Some(seeded.content_hash.clone()),
4242 sections: noop_sections.clone(),
4243 append_sections: IndexMap::new(),
4244 patch_sections: IndexMap::new(),
4245 metadata: IndexMap::new(),
4246 metadata_unset: Vec::new(),
4247 declare_relations: Vec::new(),
4248 dry_run: false,
4249 relations_unset: Vec::new(),
4250 anchors_unset: Vec::new(),
4251 },
4252 actor,
4253 Some(&client),
4254 None,
4255 )
4256 .unwrap();
4257 assert_eq!(outcome.write_id, "");
4258 assert_eq!(outcome.content_hash, seeded.content_hash);
4259 }
4260
4261 let mut sections = IndexMap::new();
4264 sections.insert(
4265 "identity".to_string(),
4266 "third call: real change".to_string(),
4267 );
4268 let real = engine
4269 .update_entity(
4270 UpdateEntityArgs {
4271 anchors: Vec::new(),
4272 id: seeded.id.clone(),
4273 expected_hash: Some(seeded.content_hash.clone()),
4274 sections,
4275 append_sections: IndexMap::new(),
4276 patch_sections: IndexMap::new(),
4277 metadata: IndexMap::new(),
4278 metadata_unset: Vec::new(),
4279 declare_relations: Vec::new(),
4280 dry_run: false,
4281 relations_unset: Vec::new(),
4282 anchors_unset: Vec::new(),
4283 },
4284 actor,
4285 Some(&client),
4286 None,
4287 )
4288 .unwrap();
4289 assert!(!real.write_id.is_empty());
4290 assert_ne!(real.content_hash, seeded.content_hash);
4291 }
4292
4293 #[test]
4302 fn synthesis_gc_drops_auto_emitted_reference_when_body_link_removed() {
4303 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4307 use indexmap::IndexMap;
4308 use tempfile::TempDir;
4309
4310 let tmp = TempDir::new().unwrap();
4311 let mem_dir = tmp.path().to_path_buf();
4312 let writer = FilesystemMemWriter::new(mem_dir.clone());
4313 let mut engine = Engine::from_mounts(vec![(
4314 folder_mount("specs", mem_dir.clone()),
4315 Box::new(writer) as Box<dyn MemBackend>,
4316 )])
4317 .unwrap();
4318 engine.set_workspace_root(mem_dir.clone());
4319 let (actor, client) = cli_actor();
4320
4321 let target = engine
4322 .create_entity(
4323 empty_create_args("specs", "Target"),
4324 actor,
4325 Some(&client),
4326 None,
4327 )
4328 .unwrap();
4329 let mut sections: IndexMap<String, String> = IndexMap::new();
4332 sections.insert("identity".to_string(), "source identity".to_string());
4333 sections.insert(
4334 "purpose".to_string(),
4335 "see [[target]] for context".to_string(),
4336 );
4337 let source = engine
4338 .create_entity(
4339 CreateEntityArgs {
4340 anchors: Vec::new(),
4341 mem: "specs".to_string(),
4342 title: "Source".to_string(),
4343 entity_type: "spec".to_string(),
4344 sections,
4345 metadata: IndexMap::new(),
4346 relations: Vec::new(),
4347 dry_run: false,
4348 },
4349 actor,
4350 Some(&client),
4351 None,
4352 )
4353 .unwrap();
4354 assert!(
4355 engine
4356 .get_entity(&source.id)
4357 .unwrap()
4358 .relationships
4359 .iter()
4360 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4361 "create-time synthesis must emit REFERENCES → target",
4362 );
4363
4364 let mut new_sections: IndexMap<String, String> = IndexMap::new();
4367 new_sections.insert("purpose".to_string(), "no link any more".to_string());
4368 engine
4369 .update_entity(
4370 UpdateEntityArgs {
4371 anchors: Vec::new(),
4372 id: source.id.clone(),
4373 expected_hash: Some(source.content_hash.clone()),
4374 sections: new_sections,
4375 append_sections: IndexMap::new(),
4376 patch_sections: IndexMap::new(),
4377 metadata: IndexMap::new(),
4378 metadata_unset: Vec::new(),
4379 declare_relations: Vec::new(),
4380 dry_run: false,
4381 relations_unset: Vec::new(),
4382 anchors_unset: Vec::new(),
4383 },
4384 actor,
4385 Some(&client),
4386 None,
4387 )
4388 .expect("update must succeed; GC drops the now-orphan REFERENCES");
4389 let in_mem = engine.get_entity(&source.id).unwrap();
4390 assert!(
4391 !in_mem
4392 .relationships
4393 .iter()
4394 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4395 "GC must drop the auto-emitted REFERENCES after body link removal; got {:?}",
4396 in_mem.relationships,
4397 );
4398 }
4399
4400 #[test]
4401 fn update_gc_removes_orphan_stub_when_last_body_link_dropped() {
4402 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4410 use indexmap::IndexMap;
4411 use tempfile::TempDir;
4412
4413 let tmp = TempDir::new().unwrap();
4414 let mem_dir = tmp.path().to_path_buf();
4415 let writer = FilesystemMemWriter::new(mem_dir.clone());
4416 let mut engine = Engine::from_mounts(vec![(
4417 folder_mount("specs", mem_dir.clone()),
4418 Box::new(writer) as Box<dyn MemBackend>,
4419 )])
4420 .unwrap();
4421 engine.set_workspace_root(mem_dir.clone());
4422 let (actor, client) = cli_actor();
4423
4424 let ghost = crate::EntityId::new("specs", "ghost");
4425 let mut sections: IndexMap<String, String> = IndexMap::new();
4426 sections.insert("identity".to_string(), "source identity".to_string());
4427 sections.insert(
4428 "purpose".to_string(),
4429 "see [[ghost]] for context".to_string(),
4430 );
4431 let source = engine
4432 .create_entity(
4433 CreateEntityArgs {
4434 anchors: Vec::new(),
4435 mem: "specs".to_string(),
4436 title: "Source".to_string(),
4437 entity_type: "spec".to_string(),
4438 sections,
4439 metadata: IndexMap::new(),
4440 relations: Vec::new(),
4441 dry_run: false,
4442 },
4443 actor,
4444 Some(&client),
4445 None,
4446 )
4447 .unwrap();
4448 assert!(
4449 engine.store().contains(&ghost) && engine.get_entity(&ghost).unwrap().stub,
4450 "body wiki-link to an absent target must auto-stub it",
4451 );
4452 assert_eq!(
4453 engine.health().stub_count,
4454 1,
4455 "one stub before the link drop"
4456 );
4457
4458 let mut new_sections: IndexMap<String, String> = IndexMap::new();
4459 new_sections.insert("purpose".to_string(), "no link any more".to_string());
4460 let outcome = engine
4461 .update_entity(
4462 UpdateEntityArgs {
4463 anchors: Vec::new(),
4464 id: source.id.clone(),
4465 expected_hash: Some(source.content_hash.clone()),
4466 sections: new_sections,
4467 append_sections: IndexMap::new(),
4468 patch_sections: IndexMap::new(),
4469 metadata: IndexMap::new(),
4470 metadata_unset: Vec::new(),
4471 declare_relations: Vec::new(),
4472 dry_run: false,
4473 relations_unset: Vec::new(),
4474 anchors_unset: Vec::new(),
4475 },
4476 actor,
4477 Some(&client),
4478 None,
4479 )
4480 .expect("update must succeed and GC the now-orphan stub");
4481
4482 assert_eq!(
4483 outcome.orphan_stubs_removed,
4484 vec![ghost.clone()],
4485 "the update that dropped the last body link must report the GC'd stub",
4486 );
4487 assert!(
4488 !engine.store().contains(&ghost),
4489 "orphan stub must be gone from the in-memory store",
4490 );
4491 assert_eq!(
4492 engine.health().stub_count,
4493 0,
4494 "stub count decremented in-session"
4495 );
4496
4497 engine.reload_each_writable_mem().unwrap();
4501 assert!(
4502 !engine.store().contains(&ghost),
4503 "stub stays gone after reload-from-disk",
4504 );
4505 assert_eq!(
4506 engine.health().stub_count,
4507 0,
4508 "reloaded-from-disk store carries the same stub count as the in-session post-update state",
4509 );
4510 }
4511
4512 #[test]
4513 fn update_gc_noop_when_section_edit_changes_no_body_link() {
4514 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4519 use indexmap::IndexMap;
4520 use tempfile::TempDir;
4521
4522 let tmp = TempDir::new().unwrap();
4523 let mem_dir = tmp.path().to_path_buf();
4524 let writer = FilesystemMemWriter::new(mem_dir.clone());
4525 let mut engine = Engine::from_mounts(vec![(
4526 folder_mount("specs", mem_dir.clone()),
4527 Box::new(writer) as Box<dyn MemBackend>,
4528 )])
4529 .unwrap();
4530 engine.set_workspace_root(mem_dir.clone());
4531 let (actor, client) = cli_actor();
4532
4533 let ghost = crate::EntityId::new("specs", "ghost");
4534 let mut sections: IndexMap<String, String> = IndexMap::new();
4535 sections.insert("identity".to_string(), "original identity".to_string());
4536 sections.insert(
4537 "purpose".to_string(),
4538 "see [[ghost]] for context".to_string(),
4539 );
4540 let source = engine
4541 .create_entity(
4542 CreateEntityArgs {
4543 anchors: Vec::new(),
4544 mem: "specs".to_string(),
4545 title: "Source".to_string(),
4546 entity_type: "spec".to_string(),
4547 sections,
4548 metadata: IndexMap::new(),
4549 relations: Vec::new(),
4550 dry_run: false,
4551 },
4552 actor,
4553 Some(&client),
4554 None,
4555 )
4556 .unwrap();
4557 assert!(engine.store().contains(&ghost), "ghost stub materialised");
4558
4559 let mut edit: IndexMap<String, String> = IndexMap::new();
4562 edit.insert("identity".to_string(), "edited identity".to_string());
4563 let outcome = engine
4564 .update_entity(
4565 UpdateEntityArgs {
4566 anchors: Vec::new(),
4567 id: source.id.clone(),
4568 expected_hash: Some(source.content_hash.clone()),
4569 sections: edit,
4570 append_sections: IndexMap::new(),
4571 patch_sections: IndexMap::new(),
4572 metadata: IndexMap::new(),
4573 metadata_unset: Vec::new(),
4574 declare_relations: Vec::new(),
4575 dry_run: false,
4576 relations_unset: Vec::new(),
4577 anchors_unset: Vec::new(),
4578 },
4579 actor,
4580 Some(&client),
4581 None,
4582 )
4583 .expect("update must succeed");
4584 assert!(
4585 outcome.orphan_stubs_removed.is_empty(),
4586 "an edit that keeps every body wiki-link orphans nothing; got {:?}",
4587 outcome.orphan_stubs_removed,
4588 );
4589 assert!(
4590 engine.store().contains(&ghost),
4591 "the still-referenced stub survives the unrelated section edit",
4592 );
4593 }
4594
4595 #[test]
4596 fn update_gc_preserves_stub_with_surviving_referrer() {
4597 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4601 use indexmap::IndexMap;
4602 use tempfile::TempDir;
4603
4604 let tmp = TempDir::new().unwrap();
4605 let mem_dir = tmp.path().to_path_buf();
4606 let writer = FilesystemMemWriter::new(mem_dir.clone());
4607 let mut engine = Engine::from_mounts(vec![(
4608 folder_mount("specs", mem_dir.clone()),
4609 Box::new(writer) as Box<dyn MemBackend>,
4610 )])
4611 .unwrap();
4612 engine.set_workspace_root(mem_dir.clone());
4613 let (actor, client) = cli_actor();
4614
4615 let ghost = crate::EntityId::new("specs", "ghost");
4616 let make_with_link = |title: &str| {
4617 let mut sections: IndexMap<String, String> = IndexMap::new();
4618 sections.insert("identity".to_string(), format!("{title} identity"));
4619 sections.insert("purpose".to_string(), "see [[ghost]]".to_string());
4620 CreateEntityArgs {
4621 anchors: Vec::new(),
4622 mem: "specs".to_string(),
4623 title: title.to_string(),
4624 entity_type: "spec".to_string(),
4625 sections,
4626 metadata: IndexMap::new(),
4627 relations: Vec::new(),
4628 dry_run: false,
4629 }
4630 };
4631 let source_a = engine
4632 .create_entity(make_with_link("Source A"), actor, Some(&client), None)
4633 .unwrap();
4634 engine
4635 .create_entity(make_with_link("Source B"), actor, Some(&client), None)
4636 .unwrap();
4637 assert!(engine.store().contains(&ghost), "ghost stub materialised");
4638
4639 let mut drop_link: IndexMap<String, String> = IndexMap::new();
4641 drop_link.insert("purpose".to_string(), "no link here".to_string());
4642 let outcome = engine
4643 .update_entity(
4644 UpdateEntityArgs {
4645 anchors: Vec::new(),
4646 id: source_a.id.clone(),
4647 expected_hash: Some(source_a.content_hash.clone()),
4648 sections: drop_link,
4649 append_sections: IndexMap::new(),
4650 patch_sections: IndexMap::new(),
4651 metadata: IndexMap::new(),
4652 metadata_unset: Vec::new(),
4653 declare_relations: Vec::new(),
4654 dry_run: false,
4655 relations_unset: Vec::new(),
4656 anchors_unset: Vec::new(),
4657 },
4658 actor,
4659 Some(&client),
4660 None,
4661 )
4662 .expect("update must succeed");
4663 assert!(
4664 outcome.orphan_stubs_removed.is_empty(),
4665 "the stub keeps a referrer (source B), so nothing is GC'd; got {:?}",
4666 outcome.orphan_stubs_removed,
4667 );
4668 assert!(
4669 engine.store().contains(&ghost),
4670 "stub survives via the surviving referrer",
4671 );
4672 }
4673
4674 #[test]
4675 fn synthesis_gc_preserves_non_pointer_explicit_relation_across_body_update() {
4676 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
4685 use indexmap::IndexMap;
4686 use tempfile::TempDir;
4687
4688 let tmp = TempDir::new().unwrap();
4689 let mem_dir = tmp.path().to_path_buf();
4690 let writer = FilesystemMemWriter::new(mem_dir.clone());
4691 let mut engine = Engine::from_mounts(vec![(
4692 folder_mount("specs", mem_dir.clone()),
4693 Box::new(writer) as Box<dyn MemBackend>,
4694 )])
4695 .unwrap();
4696 engine.set_workspace_root(mem_dir.clone());
4697 let (actor, client) = cli_actor();
4698
4699 let target = engine
4700 .create_entity(
4701 empty_create_args("specs", "Target"),
4702 actor,
4703 Some(&client),
4704 None,
4705 )
4706 .unwrap();
4707 let source = engine
4708 .create_entity(
4709 empty_create_args("specs", "Source"),
4710 actor,
4711 Some(&client),
4712 None,
4713 )
4714 .unwrap();
4715
4716 let relate = engine
4718 .relate_entity(
4719 RelateEntityArgs {
4720 source: source.id.clone(),
4721 expected_hash: Some(source.content_hash.clone()),
4722 rel_type: "USES".to_string(),
4723 target: target.id.clone(),
4724 remove: false,
4725 description: None,
4726 dry_run: false,
4727 },
4728 actor,
4729 Some(&client),
4730 None,
4731 )
4732 .unwrap();
4733
4734 let mut sections: IndexMap<String, String> = IndexMap::new();
4737 sections.insert("purpose".to_string(), "unrelated edit".to_string());
4738 engine
4739 .update_entity(
4740 UpdateEntityArgs {
4741 anchors: Vec::new(),
4742 id: source.id.clone(),
4743 expected_hash: Some(relate.content_hash.clone()),
4744 sections,
4745 append_sections: IndexMap::new(),
4746 patch_sections: IndexMap::new(),
4747 metadata: IndexMap::new(),
4748 metadata_unset: Vec::new(),
4749 declare_relations: Vec::new(),
4750 dry_run: false,
4751 relations_unset: Vec::new(),
4752 anchors_unset: Vec::new(),
4753 },
4754 actor,
4755 Some(&client),
4756 None,
4757 )
4758 .expect("update must succeed");
4759 let in_mem = engine.get_entity(&source.id).unwrap();
4760 assert!(
4761 in_mem
4762 .relationships
4763 .iter()
4764 .any(|r| r.rel_type == "USES" && r.target == target.id),
4765 "explicit USES must survive an unrelated body update; got {:?}",
4766 in_mem.relationships,
4767 );
4768 }
4769
4770 #[test]
4771 fn synthesis_dedupes_repeated_body_links_to_same_target() {
4772 use crate::engine::UpdateEntityArgs;
4775 use indexmap::IndexMap;
4776 use tempfile::TempDir;
4777
4778 let tmp = TempDir::new().unwrap();
4779 let mem_dir = tmp.path().to_path_buf();
4780 let writer = FilesystemMemWriter::new(mem_dir.clone());
4781 let mut engine = Engine::from_mounts(vec![(
4782 folder_mount("specs", mem_dir.clone()),
4783 Box::new(writer) as Box<dyn MemBackend>,
4784 )])
4785 .unwrap();
4786 engine.set_workspace_root(mem_dir.clone());
4787 let (actor, client) = cli_actor();
4788
4789 let target = engine
4790 .create_entity(
4791 empty_create_args("specs", "Target"),
4792 actor,
4793 Some(&client),
4794 None,
4795 )
4796 .unwrap();
4797 let source = engine
4798 .create_entity(
4799 empty_create_args("specs", "Source"),
4800 actor,
4801 Some(&client),
4802 None,
4803 )
4804 .unwrap();
4805
4806 let mut sections: IndexMap<String, String> = IndexMap::new();
4807 sections.insert(
4808 "purpose".to_string(),
4809 "see [[target]] and again [[target]]".to_string(),
4810 );
4811 engine
4812 .update_entity(
4813 UpdateEntityArgs {
4814 anchors: Vec::new(),
4815 id: source.id.clone(),
4816 expected_hash: Some(source.content_hash.clone()),
4817 sections,
4818 append_sections: IndexMap::new(),
4819 patch_sections: IndexMap::new(),
4820 metadata: IndexMap::new(),
4821 metadata_unset: Vec::new(),
4822 declare_relations: Vec::new(),
4823 dry_run: false,
4824 relations_unset: Vec::new(),
4825 anchors_unset: Vec::new(),
4826 },
4827 actor,
4828 Some(&client),
4829 None,
4830 )
4831 .unwrap();
4832 let in_mem = engine.get_entity(&source.id).unwrap();
4833 let count = in_mem
4834 .relationships
4835 .iter()
4836 .filter(|r| r.rel_type == "REFERENCES" && r.target == target.id)
4837 .count();
4838 assert_eq!(
4839 count, 1,
4840 "dedupe must leave exactly one REFERENCES → target; got {:?}",
4841 in_mem.relationships,
4842 );
4843 }
4844
4845 #[test]
4846 fn synthesis_coexists_with_explicit_uses_to_same_target() {
4847 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
4852 use indexmap::IndexMap;
4853 use tempfile::TempDir;
4854
4855 let tmp = TempDir::new().unwrap();
4856 let mem_dir = tmp.path().to_path_buf();
4857 let writer = FilesystemMemWriter::new(mem_dir.clone());
4858 let mut engine = Engine::from_mounts(vec![(
4859 folder_mount("specs", mem_dir.clone()),
4860 Box::new(writer) as Box<dyn MemBackend>,
4861 )])
4862 .unwrap();
4863 engine.set_workspace_root(mem_dir.clone());
4864 let (actor, client) = cli_actor();
4865
4866 let target = engine
4867 .create_entity(
4868 empty_create_args("specs", "Target"),
4869 actor,
4870 Some(&client),
4871 None,
4872 )
4873 .unwrap();
4874 let source = engine
4875 .create_entity(
4876 empty_create_args("specs", "Source"),
4877 actor,
4878 Some(&client),
4879 None,
4880 )
4881 .unwrap();
4882 let relate = engine
4884 .relate_entity(
4885 RelateEntityArgs {
4886 source: source.id.clone(),
4887 expected_hash: Some(source.content_hash.clone()),
4888 rel_type: "USES".to_string(),
4889 target: target.id.clone(),
4890 remove: false,
4891 description: None,
4892 dry_run: false,
4893 },
4894 actor,
4895 Some(&client),
4896 None,
4897 )
4898 .unwrap();
4899 let mut sections: IndexMap<String, String> = IndexMap::new();
4901 sections.insert(
4902 "purpose".to_string(),
4903 "we also reference [[target]]".to_string(),
4904 );
4905 engine
4906 .update_entity(
4907 UpdateEntityArgs {
4908 anchors: Vec::new(),
4909 id: source.id.clone(),
4910 expected_hash: Some(relate.content_hash.clone()),
4911 sections,
4912 append_sections: IndexMap::new(),
4913 patch_sections: IndexMap::new(),
4914 metadata: IndexMap::new(),
4915 metadata_unset: Vec::new(),
4916 declare_relations: Vec::new(),
4917 dry_run: false,
4918 relations_unset: Vec::new(),
4919 anchors_unset: Vec::new(),
4920 },
4921 actor,
4922 Some(&client),
4923 None,
4924 )
4925 .unwrap();
4926 let in_mem = engine.get_entity(&source.id).unwrap();
4927 assert!(
4928 in_mem
4929 .relationships
4930 .iter()
4931 .any(|r| r.rel_type == "USES" && r.target == target.id),
4932 "USES must survive — synthesis dedupes on (rel_type, target)",
4933 );
4934 assert!(
4935 in_mem
4936 .relationships
4937 .iter()
4938 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4939 "REFERENCES must be synthesised even though USES already targets the same entity",
4940 );
4941 }
4942
4943 mod alias_synthesis_custom_schema {
4955 use std::path::Path;
4956
4957 use indexmap::IndexMap;
4958 use memstead_schema::SchemaRef;
4959 use tempfile::TempDir;
4960
4961 use crate::backend::MemBackend;
4962 use crate::engine::test_helpers::*;
4963 use crate::engine::{CreateEntityArgs, Engine, EngineError, UpdateEntityArgs};
4964 use crate::storage::FilesystemMemWriter;
4965 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4966
4967 const TYPE_BODY: &str = r#"description: t
4968when_to_use: tests
4969sections:
4970 - key: body
4971 heading: Body
4972 required: true
4973 search_weight: 10.0
4974 catch_all: true
4975 write_rules: []
4976metadata_fields: []
4977title_weight: 100.0
4978text_fields:
4979 - body
4980hierarchy_relationship: _default
4981no_self_loop_relationships: []
4982updatable_fields:
4983 - title
4984 - body
4985health_required_fields:
4986 - body
4987staleness_threshold_days: 90
4988write_rules: []
4989"#;
4990
4991 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
4992 let dir = root.join(name);
4993 std::fs::create_dir_all(dir.join("types")).unwrap();
4994 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
4995 for (type_name, body) in types {
4996 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
4997 }
4998 }
4999
5000 fn make_type_yaml(name: &str) -> String {
5001 format!("name: {name}\n{TYPE_BODY}")
5002 }
5003
5004 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
5005 Mount {
5006 mem: mem.to_string(),
5007 schema: Some(pin),
5008 storage: MountStorage::Folder { path },
5009 capability: MountCapability::Write,
5010 lifecycle: MountLifecycle::Eager,
5011 cross_linkable: true,
5012 migration_target: None,
5013 }
5014 }
5015
5016 fn engine_with_schema(
5017 manifest: &str,
5018 type_yaml_name: &str,
5019 schema_name: &str,
5020 schema_version: semver::Version,
5021 ) -> (Engine, TempDir) {
5022 let tmp = TempDir::new().unwrap();
5023 let schemas_dir = tmp.path().join("schemas");
5024 std::fs::create_dir_all(&schemas_dir).unwrap();
5025 write_schema_files(
5026 &schemas_dir,
5027 schema_name,
5028 manifest,
5029 &[(type_yaml_name, &make_type_yaml(type_yaml_name))],
5030 );
5031 let mem_dir = tmp.path().join("mem");
5032 std::fs::create_dir_all(&mem_dir).unwrap();
5033 let writer = FilesystemMemWriter::new(mem_dir.clone());
5034 let pin = SchemaRef::new(schema_name, schema_version);
5035 let mount = folder_mount_with_pin("v", mem_dir, pin);
5036 let mut engine = Engine::from_mounts_with_schemas_dir(
5037 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
5038 Some(&schemas_dir),
5039 )
5040 .expect("engine with custom schema constructs");
5041 engine.set_workspace_root(tmp.path().to_path_buf());
5042 (engine, tmp)
5043 }
5044
5045 #[test]
5046 fn non_references_alias_pointer_emits_named_rel_type_from_body_link() {
5047 let manifest = r#"name: aliased
5052version: 0.1.0
5053description: alias-synthesis fixture using a non-REFERENCES pointer
5054when_to_use: tests prove the engine does not hard-code REFERENCES
5055types:
5056 - doc
5057relationships:
5058 mode: strict
5059 definitions:
5060 - name: CITES
5061 description: Citation — auto-emitted from body wiki-links
5062 default_weight: 0.5
5063 - name: PART_OF
5064 description: Hierarchy
5065 default_weight: 3.0
5066 acyclic: true
5067 - name: _default
5068 description: Fallback
5069 default_weight: 1.0
5070alias_target_rel_type: CITES
5071community:
5072 resolution: 1.0
5073 seed: 42
5074"#;
5075 let (mut engine, _tmp) =
5076 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5077 let (actor, client) = cli_actor();
5078
5079 let target = engine
5080 .create_entity(
5081 CreateEntityArgs {
5082 anchors: Vec::new(),
5083 mem: "v".to_string(),
5084 title: "Target".to_string(),
5085 entity_type: "doc".to_string(),
5086 sections: IndexMap::from_iter([(
5087 "body".to_string(),
5088 "target body".to_string(),
5089 )]),
5090 metadata: IndexMap::new(),
5091 relations: Vec::new(),
5092 dry_run: false,
5093 },
5094 actor,
5095 Some(&client),
5096 None,
5097 )
5098 .unwrap();
5099
5100 let mut sections: IndexMap<String, String> = IndexMap::new();
5101 sections.insert("body".to_string(), "see [[target]]".to_string());
5102 let source = engine
5103 .create_entity(
5104 CreateEntityArgs {
5105 anchors: Vec::new(),
5106 mem: "v".to_string(),
5107 title: "Source".to_string(),
5108 entity_type: "doc".to_string(),
5109 sections,
5110 metadata: IndexMap::new(),
5111 relations: Vec::new(),
5112 dry_run: false,
5113 },
5114 actor,
5115 Some(&client),
5116 None,
5117 )
5118 .expect("create must succeed; CITES is auto-emitted by synthesis");
5119
5120 let in_mem = engine.get_entity(&source.id).unwrap();
5121 assert!(
5122 in_mem
5123 .relationships
5124 .iter()
5125 .any(|r| r.rel_type == "CITES" && r.target == target.id),
5126 "synthesis must emit CITES (the pointer rel-type), not REFERENCES; got {:?}",
5127 in_mem.relationships,
5128 );
5129 assert!(
5130 !in_mem
5131 .relationships
5132 .iter()
5133 .any(|r| r.rel_type == "REFERENCES"),
5134 "engine must not hard-code REFERENCES — pointer rel-type is CITES; got {:?}",
5135 in_mem.relationships,
5136 );
5137 }
5138
5139 #[test]
5140 fn no_pointer_schema_refuses_unbacked_body_wiki_link() {
5141 let manifest = r#"name: no-alias
5146version: 0.1.0
5147description: schema without alias_target_rel_type pointer
5148when_to_use: tests prove strict validator still fires for opt-out schemas
5149types:
5150 - doc
5151relationships:
5152 mode: strict
5153 definitions:
5154 - name: USES
5155 description: Use
5156 default_weight: 1.0
5157 - name: PART_OF
5158 description: Hierarchy
5159 default_weight: 3.0
5160 acyclic: true
5161 - name: _default
5162 description: Fallback
5163 default_weight: 1.0
5164community:
5165 resolution: 1.0
5166 seed: 42
5167"#;
5168 let (mut engine, _tmp) =
5169 engine_with_schema(manifest, "doc", "no-alias", semver::Version::new(0, 1, 0));
5170 let (actor, client) = cli_actor();
5171
5172 let target = engine
5173 .create_entity(
5174 CreateEntityArgs {
5175 anchors: Vec::new(),
5176 mem: "v".to_string(),
5177 title: "Target".to_string(),
5178 entity_type: "doc".to_string(),
5179 sections: IndexMap::from_iter([(
5180 "body".to_string(),
5181 "target body".to_string(),
5182 )]),
5183 metadata: IndexMap::new(),
5184 relations: Vec::new(),
5185 dry_run: false,
5186 },
5187 actor,
5188 Some(&client),
5189 None,
5190 )
5191 .unwrap();
5192 let source = engine
5193 .create_entity(
5194 CreateEntityArgs {
5195 anchors: Vec::new(),
5196 mem: "v".to_string(),
5197 title: "Source".to_string(),
5198 entity_type: "doc".to_string(),
5199 sections: IndexMap::from_iter([(
5200 "body".to_string(),
5201 "source body".to_string(),
5202 )]),
5203 metadata: IndexMap::new(),
5204 relations: Vec::new(),
5205 dry_run: false,
5206 },
5207 actor,
5208 Some(&client),
5209 None,
5210 )
5211 .unwrap();
5212
5213 let mut sections: IndexMap<String, String> = IndexMap::new();
5217 sections.insert("body".to_string(), "see [[target]]".to_string());
5218 let err = engine
5219 .update_entity(
5220 UpdateEntityArgs {
5221 anchors: Vec::new(),
5222 id: source.id.clone(),
5223 expected_hash: Some(source.content_hash.clone()),
5224 sections,
5225 append_sections: IndexMap::new(),
5226 patch_sections: IndexMap::new(),
5227 metadata: IndexMap::new(),
5228 metadata_unset: Vec::new(),
5229 declare_relations: Vec::new(),
5230 dry_run: false,
5231 relations_unset: Vec::new(),
5232 anchors_unset: Vec::new(),
5233 },
5234 actor,
5235 Some(&client),
5236 None,
5237 )
5238 .unwrap_err();
5239 match err {
5240 EngineError::WikiLinkWithoutRelation { from_id, missing } => {
5241 assert_eq!(from_id, source.id.to_string());
5242 assert_eq!(missing.len(), 1);
5243 assert_eq!(missing[0].section_key, "body");
5244 assert_eq!(missing[0].target_id, target.id.to_string());
5245 }
5246 other => panic!(
5247 "no-pointer schema must refuse with WikiLinkWithoutRelation; got {other:?}"
5248 ),
5249 }
5250 }
5251
5252 #[test]
5261 fn natural_form_body_wiki_link_refuses_with_typed_envelope() {
5262 let manifest = r#"name: aliased
5263version: 0.1.0
5264description: alias-synthesis fixture
5265when_to_use: tests prove strict wiki-link grammar at mutation entry
5266types:
5267 - doc
5268relationships:
5269 mode: strict
5270 definitions:
5271 - name: REFERENCES
5272 description: Reference — auto-emitted from body wiki-links
5273 default_weight: 0.5
5274 - name: PART_OF
5275 description: Hierarchy
5276 default_weight: 3.0
5277 acyclic: true
5278 - name: _default
5279 description: Fallback
5280 default_weight: 1.0
5281alias_target_rel_type: REFERENCES
5282community:
5283 resolution: 1.0
5284 seed: 42
5285"#;
5286 let (mut engine, _tmp) =
5287 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5288 let (actor, client) = cli_actor();
5289
5290 let mut sections: IndexMap<String, String> = IndexMap::new();
5291 sections.insert("body".to_string(), "see [[Knowledge Graph]]".to_string());
5292 let err = engine
5293 .create_entity(
5294 CreateEntityArgs {
5295 anchors: Vec::new(),
5296 mem: "v".to_string(),
5297 title: "Source".to_string(),
5298 entity_type: "doc".to_string(),
5299 sections,
5300 metadata: IndexMap::new(),
5301 relations: Vec::new(),
5302 dry_run: false,
5303 },
5304 actor,
5305 Some(&client),
5306 None,
5307 )
5308 .unwrap_err();
5309 match err {
5310 EngineError::InvalidWikiLinkTarget {
5311 raw,
5312 suggested,
5313 section,
5314 link_source,
5315 ..
5316 } => {
5317 assert_eq!(raw, "Knowledge Graph");
5318 assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
5319 assert_eq!(section, "body");
5320 assert_eq!(link_source, "body_link");
5321 }
5322 other => panic!(
5323 "natural-form body wiki-link must refuse with InvalidWikiLinkTarget; got {other:?}"
5324 ),
5325 }
5326 }
5327
5328 #[test]
5334 fn tier_two_bad_mem_prefix_refuses_with_distinct_envelope() {
5335 let manifest = r#"name: aliased
5336version: 0.1.0
5337description: alias-synthesis fixture
5338when_to_use: tests prove strict mem-prefix grammar at mutation entry
5339types:
5340 - doc
5341relationships:
5342 mode: strict
5343 definitions:
5344 - name: REFERENCES
5345 description: Reference
5346 default_weight: 0.5
5347 - name: PART_OF
5348 description: Hierarchy
5349 default_weight: 3.0
5350 acyclic: true
5351 - name: _default
5352 description: Fallback
5353 default_weight: 1.0
5354alias_target_rel_type: REFERENCES
5355community:
5356 resolution: 1.0
5357 seed: 42
5358"#;
5359 let (mut engine, _tmp) =
5360 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5361 let (actor, client) = cli_actor();
5362
5363 let mut sections: IndexMap<String, String> = IndexMap::new();
5364 sections.insert("body".to_string(), "see [[Other Mem:foo]]".to_string());
5365 let err = engine
5366 .create_entity(
5367 CreateEntityArgs {
5368 anchors: Vec::new(),
5369 mem: "v".to_string(),
5370 title: "Source".to_string(),
5371 entity_type: "doc".to_string(),
5372 sections,
5373 metadata: IndexMap::new(),
5374 relations: Vec::new(),
5375 dry_run: false,
5376 },
5377 actor,
5378 Some(&client),
5379 None,
5380 )
5381 .unwrap_err();
5382 match err {
5383 EngineError::InvalidWikiLinkMem { raw, section, .. } => {
5384 assert_eq!(raw, "Other Mem");
5385 assert_eq!(section, "body");
5386 }
5387 other => panic!(
5388 "Tier-2 bad mem prefix must refuse with InvalidWikiLinkMem; got {other:?}"
5389 ),
5390 }
5391 }
5392
5393 #[test]
5400 fn hierarchical_dash_form_body_link_refuses_with_colon_suggestion() {
5401 let manifest = r#"name: aliased
5402version: 0.1.0
5403description: alias-synthesis fixture
5404when_to_use: tests prove hierarchical dash-form refusal at mutation entry
5405types:
5406 - doc
5407relationships:
5408 mode: strict
5409 definitions:
5410 - name: REFERENCES
5411 description: Reference — auto-emitted from body wiki-links
5412 default_weight: 0.5
5413 - name: PART_OF
5414 description: Hierarchy
5415 default_weight: 3.0
5416 acyclic: true
5417 - name: _default
5418 description: Fallback
5419 default_weight: 1.0
5420alias_target_rel_type: REFERENCES
5421community:
5422 resolution: 1.0
5423 seed: 42
5424"#;
5425 let (mut engine, _tmp) =
5426 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5427 let (actor, client) = cli_actor();
5428
5429 let mut sections: IndexMap<String, String> = IndexMap::new();
5430 sections.insert(
5431 "body".to_string(),
5432 "see [[team/sub-mem--target]]".to_string(),
5433 );
5434 let err = engine
5435 .create_entity(
5436 CreateEntityArgs {
5437 anchors: Vec::new(),
5438 mem: "v".to_string(),
5439 title: "Source".to_string(),
5440 entity_type: "doc".to_string(),
5441 sections,
5442 metadata: IndexMap::new(),
5443 relations: Vec::new(),
5444 dry_run: false,
5445 },
5446 actor,
5447 Some(&client),
5448 None,
5449 )
5450 .unwrap_err();
5451 match err {
5452 EngineError::InvalidWikiLinkTarget {
5453 raw,
5454 suggested,
5455 section,
5456 link_source,
5457 ..
5458 } => {
5459 assert_eq!(raw, "team/sub-mem--target");
5460 assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
5461 assert_eq!(section, "body");
5462 assert_eq!(link_source, "body_link");
5463 }
5464 other => panic!(
5465 "hierarchical dash-form body link must refuse with InvalidWikiLinkTarget; got {other:?}"
5466 ),
5467 }
5468
5469 let listed = engine.store().all_entities().collect::<Vec<_>>();
5472 assert!(
5473 listed.is_empty(),
5474 "refused create must not leave any entity behind, got: {listed:?}"
5475 );
5476 }
5477 }
5478
5479 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";
5488
5489 fn repair_engine() -> (TempDir, Engine) {
5490 let tmp = TempDir::new().unwrap();
5491 let mem_dir = tmp.path().to_path_buf();
5492 std::fs::write(
5493 mem_dir.join("anchor.md"),
5494 "---\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",
5495 )
5496 .unwrap();
5497 std::fs::write(mem_dir.join("drifted.md"), DRIFTED_MD).unwrap();
5498 let writer = FilesystemMemWriter::new(mem_dir.clone());
5499 let engine = Engine::from_mounts(vec![(
5500 folder_mount("specs", mem_dir),
5501 Box::new(writer) as Box<dyn MemBackend>,
5502 )])
5503 .unwrap();
5504 (tmp, engine)
5505 }
5506
5507 fn repair_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5508 UpdateEntityArgs {
5509 anchors: Vec::new(),
5510 id,
5511 expected_hash: hash,
5512 sections: IndexMap::new(),
5513 append_sections: IndexMap::new(),
5514 patch_sections: IndexMap::new(),
5515 metadata: IndexMap::new(),
5516 metadata_unset: Vec::new(),
5517 declare_relations: Vec::new(),
5518 dry_run: false,
5519 relations_unset: vec![crate::ops::RelationUnsetArg {
5520 rel_type: "USES".to_string(),
5521 target: EntityId::new("specs", "anchor"),
5522 }],
5523 anchors_unset: Vec::new(),
5524 }
5525 }
5526
5527 #[test]
5532 fn relations_unset_on_conformant_entity_refuses_repair_not_needed() {
5533 let (_tmp, mut engine) = repair_engine();
5534 let anchor = EntityId::new("specs", "anchor");
5537 let drifted = EntityId::new("specs", "drifted");
5538 engine
5539 .relate_entity(
5540 RelateEntityArgs {
5541 source: anchor.clone(),
5542 expected_hash: None,
5543 rel_type: "USES".to_string(),
5544 target: drifted.clone(),
5545 remove: false,
5546 description: None,
5547 dry_run: false,
5548 },
5549 Actor::Cli,
5550 None,
5551 None,
5552 )
5553 .expect("relate on conformant entity works");
5554 let mut args = repair_args(anchor.clone(), None);
5555 args.relations_unset[0].target = drifted.clone();
5556 let err = engine
5557 .update_entity(args, Actor::Cli, None, None)
5558 .unwrap_err();
5559 match err {
5560 EngineError::RepairNotNeeded { id, recovery } => {
5561 assert_eq!(id, anchor.to_string());
5562 assert!(
5563 recovery.contains("memstead_relate"),
5564 "recovery must point at the focused tool; got {recovery}"
5565 );
5566 }
5567 other => panic!("expected RepairNotNeeded, got {other:?}"),
5568 }
5569 let entity = engine.store().get(&anchor).unwrap();
5571 assert!(
5572 entity.relationships.iter().any(|r| r.target == drifted),
5573 "gate must not modify the entity"
5574 );
5575 }
5576
5577 #[test]
5582 fn relations_unset_repairs_non_conformant_entity_atomically() {
5583 let (_tmp, mut engine) = repair_engine();
5584 let drifted = EntityId::new("specs", "drifted");
5585 let pre = engine.conformance_findings("specs", None).unwrap();
5587 assert!(
5588 pre.iter().any(|f| f.id == drifted.to_string()),
5589 "fixture must lint non-conformant; got {pre:?}"
5590 );
5591 let mut args = repair_args(drifted.clone(), None);
5592 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
5593 engine
5594 .update_entity(args, Actor::Cli, None, None)
5595 .expect("repair update lands");
5596 let entity = engine.store().get(&drifted).unwrap();
5597 assert!(
5598 entity.relationships.is_empty(),
5599 "relation must be removed; got {:?}",
5600 entity.relationships
5601 );
5602 assert!(
5603 !entity.metadata.contains_key("zzz_bogus_field"),
5604 "conformance break must be repaired in the same update"
5605 );
5606 let post = engine.conformance_findings("specs", None).unwrap();
5607 assert!(
5608 post.iter().all(|f| f.id != drifted.to_string()),
5609 "post-repair entity must be conformant; got {post:?}"
5610 );
5611 }
5612
5613 #[test]
5617 fn relations_unset_post_state_must_still_validate() {
5618 let (_tmp, mut engine) = repair_engine();
5619 let drifted = EntityId::new("specs", "drifted");
5620 let mut args = repair_args(drifted.clone(), None);
5621 args.sections = IndexMap::from_iter([("nonexistent_section".to_string(), "x".to_string())]);
5624 let err = engine
5625 .update_entity(args, Actor::Cli, None, None)
5626 .unwrap_err();
5627 assert_eq!(
5628 err.code(),
5629 "UNKNOWN_SECTION",
5630 "strict-write post-condition must hold during repair; got {err:?}"
5631 );
5632 let entity = engine.store().get(&drifted).unwrap();
5634 assert!(
5635 !entity.relationships.is_empty(),
5636 "refused repair must not partially apply"
5637 );
5638 }
5639
5640 #[test]
5643 fn relations_unset_absent_pair_is_silent_noop() {
5644 let (_tmp, mut engine) = repair_engine();
5645 let drifted = EntityId::new("specs", "drifted");
5646 let mut args = repair_args(drifted.clone(), None);
5647 args.relations_unset[0].rel_type = "NEVER_DECLARED".to_string();
5648 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
5650 engine
5651 .update_entity(args, Actor::Cli, None, None)
5652 .expect("absent pair no-ops, update lands");
5653 let entity = engine.store().get(&drifted).unwrap();
5654 assert_eq!(
5655 entity.relationships.len(),
5656 1,
5657 "the USES relation must survive an unmatched unset"
5658 );
5659 }
5660
5661 fn anchor_input(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
5664 crate::anchor::AnchorInput {
5665 artifact: Some(artifact.to_string()),
5666 grain: Some("file".to_string()),
5667 class: Some("anchored".to_string()),
5668 hash: Some(hash.to_string()),
5669 hash_stability: Some("stable".to_string()),
5670 ..Default::default()
5671 }
5672 }
5673
5674 fn anchor_unset(artifact: &str) -> crate::anchor::AnchorUnsetInput {
5675 crate::anchor::AnchorUnsetInput {
5676 artifact: Some(artifact.to_string()),
5677 grain: None,
5678 class: None,
5679 }
5680 }
5681
5682 fn anchor_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5684 UpdateEntityArgs {
5685 anchors: Vec::new(),
5686 anchors_unset: Vec::new(),
5687 id,
5688 expected_hash: hash,
5689 sections: IndexMap::new(),
5690 append_sections: IndexMap::new(),
5691 patch_sections: IndexMap::new(),
5692 metadata: IndexMap::new(),
5693 metadata_unset: Vec::new(),
5694 declare_relations: Vec::new(),
5695 dry_run: false,
5696 relations_unset: Vec::new(),
5697 }
5698 }
5699
5700 fn anchored_engine() -> (Engine, TempDir, EntityId, String) {
5703 let tmp = TempDir::new().unwrap();
5704 let mem_dir = tmp.path().to_path_buf();
5705 let writer = FilesystemMemWriter::new(mem_dir.clone());
5706 let mut engine = Engine::from_mounts(vec![(
5707 folder_mount("specs", mem_dir),
5708 Box::new(writer) as Box<dyn MemBackend>,
5709 )])
5710 .unwrap();
5711 let (actor, client) = cli_actor();
5712 let mut args = empty_create_args("specs", "Anchored");
5713 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
5714 let created = engine
5715 .create_entity(args, actor, Some(&client), None)
5716 .unwrap();
5717 let id = EntityId::new("specs", "anchored");
5718 assert_eq!(engine.entity_anchors(&id).len(), 2);
5719 (engine, tmp, id, created.content_hash)
5720 }
5721
5722 #[test]
5727 fn update_anchors_merge_appends_and_replaces_by_triple() {
5728 let (mut engine, _tmp, id, hash) = anchored_engine();
5729 let (actor, client) = cli_actor();
5730
5731 let mut args = anchor_args(id.clone(), Some(hash));
5733 args.anchors = vec![anchor_input("c.rs", "h-c")];
5734 let out = engine
5735 .update_entity(args, actor, Some(&client), None)
5736 .unwrap();
5737 let anchors = engine.entity_anchors(&id);
5738 assert_eq!(anchors.len(), 3, "N existing + 1 new = N+1");
5739 assert_eq!(anchors[0].artifact, "a.rs");
5740 assert_eq!(anchors[0].hash.as_deref(), Some("h-a"));
5741 assert_eq!(anchors[1].artifact, "b.rs");
5742 assert_eq!(anchors[2].artifact, "c.rs");
5743 assert!(!engine.anchors_referencing_artifact("a.rs").is_empty());
5744 assert!(!engine.anchors_referencing_artifact("c.rs").is_empty());
5745
5746 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5748 args.anchors = vec![anchor_input("a.rs", "h-a2")];
5749 engine
5750 .update_entity(args, actor, Some(&client), None)
5751 .unwrap();
5752 let anchors = engine.entity_anchors(&id);
5753 assert_eq!(anchors.len(), 3);
5754 assert_eq!(anchors[0].artifact, "a.rs");
5755 assert_eq!(anchors[0].hash.as_deref(), Some("h-a2"));
5756 assert_eq!(anchors[1].hash.as_deref(), Some("h-b"), "b untouched");
5757 assert_eq!(anchors[2].hash.as_deref(), Some("h-c"), "c untouched");
5758 }
5759
5760 #[test]
5764 fn update_anchors_full_resend_and_absent_are_noops_on_stored_set() {
5765 let (mut engine, tmp, id, hash) = anchored_engine();
5766 let (actor, client) = cli_actor();
5767 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
5768 let before = std::fs::read(&sidecar_path).unwrap();
5769
5770 let mut args = anchor_args(id.clone(), Some(hash));
5772 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
5773 let out = engine
5774 .update_entity(args, actor, Some(&client), None)
5775 .unwrap();
5776 assert_eq!(
5777 std::fs::read(&sidecar_path).unwrap(),
5778 before,
5779 "full re-send keeps the stored bytes"
5780 );
5781
5782 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5784 args.sections
5785 .insert("identity".to_string(), "changed body".to_string());
5786 engine
5787 .update_entity(args, actor, Some(&client), None)
5788 .unwrap();
5789 assert_eq!(
5790 std::fs::read(&sidecar_path).unwrap(),
5791 before,
5792 "an anchorless update never touches the stored set"
5793 );
5794 }
5795
5796 #[test]
5801 fn update_anchors_unset_bare_narrowed_idempotent_and_unset_first() {
5802 let (mut engine, _tmp, id, hash) = anchored_engine();
5803 let (actor, client) = cli_actor();
5804
5805 let mut span = anchor_input("a.rs", "h-span");
5807 span.grain = Some("span".to_string());
5808 let mut args = anchor_args(id.clone(), Some(hash));
5809 args.anchors = vec![span];
5810 let out = engine
5811 .update_entity(args, actor, Some(&client), None)
5812 .unwrap();
5813 assert_eq!(engine.entity_anchors(&id).len(), 3);
5814
5815 let mut narrowed = anchor_unset("a.rs");
5817 narrowed.grain = Some("span".to_string());
5818 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5819 args.anchors_unset = vec![narrowed];
5820 let out = engine
5821 .update_entity(args, actor, Some(&client), None)
5822 .unwrap();
5823 let anchors = engine.entity_anchors(&id);
5824 assert_eq!(anchors.len(), 2);
5825 assert!(
5826 anchors
5827 .iter()
5828 .all(|a| a.grain == crate::anchor::AnchorGrain::File)
5829 );
5830
5831 let mut args = anchor_args(id.clone(), Some(out.content_hash.clone()));
5833 args.anchors_unset = vec![anchor_unset("never-there.rs")];
5834 engine
5835 .update_entity(args, actor, Some(&client), None)
5836 .expect("unset of a nonexistent target is a no-op, not an error");
5837 assert_eq!(engine.entity_anchors(&id).len(), 2);
5838
5839 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5842 args.anchors_unset = vec![anchor_unset("a.rs")];
5843 args.anchors = vec![anchor_input("a.rs", "h-a-fresh")];
5844 engine
5845 .update_entity(args, actor, Some(&client), None)
5846 .unwrap();
5847 let anchors = engine.entity_anchors(&id);
5848 assert_eq!(anchors.len(), 2);
5849 assert_eq!(anchors[0].artifact, "b.rs", "b.rs untouched throughout");
5850 assert_eq!(anchors[1].hash.as_deref(), Some("h-a-fresh"));
5851 }
5852
5853 #[test]
5860 fn a_payload_naming_one_triple_twice_is_refused() {
5861 let (mut engine, _tmp, id, hash) = anchored_engine();
5862 let (actor, client) = cli_actor();
5863
5864 let mut args = anchor_args(id.clone(), Some(hash.clone()));
5865 args.anchors = vec![
5866 anchor_input("a.rs", "h-first"),
5867 anchor_input("a.rs", "h-second"),
5868 ];
5869 let err = engine
5870 .update_entity(args, actor, Some(&client), None)
5871 .expect_err("the repeated triple must refuse");
5872 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
5873 assert!(
5874 format!("{err}").contains("more than once"),
5875 "the refusal names the collapse: {err}"
5876 );
5877
5878 assert_eq!(engine.entity_anchors(&id).len(), 2);
5880
5881 let mut span = anchor_input("a.rs", "h-span");
5884 span.grain = Some("span".to_string());
5885 let mut args = anchor_args(id.clone(), Some(hash));
5886 args.anchors = vec![anchor_input("a.rs", "h-file"), span];
5887 engine
5888 .update_entity(args, actor, Some(&client), None)
5889 .expect("two grains on one artifact are two rows");
5890 assert_eq!(engine.entity_anchors(&id).len(), 3);
5891 }
5892
5893 #[test]
5897 fn a_re_pin_without_a_hash_keeps_the_stored_baseline() {
5898 let (mut engine, _tmp, id, hash) = anchored_engine();
5899 let (actor, client) = cli_actor();
5900
5901 let mut hashless = anchor_input("a.rs", "");
5902 hashless.hash = None;
5903 let mut args = anchor_args(id.clone(), Some(hash));
5904 args.anchors = vec![hashless];
5905 engine
5906 .update_entity(args, actor, Some(&client), None)
5907 .unwrap();
5908
5909 let kept = engine
5910 .entity_anchors(&id)
5911 .into_iter()
5912 .find(|a| a.artifact == "a.rs")
5913 .expect("the row is still there");
5914 assert_eq!(
5915 kept.hash.as_deref(),
5916 Some("h-a"),
5917 "the baseline the re-pin did not mention survives it"
5918 );
5919 }
5920
5921 #[test]
5925 fn update_anchor_only_and_unset_only_commit_without_hash_movement() {
5926 let (mut engine, _tmp, id, hash) = anchored_engine();
5927 let (actor, client) = cli_actor();
5928
5929 let mut args = anchor_args(id.clone(), Some(hash.clone()));
5930 args.anchors_unset = vec![anchor_unset("b.rs")];
5931 let out = engine
5932 .update_entity(args, actor, Some(&client), None)
5933 .unwrap();
5934 assert!(
5935 !out.write_id.is_empty(),
5936 "unset-only update commits the sidecar"
5937 );
5938 assert_eq!(out.content_hash, hash, "anchors never move `_hash`");
5939 assert_eq!(engine.entity_anchors(&id).len(), 1);
5940
5941 let err = engine
5944 .update_entity(
5945 anchor_args(id.clone(), Some(hash)),
5946 actor,
5947 Some(&client),
5948 None,
5949 )
5950 .unwrap_err();
5951 assert!(matches!(err, EngineError::EmptyUpdate { .. }));
5952 }
5953
5954 #[test]
5962 fn anchor_only_update_across_second_boundary_never_moves_hash() {
5963 let (mut engine, _tmp, id, hash) = anchored_engine();
5964 let (actor, client) = cli_actor();
5965
5966 let t0 = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_778_243_696);
5967 engine.set_mutation_clock(std::sync::Arc::new(move || t0));
5968 let mut args = anchor_args(id.clone(), Some(hash));
5971 args.metadata = [("level".to_string(), "M1".to_string())]
5972 .into_iter()
5973 .collect();
5974 let restamped = engine
5975 .update_entity(args, actor, Some(&client), None)
5976 .unwrap();
5977
5978 let t1 = t0 + std::time::Duration::from_secs(1);
5980 engine.set_mutation_clock(std::sync::Arc::new(move || t1));
5981 let mut args = anchor_args(id.clone(), Some(restamped.content_hash.clone()));
5982 args.anchors = vec![anchor_input("c.rs", "h-c")];
5983 let out = engine
5984 .update_entity(args, actor, Some(&client), None)
5985 .unwrap();
5986 assert!(!out.write_id.is_empty(), "anchor-only update commits");
5987 assert_eq!(
5988 out.content_hash, restamped.content_hash,
5989 "anchors never move `_hash`, even across a second boundary"
5990 );
5991 let entity = engine.store().get(&id).unwrap();
5993 assert_eq!(
5994 entity
5995 .metadata
5996 .get("last_modified")
5997 .and_then(|v| v.as_str()),
5998 Some("2026-05-08T12:34:56Z"),
5999 "anchor-only update must not restamp last_modified"
6000 );
6001 }
6002
6003 #[test]
6007 fn malformed_anchor_unset_refuses_and_nothing_is_written() {
6008 let (mut engine, tmp, id, hash) = anchored_engine();
6009 let (actor, client) = cli_actor();
6010 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
6011 let before = std::fs::read(&sidecar_path).unwrap();
6012
6013 let mut bad = anchor_unset("a.rs");
6014 bad.grain = Some("paragraph".to_string()); let mut args = anchor_args(id.clone(), Some(hash));
6016 args.anchors_unset = vec![bad];
6017 args.anchors = vec![anchor_input("c.rs", "h-c")];
6019 let err = engine
6020 .update_entity(args, actor, Some(&client), None)
6021 .unwrap_err();
6022 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
6023 assert_eq!(engine.entity_anchors(&id).len(), 2, "no partial apply");
6024 assert_eq!(std::fs::read(&sidecar_path).unwrap(), before);
6025 }
6026
6027 fn bare_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
6031 UpdateEntityArgs {
6032 anchors: Vec::new(),
6033 anchors_unset: Vec::new(),
6034 id,
6035 expected_hash: hash,
6036 sections: IndexMap::new(),
6037 append_sections: IndexMap::new(),
6038 patch_sections: IndexMap::new(),
6039 metadata: IndexMap::new(),
6040 metadata_unset: Vec::new(),
6041 declare_relations: Vec::new(),
6042 dry_run: false,
6043 relations_unset: Vec::new(),
6044 }
6045 }
6046
6047 #[test]
6055 fn reserved_key_unset_repairs_smuggled_entity_and_set_stays_refused() {
6056 let tmp = TempDir::new().unwrap();
6057 let mem_dir = tmp.path().to_path_buf();
6058 std::fs::write(
6060 mem_dir.join("smuggled.md"),
6061 "---\ntype: spec\nmem: wrong-mem\nid: bogus-id\n---\n# Smuggled\n\n## Identity\n\nsmuggled identity.\n\n## Purpose\n\nsmuggled purpose.\n",
6062 )
6063 .unwrap();
6064 let writer = FilesystemMemWriter::new(mem_dir.clone());
6065 let mut engine = Engine::from_mounts(vec![(
6066 folder_mount("specs", mem_dir.clone()),
6067 Box::new(writer) as Box<dyn MemBackend>,
6068 )])
6069 .unwrap();
6070 let (actor, client) = cli_actor();
6071 let id = EntityId::new("specs", "smuggled");
6072 let entity = engine.get_entity(&id).expect("fixture boots");
6073 assert!(
6074 entity.metadata.contains_key("mem") && entity.metadata.contains_key("id"),
6075 "fixture must carry the smuggled keys after boot"
6076 );
6077 let hash = entity.content_hash.clone();
6078
6079 for reserved in ["type", "mem", "id"] {
6081 let mut args = bare_args(id.clone(), Some(hash.clone()));
6082 args.metadata
6083 .insert(reserved.to_string(), "resmuggled".to_string());
6084 let err = engine
6085 .update_entity(args, actor, Some(&client), None)
6086 .expect_err("reserved-key set must refuse on update");
6087 assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
6088 }
6089 let mut batch_item = bare_args(id.clone(), Some(hash.clone()));
6092 batch_item
6093 .metadata
6094 .insert("id".to_string(), "resmuggled".to_string());
6095 let batch = engine
6096 .batch_update(vec![(batch_item, None)], actor, Some(&client), false)
6097 .expect("batch returns a result envelope");
6098 assert!(
6099 !batch.applied,
6100 "batch with a reserved-key set must not apply"
6101 );
6102 assert_eq!(batch.failed, 1);
6103
6104 let mut args = bare_args(id.clone(), Some(hash));
6106 args.metadata_unset = vec!["mem".to_string(), "id".to_string()];
6107 let out = engine
6108 .update_entity(args, actor, Some(&client), None)
6109 .expect("reserved-key unset is the sanctioned repair");
6110 assert!(!out.write_id.is_empty(), "repair is a real commit");
6111 assert_eq!(
6112 out.modified_metadata.unset,
6113 vec!["mem".to_string(), "id".to_string()]
6114 );
6115
6116 let entity = engine.get_entity(&id).expect("entity survives repair");
6119 assert!(
6120 !entity.metadata.contains_key("mem") && !entity.metadata.contains_key("id"),
6121 "smuggled keys must be gone from the store"
6122 );
6123 let on_disk = std::fs::read_to_string(mem_dir.join("smuggled.md")).unwrap();
6124 assert!(
6125 !on_disk.contains("wrong-mem") && !on_disk.contains("bogus-id"),
6126 "smuggled keys must be gone from the file: {on_disk}"
6127 );
6128 let mut args = bare_args(id.clone(), Some(entity.content_hash.clone()));
6129 args.sections
6130 .insert("identity".to_string(), "repaired identity".to_string());
6131 engine
6132 .update_entity(args, actor, Some(&client), None)
6133 .expect("post-repair entity round-trips cleanly");
6134 }
6135
6136 #[test]
6144 fn reserved_type_unset_reseeds_and_is_a_noop_on_healthy_entities() {
6145 let tmp = TempDir::new().unwrap();
6146 let mem_dir = tmp.path().to_path_buf();
6147 let writer = FilesystemMemWriter::new(mem_dir.clone());
6148 let mut engine = Engine::from_mounts(vec![(
6149 folder_mount("specs", mem_dir.clone()),
6150 Box::new(writer) as Box<dyn MemBackend>,
6151 )])
6152 .unwrap();
6153 let (actor, client) = cli_actor();
6154 let created = engine
6155 .create_entity(
6156 empty_create_args("specs", "Healthy"),
6157 actor,
6158 Some(&client),
6159 None,
6160 )
6161 .unwrap();
6162 let id = EntityId::new("specs", "healthy");
6163
6164 for key in ["type", "mem", "id"] {
6165 let mut args = bare_args(id.clone(), Some(created.content_hash.clone()));
6166 args.metadata_unset = vec![key.to_string()];
6167 let out = engine
6168 .update_entity(args, actor, Some(&client), None)
6169 .unwrap_or_else(|e| panic!("unset '{key}' on a healthy entity must no-op: {e:?}"));
6170 assert!(
6171 out.write_id.is_empty(),
6172 "unset '{key}' on a healthy entity is a no-op, not a commit"
6173 );
6174 assert!(
6175 out.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
6176 "no-op must carry the UPDATE_NOOP warning for '{key}'"
6177 );
6178 }
6179 let entity = engine.get_entity(&id).unwrap();
6180 assert_eq!(entity.entity_type, "spec");
6181 assert_eq!(
6182 entity.metadata.get("type").and_then(|v| v.as_str()),
6183 Some("spec"),
6184 "the discriminator survives a type unset"
6185 );
6186 }
6187
6188 #[test]
6197 fn declare_relations_refuses_cycle_and_self_loop_like_relate() {
6198 let tmp = TempDir::new().unwrap();
6199 let mem_dir = tmp.path().to_path_buf();
6200 let writer = FilesystemMemWriter::new(mem_dir.clone());
6201 let mut engine = Engine::from_mounts(vec![(
6202 folder_mount("specs", mem_dir),
6203 Box::new(writer) as Box<dyn MemBackend>,
6204 )])
6205 .unwrap();
6206 let (actor, client) = cli_actor();
6207
6208 let alpha = engine
6210 .create_entity(
6211 empty_create_args("specs", "Alpha"),
6212 actor,
6213 Some(&client),
6214 None,
6215 )
6216 .unwrap();
6217 let beta = engine
6218 .create_entity(
6219 empty_create_args("specs", "Beta"),
6220 actor,
6221 Some(&client),
6222 None,
6223 )
6224 .unwrap();
6225 engine
6226 .relate_entity(
6227 crate::engine::RelateEntityArgs {
6228 source: alpha.id.clone(),
6229 target: beta.id.clone(),
6230 rel_type: "PART_OF".to_string(),
6231 remove: false,
6232 expected_hash: None,
6233 description: None,
6234 dry_run: false,
6235 },
6236 actor,
6237 Some(&client),
6238 None,
6239 )
6240 .unwrap();
6241
6242 let declare = |rel_type: &str, from: &EntityId, to: &EntityId, hash: String| {
6243 let mut args = bare_args(from.clone(), Some(hash));
6244 args.declare_relations = vec![crate::ops::RelateArg {
6245 target: to.clone(),
6246 rel_type: rel_type.to_string(),
6247 description: None,
6248 }];
6249 args
6250 };
6251
6252 let err = engine
6254 .update_entity(
6255 declare("PART_OF", &beta.id, &alpha.id, beta.content_hash.clone()),
6256 actor,
6257 Some(&client),
6258 None,
6259 )
6260 .expect_err("cycle-closing declare_relations must refuse");
6261 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
6262 let details = err.details();
6263 assert_eq!(details["rel_type"], "PART_OF");
6264 assert!(details["existing_path"].is_array());
6265 assert!(
6266 engine
6267 .get_entity(&beta.id)
6268 .unwrap()
6269 .relationships
6270 .is_empty(),
6271 "the refused edge must not land"
6272 );
6273
6274 let alpha_hash = engine.get_entity(&alpha.id).unwrap().content_hash.clone();
6277 let err = engine
6278 .update_entity(
6279 declare("USES", &alpha.id, &alpha.id, alpha_hash),
6280 actor,
6281 Some(&client),
6282 None,
6283 )
6284 .expect_err("self-loop declare_relations must refuse");
6285 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
6286
6287 engine
6289 .update_entity(
6290 declare(
6291 "PART_OF",
6292 &beta.id,
6293 &EntityId::new("specs", "gamma"),
6294 beta.content_hash.clone(),
6295 ),
6296 actor,
6297 Some(&client),
6298 None,
6299 )
6300 .expect("a non-cycle PART_OF declare must land as today");
6301 }
6302}