1use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::EntityId;
8use crate::entity::generator::generate_markdown;
9use crate::entity::parser::parse_markdown;
10use crate::entity::store_builder::push_entities_into_store;
11use crate::ops::{ModifiedMetadata, ModifiedSections, WarningHint};
12use crate::provenance::{Provenance, ProvenanceKind};
13use crate::runtime_validator::{
14 parse_metadata_value, validate_section_content, validate_section_keys,
15 validate_unsettable_metadata_key, validate_updatable_section, validate_writable_metadata_key,
16};
17use crate::vcs::{Actor, ClientId, CommitContext};
18use crate::workspace::MountCapability;
19
20use super::super::{Engine, EngineError, UpdateEntityArgs, UpdateEntityOutcome};
21use super::{
22 PATCH_OLD_NOT_FOUND_CONTENT_CAP, make_stub, unknown_type_error,
23 validate_relation_target_grammar,
24};
25use crate::engine::outcomes::RelationDeclared;
26use crate::entity::{Entity, Relationship};
27
28use std::sync::Arc;
29
30enum PrepareOutcome {
34 Done(UpdateEntityOutcome),
37 Prepared(PreparedUpdate),
40}
41
42struct PreparedUpdate {
46 mount_idx: usize,
47 id: EntityId,
48 mem: String,
49 type_def: Arc<memstead_schema::TypeDefinition>,
50 file_path: String,
51 markdown: String,
52 prev_body_targets: std::collections::HashSet<EntityId>,
55 modified_date: String,
56 modified_sections: ModifiedSections,
57 modified_metadata: ModifiedMetadata,
58 warnings: Vec<WarningHint>,
59 relations_declared: Vec<RelationDeclared>,
60 anchors: Vec<crate::anchor::Anchor>,
64 anchor_unsets: Vec<crate::anchor::AnchorUnset>,
68 anchor_only: bool,
79}
80
81struct AppliedWrite {
84 content_hash: String,
85 title: String,
86 orphan_stubs_removed: Vec<EntityId>,
87}
88
89impl Engine {
90 pub fn update_entity(
106 &mut self,
107 args: UpdateEntityArgs,
108 actor: Actor,
109 client: Option<&ClientId>,
110 note: Option<&str>,
111 ) -> Result<UpdateEntityOutcome, EngineError> {
112 let mut drift_warnings = self.reload_if_stale(Some(args.id.mem()));
119 let mut outcome = match self.prepare_update(args)? {
120 PrepareOutcome::Done(outcome) => outcome,
121 PrepareOutcome::Prepared(prepared) => {
122 self.commit_prepared_update(prepared, actor, client, note)?
123 }
124 };
125 drift_warnings.append(&mut outcome.warnings);
126 outcome.warnings = drift_warnings;
127 Ok(outcome)
128 }
129
130 fn commit_prepared_update(
135 &mut self,
136 prepared: PreparedUpdate,
137 actor: Actor,
138 client: Option<&ClientId>,
139 note: Option<&str>,
140 ) -> Result<UpdateEntityOutcome, EngineError> {
141 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
142 backend.write_entity(Path::new(&prepared.file_path), prepared.markdown.as_bytes())?;
143 if !prepared.anchors.is_empty() || !prepared.anchor_unsets.is_empty() {
146 super::stage_anchors_sidecar(
147 backend,
148 &prepared.id,
149 &prepared.anchor_unsets,
150 prepared.anchors.clone(),
151 )?;
152 }
153 if let Some(schema) = self.schemas.get(prepared.id.mem()) {
157 for r in prepared
158 .relations_declared
159 .iter()
160 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
161 {
162 let hash = self
163 .store
164 .get(&r.target)
165 .map(|e| e.content_hash.clone())
166 .unwrap_or_default();
167 let (from, rel, to) = (
168 prepared.id.to_string(),
169 r.rel_type.clone(),
170 r.target.to_string(),
171 );
172 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
173 }
174 }
175 let commit_subject = if prepared.anchor_only {
181 format!("memstead: anchor {}", prepared.id)
182 } else {
183 format!("memstead: update {}", prepared.id)
184 };
185 let ctx = CommitContext {
186 actor,
187 client: client.cloned(),
188 tool: Some("update_entity"),
189 note: note.map(String::from),
190 role: self.current_role,
191 logical_operation_id: None,
192 entity_ids: None,
193 };
194 let commit_sha = backend.commit(&commit_subject, &ctx)?;
195 backend.append_provenance(
196 &Provenance::new(
197 std::time::SystemTime::now(),
198 ProvenanceKind::Update,
199 Some(prepared.id.to_string()),
200 actor,
201 client.cloned(),
202 note.map(String::from),
203 )
204 .with_role(self.current_role),
205 )?;
206 self.record_self_write(prepared.mount_idx, &commit_sha);
207 self.stamp_mutation_versions(prepared.mount_idx);
208
209 let applied = self.apply_prepared_to_store(&prepared)?;
210
211 self.invalidate_communities();
212 self.invalidate_search_indexes();
213
214 let mut warnings = prepared.warnings;
218 if let Some(w) = self.note_missing_warning("update_entity", note) {
219 warnings.push(w);
220 }
221
222 Ok(UpdateEntityOutcome {
223 id: prepared.id.clone(),
224 title: applied.title,
225 file_path: prepared.file_path,
226 content_hash: applied.content_hash,
227 commit_sha,
228 modified_date: prepared.modified_date,
229 orphan_stubs_removed: applied.orphan_stubs_removed,
230 modified_sections: prepared.modified_sections,
231 modified_metadata: prepared.modified_metadata,
232 prospective_hash: None,
233 warnings,
234 relations_declared: prepared.relations_declared,
235 })
236 }
237
238 fn apply_prepared_to_store(
245 &mut self,
246 prepared: &PreparedUpdate,
247 ) -> Result<AppliedWrite, EngineError> {
248 let parse_result = parse_markdown(
249 &prepared.markdown,
250 &prepared.file_path,
251 prepared.type_def.as_ref(),
252 &prepared.mem,
253 )
254 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
255 let content_hash = parse_result.entity.content_hash.clone();
256 let title = parse_result.entity.title.clone();
257 let fallback = engine_fallback_type();
258 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
259 crate::entity::store_builder::remap_alias_target_edge_sources(
260 &mut self.store,
261 &self.schemas,
262 );
263 let orphan_stubs_removed =
264 super::gc_orphan_stubs_among(&mut self.store, &prepared.prev_body_targets);
265 Ok(AppliedWrite {
266 content_hash,
267 title,
268 orphan_stubs_removed,
269 })
270 }
271
272 fn prepare_update(&mut self, args: UpdateEntityArgs) -> Result<PrepareOutcome, EngineError> {
280 let id = &args.id;
281 let mem = id.mem().to_string();
282
283 let mount_idx = self
284 .mounts
285 .iter()
286 .position(|m| m.mount.mem == mem)
287 .ok_or_else(|| self.unknown_mem_error(&mem))?;
288 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
289 return Err(EngineError::ReadOnlyMount(mem));
290 }
291
292 let entity = self
293 .store
294 .get(id)
295 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
296
297 let prev_body_targets = super::collect_body_link_targets(entity);
303
304 if entity.stub {
312 return Err(EngineError::StubNotUpdatable { id: id.to_string() });
313 }
314
315 if !args.dry_run
321 && let Some(expected) = args.expected_hash.as_deref()
322 && entity.content_hash != expected
323 {
324 return Err(EngineError::HashMismatch {
325 id: id.to_string(),
326 current: entity.content_hash.clone(),
327 is_stub: entity.stub,
328 });
329 }
330
331 if args.sections.is_empty()
343 && args.append_sections.is_empty()
344 && args.patch_sections.is_empty()
345 && args.metadata.is_empty()
346 && args.metadata_unset.is_empty()
347 && args.declare_relations.is_empty()
348 && args.relations_unset.is_empty()
349 && args.anchors.is_empty()
350 && args.anchors_unset.is_empty()
351 {
352 return Err(EngineError::EmptyUpdate { id: id.to_string() });
353 }
354
355 let validated_anchors = self.validate_anchor_inputs(&mem, &args.anchors)?;
361 let validated_anchor_unsets = Self::validate_anchor_unsets(&args.anchors_unset)?;
362
363 let schema = self
364 .schemas
365 .get(&mem)
366 .expect("schema present for every registered mount")
367 .clone();
368 let type_def = schema
369 .get_type(&entity.entity_type)
370 .ok_or_else(|| unknown_type_error(schema.as_ref(), &entity.entity_type))?;
371
372 for key in args.sections.keys() {
379 let mut modes = vec!["sections".to_string()];
380 if args.append_sections.contains_key(key) {
381 modes.push("append_sections".to_string());
382 }
383 if args.patch_sections.contains_key(key) {
384 modes.push("patch_sections".to_string());
385 }
386 if modes.len() > 1 {
387 return Err(EngineError::ConflictingSectionModes {
388 section: key.clone(),
389 modes,
390 });
391 }
392 }
393 for key in args.append_sections.keys() {
394 if args.patch_sections.contains_key(key) {
395 return Err(EngineError::ConflictingSectionModes {
396 section: key.clone(),
397 modes: vec!["append_sections".to_string(), "patch_sections".to_string()],
398 });
399 }
400 }
401
402 validate_section_keys(
403 args.sections
404 .keys()
405 .chain(args.append_sections.keys())
406 .chain(args.patch_sections.keys())
407 .map(String::as_str),
408 type_def.as_ref(),
409 )?;
410 validate_section_content(
416 args.sections
417 .iter()
418 .map(|(k, v)| (k.as_str(), v.as_str()))
419 .chain(
420 args.append_sections
421 .iter()
422 .map(|(k, v)| (k.as_str(), v.as_str())),
423 )
424 .chain(
425 args.patch_sections
426 .iter()
427 .map(|(k, p)| (k.as_str(), p.new.as_str())),
428 ),
429 )?;
430 for key in args.sections.keys() {
431 validate_updatable_section(key.as_str(), type_def.as_ref())?;
432 }
433 for key in args.append_sections.keys() {
434 validate_updatable_section(key.as_str(), type_def.as_ref())?;
435 }
436 for key in args.patch_sections.keys() {
437 validate_updatable_section(key.as_str(), type_def.as_ref())?;
438 }
439 for key in args.metadata.keys() {
440 validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
441 }
442 for key in &args.metadata_unset {
448 validate_unsettable_metadata_key(key.as_str(), type_def.as_ref())?;
449 }
450
451 let mut overlap: Vec<String> = args
458 .metadata
459 .keys()
460 .filter(|k| args.metadata_unset.iter().any(|u| u == k.as_str()))
461 .cloned()
462 .collect();
463 if !overlap.is_empty() {
464 overlap.sort();
465 overlap.dedup();
466 return Err(EngineError::SetAndUnsetConflict { keys: overlap });
467 }
468
469 if !args.relations_unset.is_empty() {
478 let findings = crate::ops::integrity::entity_conformance_findings(
479 &self.store,
480 entity,
481 schema.as_ref(),
482 &self.schemas,
483 );
484 if findings.is_empty() {
485 return Err(EngineError::RepairNotNeeded {
486 id: id.to_string(),
487 recovery: "use memstead_relate(remove=true) to detach an edge from a conformant entity, or the additive memstead_update params to evolve it"
488 .to_string(),
489 });
490 }
491 }
492
493 let mut next = entity.clone();
494
495 for unset in &args.relations_unset {
502 let canonical = crate::entity::id::validate_rel_type(&unset.rel_type)
503 .unwrap_or_else(|_| unset.rel_type.clone());
504 next.relationships
505 .retain(|r| !(r.rel_type == canonical && r.target == unset.target));
506 }
507
508 let relations_declared = apply_declare_relations(
518 self,
519 &mut next,
520 &args.declare_relations,
521 &mem,
522 mount_idx,
523 type_def.as_ref(),
524 schema.as_ref(),
525 )?;
526
527 let format_touched: std::collections::HashSet<String> = args
531 .sections
532 .keys()
533 .chain(args.append_sections.keys())
534 .chain(args.patch_sections.keys())
535 .cloned()
536 .collect();
537
538 let mut modified_sections: Vec<String> = Vec::new();
539 for (key, body) in args.sections {
540 modified_sections.push(key.clone());
541 next.sections.insert(key, body);
542 }
543
544 let mut modified_sections_appended: Vec<String> = Vec::new();
548 for (key, value) in args.append_sections {
549 let existing = next.sections.get(&key).cloned().unwrap_or_default();
550 let new_content = if existing.trim().is_empty() {
551 value
552 } else {
553 format!("{existing}\n{value}")
554 };
555 next.sections.insert(key.clone(), new_content);
556 modified_sections_appended.push(key);
557 }
558
559 let mut modified_sections_patched: Vec<String> = Vec::new();
567 for (key, patch) in args.patch_sections {
568 let existing = next
569 .sections
570 .get(&key)
571 .ok_or_else(|| EngineError::PatchSectionEmpty {
572 section: key.clone(),
573 })?
574 .clone();
575 if !existing.contains(&patch.old) {
576 let cap = PATCH_OLD_NOT_FOUND_CONTENT_CAP;
577 let truncated = existing.len() > cap;
578 let mut cut = cap.min(existing.len());
581 while cut > 0 && !existing.is_char_boundary(cut) {
582 cut -= 1;
583 }
584 let current_content = if truncated {
585 existing[..cut].to_string()
586 } else {
587 existing.clone()
588 };
589 return Err(EngineError::PatchOldNotFound {
590 section: key,
591 current_content,
592 truncated,
593 });
594 }
595 let patched = if patch.all {
596 existing.replace(&patch.old, &patch.new)
597 } else {
598 existing.replacen(&patch.old, &patch.new, 1)
599 };
600 next.sections.insert(key.clone(), patched);
601 modified_sections_patched.push(key);
602 }
603
604 let mut modified_metadata_set: Vec<String> = Vec::new();
605 for (key, value) in &args.metadata {
606 let parsed = parse_metadata_value(key.as_str(), value.as_str(), type_def.as_ref())?;
607 modified_metadata_set.push(key.clone());
608 next.metadata.insert(key.clone(), parsed);
609 }
610
611 let mut modified_metadata_unset: Vec<String> = Vec::new();
612 for key in args.metadata_unset {
613 if crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str()) {
625 if key == "type" {
626 let authoritative =
627 crate::entity::MetadataValue::String(next.entity_type.clone());
628 if next
629 .metadata
630 .shift_remove("type")
631 .is_some_and(|removed| removed != authoritative)
632 {
633 modified_metadata_unset.push(key);
634 }
635 next.metadata.insert("type".to_string(), authoritative);
636 } else if next.metadata.shift_remove(&key).is_some() {
637 modified_metadata_unset.push(key);
638 }
639 continue;
640 }
641 let field_def = type_def.metadata_field(&key);
646 let is_required = field_def.map(|f| f.is_required()).unwrap_or(false);
647 if is_required {
648 let (field_description, enum_values) = match field_def {
649 Some(f) => (
650 Some(f.description.clone()),
651 f.enum_values.clone().unwrap_or_default(),
652 ),
653 None => (None, Vec::new()),
654 };
655 return Err(EngineError::RequiredFieldUnset {
656 field: key,
657 entity_type: type_def.name.clone(),
658 field_description,
659 enum_values,
660 type_write_rules: type_def.write_rules.clone(),
661 on_create: false,
667 missing: Vec::new(),
672 });
673 }
674 if next.metadata.shift_remove(&key).is_some() {
675 modified_metadata_unset.push(key);
676 }
677 }
678
679 let today = self.now_iso();
688
689 let (synthesised_relations, self_link_ignored) =
700 super::synthesise_alias_relations(self, &prev_body_targets, &mut next)?;
701
702 let missing = super::scan_wikilinks_without_relation(&next)?;
708 if !missing.is_empty() {
709 return Err(EngineError::WikiLinkWithoutRelation {
710 from_id: id.to_string(),
711 missing: missing
712 .into_iter()
713 .map(|(section_key, target)| crate::engine::MissingWikiLink {
714 section_key,
715 target_id: target.to_string(),
716 })
717 .collect(),
718 });
719 }
720
721 let file_path = next.file_path.clone();
722
723 let markdown_pre_stamp = generate_markdown(&next, type_def.as_ref());
732
733 let content_unchanged =
744 crate::entity::parser::compute_hash(&markdown_pre_stamp) == next.content_hash;
745
746 if !args.dry_run {
761 if content_unchanged
766 && validated_anchors.is_empty()
767 && validated_anchor_unsets.is_empty()
768 {
769 let modified_date = next
774 .metadata
775 .get("last_modified")
776 .and_then(|v| v.as_str().map(str::to_string))
777 .unwrap_or_default();
778 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
779 id: id.clone(),
780 title: next.title.clone(),
781 file_path,
782 content_hash: next.content_hash.clone(),
783 commit_sha: String::new(),
784 modified_date,
785 modified_sections: ModifiedSections::default(),
794 modified_metadata: ModifiedMetadata::default(),
795 prospective_hash: None,
796 orphan_stubs_removed: Vec::new(),
799 warnings: vec![WarningHint::UpdateNoop { id: id.clone() }],
800 relations_declared,
801 }));
802 }
803 }
804
805 if !content_unchanged {
815 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
816 }
817 let markdown = generate_markdown(&next, type_def.as_ref());
818
819 let mut warnings: Vec<WarningHint> = Vec::new();
820
821 for key in modified_sections
830 .iter()
831 .chain(modified_sections_appended.iter())
832 .chain(modified_sections_patched.iter())
833 {
834 let Some(def) = type_def.section(key) else {
835 continue;
836 };
837 if let Some(existing) = next.raw_section_headings.iter().find(|h| {
838 h.as_str() != def.heading && memstead_schema::derive_section_key(h) == *key
839 }) {
840 warnings.push(WarningHint::SectionHeadingDivergence {
841 entity_id: id.clone(),
842 section_key: key.clone(),
843 writing_heading: def.heading.clone(),
844 existing_heading: existing.clone(),
845 });
846 }
847 }
848
849 for def in &type_def.sections {
863 if def.format_severity != memstead_schema::ConstraintSeverity::Block {
864 continue;
865 }
866 if !format_touched.contains(def.key.as_str()) {
867 continue;
868 }
869 let Some(body) = next.sections.get(def.key.as_str()) else {
870 continue;
871 };
872 if let Some(first) = crate::section_format::check_section_format(def, body)
873 .into_iter()
874 .next()
875 {
876 return Err(EngineError::SectionFormatRefused {
877 entity_type: next.entity_type.clone(),
878 entity_id: id.to_string(),
879 violation: first,
880 });
881 }
882 }
883
884 let unsatisfied =
885 crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref());
886 if !unsatisfied.is_empty() {
887 let blocked: Vec<_> = unsatisfied
891 .iter()
892 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
893 .cloned()
894 .collect();
895 if !blocked.is_empty() {
896 return Err(EngineError::RequiredOutgoingUnsatisfied {
897 entity_type: next.entity_type.clone(),
898 entity_id: id.to_string(),
899 missing: blocked,
900 });
901 }
902 warnings.push(WarningHint::MissingRequiredOutgoing {
903 entity_type: next.entity_type.clone(),
904 entity_id: id.clone(),
905 missing: unsatisfied,
906 });
907 }
908
909 let violated = crate::ops::health::unsatisfied_constraints(
913 &self.store,
914 &next,
915 type_def.as_ref(),
916 Some(id),
917 );
918 if !violated.is_empty() {
919 let blocked: Vec<_> = violated
920 .iter()
921 .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
922 .cloned()
923 .collect();
924 if !blocked.is_empty() {
925 return Err(EngineError::ConstraintUnsatisfied {
926 entity_type: next.entity_type.clone(),
927 entity_id: id.to_string(),
928 violations: blocked,
929 });
930 }
931 warnings.push(WarningHint::ConstraintUnsatisfied {
932 entity_type: next.entity_type.clone(),
933 entity_id: id.clone(),
934 violations: violated,
935 });
936 }
937
938 let auto_stubbed: Vec<EntityId> = synthesised_relations
946 .iter()
947 .filter_map(|rel| {
948 if !self.store.contains(&rel.target) {
949 Some(rel.target.clone())
950 } else {
951 None
952 }
953 })
954 .collect();
955 if !auto_stubbed.is_empty() {
956 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
957 from: id.clone(),
958 stubs: auto_stubbed,
959 });
960 }
961 if self_link_ignored {
964 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
965 }
966
967 if args.dry_run {
974 let prospective = crate::entity::parser::compute_hash(&markdown);
975 let current_hash = next.content_hash.clone();
979 let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
980 today.clone()
981 } else {
982 String::new()
983 };
984 return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
985 id: id.clone(),
986 title: next.title.clone(),
987 file_path,
988 content_hash: current_hash,
989 commit_sha: String::new(),
990 modified_date,
991 modified_sections: ModifiedSections {
992 replaced: modified_sections,
993 appended: modified_sections_appended,
994 patched: modified_sections_patched,
995 },
996 modified_metadata: ModifiedMetadata {
997 set: modified_metadata_set,
998 unset: modified_metadata_unset,
999 },
1000 prospective_hash: Some(prospective),
1001 orphan_stubs_removed: Vec::new(),
1004 warnings,
1005 relations_declared: relations_declared.clone(),
1006 }));
1007 }
1008
1009 let modified_date = if content_unchanged {
1016 next.metadata
1019 .get("last_modified")
1020 .and_then(|v| v.as_str().map(str::to_string))
1021 .unwrap_or_default()
1022 } else if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1023 today.clone()
1024 } else {
1025 String::new()
1026 };
1027
1028 Ok(PrepareOutcome::Prepared(PreparedUpdate {
1029 mount_idx,
1030 id: id.clone(),
1031 mem,
1032 type_def,
1033 file_path,
1034 markdown,
1035 prev_body_targets,
1036 modified_date,
1037 modified_sections: ModifiedSections {
1038 replaced: modified_sections,
1039 appended: modified_sections_appended,
1040 patched: modified_sections_patched,
1041 },
1042 modified_metadata: ModifiedMetadata {
1043 set: modified_metadata_set,
1044 unset: modified_metadata_unset,
1045 },
1046 warnings,
1049 relations_declared,
1050 anchor_only: content_unchanged
1058 && (!validated_anchors.is_empty() || !validated_anchor_unsets.is_empty()),
1059 anchors: validated_anchors,
1060 anchor_unsets: validated_anchor_unsets,
1061 }))
1062 }
1063
1064 pub fn batch_update(
1106 &mut self,
1107 updates: Vec<(UpdateEntityArgs, Option<String>)>,
1108 actor: Actor,
1109 client: Option<&ClientId>,
1110 dry_run: bool,
1111 ) -> Result<crate::ops::BatchResult, EngineError> {
1112 if updates.is_empty() {
1113 return Ok(crate::ops::BatchResult {
1114 orphan_stubs_removed: Vec::new(),
1115 errors_suppressed: 0,
1116 applied: true,
1117 results: Vec::new(),
1118 succeeded: 0,
1119 failed: 0,
1120 commit_sha: String::new(),
1121 });
1122 }
1123
1124 let mut touched_mems: Vec<String> = updates
1131 .iter()
1132 .map(|(a, _)| a.id.mem().to_string())
1133 .collect();
1134 touched_mems.sort();
1135 touched_mems.dedup();
1136 for v in &touched_mems {
1137 self.reload_if_stale(Some(v));
1138 }
1139
1140 let store_snapshot = self.store.clone();
1146
1147 enum Item {
1153 Prepared,
1154 Noop,
1155 Error,
1156 }
1157 let mut items: Vec<(EntityId, Item)> = Vec::with_capacity(updates.len());
1158 let mut prepared: Vec<PreparedUpdate> = Vec::new();
1159 let mut notes: Vec<Option<String>> = Vec::new();
1160 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1161
1162 for (i, (args, note)) in updates.into_iter().enumerate() {
1167 let id = args.id.clone();
1168 let mut args = args;
1173 args.dry_run = false;
1174 match self.prepare_update(args) {
1175 Ok(PrepareOutcome::Done(_)) => {
1176 items.push((id, Item::Noop));
1178 }
1179 Ok(PrepareOutcome::Prepared(p)) => {
1180 prepared.push(p);
1181 notes.push(note);
1182 items.push((id, Item::Prepared));
1183 }
1184 Err(e) => {
1185 items.push((id, Item::Error));
1186 errors.push((i, e));
1187 }
1188 }
1189 }
1190
1191 if !errors.is_empty() {
1192 self.store = store_snapshot;
1197 self.discard_all_pending();
1198 let failed = errors.len();
1199 let mut error_map: std::collections::HashMap<usize, EngineError> =
1200 errors.into_iter().collect();
1201 let mut reported = 0usize;
1202 let mut suppressed = 0usize;
1203 let results: Vec<crate::ops::BatchEntry> = items
1204 .into_iter()
1205 .enumerate()
1206 .map(|(i, (id, _))| match error_map.remove(&i) {
1207 Some(e) => {
1208 if reported < Self::BATCH_ERROR_REPORT_CAP {
1209 reported += 1;
1210 crate::ops::BatchEntry {
1211 id,
1212 action: "error".to_string(),
1213 error: Some(batch_error_envelope(&e)),
1214 }
1215 } else {
1216 suppressed += 1;
1217 crate::ops::BatchEntry {
1218 id,
1219 action: "error".to_string(),
1220 error: None,
1221 }
1222 }
1223 }
1224 None => crate::ops::BatchEntry {
1225 id,
1226 action: "not_applied".to_string(),
1227 error: None,
1228 },
1229 })
1230 .collect();
1231 return Ok(crate::ops::BatchResult {
1232 orphan_stubs_removed: Vec::new(),
1233 errors_suppressed: suppressed,
1234 applied: false,
1235 results,
1236 succeeded: 0,
1237 failed,
1238 commit_sha: String::new(),
1239 });
1240 }
1241
1242 if dry_run {
1248 self.store = store_snapshot;
1249 self.discard_all_pending();
1250 let succeeded = items.len();
1251 let results: Vec<crate::ops::BatchEntry> = items
1252 .into_iter()
1253 .map(|(id, item)| crate::ops::BatchEntry {
1254 id,
1255 action: match item {
1256 Item::Prepared => "updated".to_string(),
1257 Item::Noop => "noop".to_string(),
1258 Item::Error => unreachable!("refusal path returned above"),
1259 },
1260 error: None,
1261 })
1262 .collect();
1263 return Ok(crate::ops::BatchResult {
1264 orphan_stubs_removed: Vec::new(),
1265 errors_suppressed: 0,
1266 applied: true,
1267 results,
1268 succeeded,
1269 failed: 0,
1270 commit_sha: String::new(),
1271 });
1272 }
1273
1274 for p in &prepared {
1277 if let Err(e) = self.mounts[p.mount_idx]
1278 .backend
1279 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1280 {
1281 self.store = store_snapshot;
1282 self.discard_all_pending();
1283 return Err(e.into());
1284 }
1285 if (!p.anchors.is_empty() || !p.anchor_unsets.is_empty())
1288 && let Err(e) = super::stage_anchors_sidecar(
1289 self.mounts[p.mount_idx].backend.as_ref(),
1290 &p.id,
1291 &p.anchor_unsets,
1292 p.anchors.clone(),
1293 )
1294 {
1295 self.store = store_snapshot;
1296 self.discard_all_pending();
1297 return Err(e);
1298 }
1299 if let Some(schema) = self.schemas.get(p.id.mem()) {
1302 for r in p
1303 .relations_declared
1304 .iter()
1305 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1306 {
1307 let hash = self
1308 .store
1309 .get(&r.target)
1310 .map(|e| e.content_hash.clone())
1311 .unwrap_or_default();
1312 let (from, rel, to) =
1313 (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1314 if let Err(e) = super::stage_derivation_sidecar(
1315 self.mounts[p.mount_idx].backend.as_ref(),
1316 |s| s.set(&from, &rel, &to, &hash),
1317 ) {
1318 self.store = store_snapshot;
1319 self.discard_all_pending();
1320 return Err(e);
1321 }
1322 }
1323 }
1324 }
1325
1326 let mut distinct_mounts: Vec<usize> = Vec::new();
1328 for p in &prepared {
1329 if !distinct_mounts.contains(&p.mount_idx) {
1330 distinct_mounts.push(p.mount_idx);
1331 }
1332 }
1333 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1334 for &m in &distinct_mounts {
1335 let entity_ids: Vec<String> = prepared
1336 .iter()
1337 .filter(|p| p.mount_idx == m)
1338 .map(|p| p.id.to_string())
1339 .collect();
1340 let count = entity_ids.len();
1341 let subject = format!("memstead: batch-update ({count} entities)");
1342 let ctx = CommitContext {
1343 actor,
1344 client: client.cloned(),
1345 tool: Some("batch_update"),
1346 note: None,
1347 role: self.current_role,
1348 logical_operation_id: None,
1349 entity_ids: Some(entity_ids),
1353 };
1354 match self.mounts[m].backend.commit(&subject, &ctx) {
1355 Ok(sha) => mount_commits.push((m, sha)),
1356 Err(e) => {
1357 self.store = store_snapshot;
1361 self.discard_all_pending();
1362 return Err(e.into());
1363 }
1364 }
1365 }
1366
1367 for (p, note) in prepared.iter().zip(notes.iter()) {
1371 let commit_sha = mount_commits
1372 .iter()
1373 .find(|(m, _)| *m == p.mount_idx)
1374 .map(|(_, s)| s.clone())
1375 .unwrap_or_default();
1376 self.mounts[p.mount_idx].backend.append_provenance(
1377 &Provenance::new(
1378 std::time::SystemTime::now(),
1379 ProvenanceKind::Update,
1380 Some(p.id.to_string()),
1381 actor,
1382 client.cloned(),
1383 note.clone(),
1384 )
1385 .with_role(self.current_role),
1386 )?;
1387 self.record_self_write(p.mount_idx, &commit_sha);
1388 self.stamp_mutation_versions(p.mount_idx);
1389 self.apply_prepared_to_store(p)?;
1390 }
1391
1392 self.invalidate_communities();
1393 self.invalidate_search_indexes();
1394
1395 let commit_sha = mount_commits
1398 .last()
1399 .map(|(_, s)| s.clone())
1400 .unwrap_or_default();
1401 let succeeded = items.len();
1402 let results: Vec<crate::ops::BatchEntry> = items
1403 .into_iter()
1404 .map(|(id, item)| crate::ops::BatchEntry {
1405 id,
1406 action: match item {
1407 Item::Prepared => "updated".to_string(),
1408 Item::Noop => "noop".to_string(),
1409 Item::Error => unreachable!("refusal path returned above"),
1410 },
1411 error: None,
1412 })
1413 .collect();
1414
1415 Ok(crate::ops::BatchResult {
1416 orphan_stubs_removed: Vec::new(),
1417 errors_suppressed: 0,
1418 applied: true,
1419 results,
1420 succeeded,
1421 failed: 0,
1422 commit_sha,
1423 })
1424 }
1425
1426 pub(super) fn discard_all_pending(&self) {
1431 for mount in &self.mounts {
1432 let _ = mount.backend.discard_pending();
1433 }
1434 }
1435
1436 pub fn update_entity_with_ctx(
1439 &mut self,
1440 args: UpdateEntityArgs,
1441 ctx: &CommitContext<'_>,
1442 ) -> Result<UpdateEntityOutcome, EngineError> {
1443 self.update_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1444 }
1445}
1446
1447pub(super) fn batch_error_envelope(err: &EngineError) -> crate::ops::BatchError {
1454 let code = err.code().to_string();
1460 let message = err.to_string();
1461 let details = err.details();
1462 crate::ops::BatchError {
1463 code,
1464 message,
1465 details,
1466 }
1467}
1468
1469fn apply_declare_relations(
1484 engine: &mut Engine,
1485 next: &mut Entity,
1486 declarations: &[crate::ops::RelateArg],
1487 source_mem: &str,
1488 source_mount_idx: usize,
1489 type_def: &memstead_schema::TypeDefinition,
1490 schema: &memstead_schema::Schema,
1491) -> Result<Vec<RelationDeclared>, EngineError> {
1492 let _ = type_def; let _ = source_mount_idx; let mut declared: Vec<RelationDeclared> = Vec::with_capacity(declarations.len());
1495 for rel in declarations {
1496 let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
1499 .unwrap_or_else(|_| rel.rel_type.clone());
1500
1501 validate_relation_target_grammar(&rel.to)?;
1502
1503 let target_mem = rel.to.mem().to_string();
1504 super::validate_cross_mem_add_policy(engine, source_mem, &rel.to)?;
1507
1508 let target_type = engine
1517 .store
1518 .get(&rel.to)
1519 .map(|e| e.entity_type.clone())
1520 .filter(|t| !t.is_empty());
1521 let _ = super::route_edge_validation(
1522 engine,
1523 &canonical,
1524 next.entity_type.as_str(),
1525 target_type.as_deref(),
1526 source_mem,
1527 &target_mem,
1528 &next.id,
1529 &rel.to,
1530 true,
1531 )?;
1532
1533 let normalised_description =
1538 crate::entity::normalise_description(rel.description.as_deref());
1539 super::validate_description_posture(
1540 engine,
1541 &canonical,
1542 normalised_description.as_deref(),
1543 source_mem,
1544 &target_mem,
1545 &next.id,
1546 &rel.to,
1547 )?;
1548 super::validate_manual_authoring_posture(
1551 engine, &canonical, source_mem, &next.id, &rel.to,
1552 )?;
1553
1554 super::validate_edge_acyclicity(
1558 &engine.store,
1559 schema,
1560 &next.id,
1561 next.entity_type.as_str(),
1562 &rel.to,
1563 &canonical,
1564 )?;
1565
1566 let exists = next
1571 .relationships
1572 .iter()
1573 .any(|r| r.rel_type == canonical && r.target == rel.to);
1574 if !exists {
1575 next.relationships.push(Relationship {
1576 rel_type: canonical.clone(),
1577 target: rel.to.clone(),
1578 description: normalised_description,
1579 });
1580 }
1581
1582 let target_was_stubbed = !engine.store.contains(&rel.to);
1587 if target_was_stubbed && !exists {
1588 engine.store.upsert(
1589 rel.to.clone(),
1590 make_stub(&rel.to, crate::entity::StubKind::ForwardReference),
1591 );
1592 }
1593
1594 declared.push(RelationDeclared {
1595 rel_type: canonical,
1596 target: rel.to.clone(),
1597 target_was_stubbed,
1598 });
1599 }
1600 Ok(declared)
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605
1606 use indexmap::IndexMap;
1607 use tempfile::TempDir;
1608
1609 use crate::backend::MemBackend;
1610 use crate::engine::test_helpers::*;
1611 use crate::engine::{
1612 CreateEntityArgs, Engine, EngineError, RelateEntityArgs, UpdateEntityArgs,
1613 };
1614 use crate::entity::EntityId;
1615
1616 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1617 use crate::vcs::Actor;
1618
1619 #[test]
1625 fn update_warns_on_section_heading_divergence_and_still_commits() {
1626 let tmp = TempDir::new().unwrap();
1627 let mem_dir = tmp.path().to_path_buf();
1628 std::fs::write(
1631 mem_dir.join("diverged.md"),
1632 "---\ntype: spec\n---\n# Diverged\n\n## IDENTITY\n\nold text.\n",
1633 )
1634 .unwrap();
1635 let writer = FilesystemMemWriter::new(mem_dir.clone());
1636 let mut engine = Engine::from_mounts(vec![(
1637 folder_mount("specs", mem_dir),
1638 Box::new(writer) as Box<dyn MemBackend>,
1639 )])
1640 .unwrap();
1641 let (actor, client) = cli_actor();
1642 let id = EntityId::new("specs", "diverged");
1643
1644 let update_identity = |engine: &mut Engine, body: &str| {
1645 let current = engine.get_entity(&id).unwrap().content_hash.clone();
1646 let mut sections = IndexMap::new();
1647 sections.insert("identity".to_string(), body.to_string());
1648 engine
1649 .update_entity(
1650 UpdateEntityArgs {
1651 anchors: Vec::new(),
1652 id: id.clone(),
1653 expected_hash: Some(current),
1654 sections,
1655 append_sections: IndexMap::new(),
1656 patch_sections: IndexMap::new(),
1657 metadata: IndexMap::new(),
1658 metadata_unset: Vec::new(),
1659 declare_relations: Vec::new(),
1660 dry_run: false,
1661 relations_unset: Vec::new(),
1662 anchors_unset: Vec::new(),
1663 },
1664 actor,
1665 Some(&client),
1666 None,
1667 )
1668 .unwrap()
1669 };
1670
1671 let outcome = update_identity(&mut engine, "new text.");
1672 assert!(!outcome.commit_sha.is_empty(), "the mutation still commits");
1673 let divergences: Vec<_> = outcome
1674 .warnings
1675 .iter()
1676 .filter_map(|w| match w {
1677 crate::ops::WarningHint::SectionHeadingDivergence {
1678 section_key,
1679 writing_heading,
1680 existing_heading,
1681 ..
1682 } => Some((
1683 section_key.clone(),
1684 writing_heading.clone(),
1685 existing_heading.clone(),
1686 )),
1687 _ => None,
1688 })
1689 .collect();
1690 assert_eq!(
1691 divergences,
1692 vec![(
1693 "identity".to_string(),
1694 "Identity".to_string(),
1695 "IDENTITY".to_string()
1696 )],
1697 "warning names both headings; all warnings = {:?}",
1698 outcome.warnings
1699 );
1700
1701 let outcome2 = update_identity(&mut engine, "third text.");
1704 assert!(
1705 !outcome2
1706 .warnings
1707 .iter()
1708 .any(|w| matches!(w, crate::ops::WarningHint::SectionHeadingDivergence { .. })),
1709 "matching heading emits no divergence warning: {:?}",
1710 outcome2.warnings
1711 );
1712 }
1713
1714 #[test]
1715 fn batch_update_empty_batch_returns_zero_counts() {
1716 let tmp = TempDir::new().unwrap();
1719 let mem_dir = tmp.path().to_path_buf();
1720 let writer = FilesystemMemWriter::new(mem_dir.clone());
1721 let mut engine = Engine::from_mounts(vec![(
1722 folder_mount("specs", mem_dir),
1723 Box::new(writer) as Box<dyn MemBackend>,
1724 )])
1725 .unwrap();
1726
1727 let result = engine
1728 .batch_update(Vec::new(), Actor::Cli, None, false)
1729 .unwrap();
1730 assert!(result.applied, "empty batch is a vacuous success");
1731 assert_eq!(result.results.len(), 0);
1732 assert_eq!(result.succeeded, 0);
1733 assert_eq!(result.failed, 0);
1734 assert_eq!(result.commit_sha, "");
1735 }
1736
1737 #[test]
1738 fn batch_update_refuses_whole_batch_when_one_item_fails() {
1739 let tmp = TempDir::new().unwrap();
1746 let mem_dir = tmp.path().to_path_buf();
1747 let writer = FilesystemMemWriter::new(mem_dir.clone());
1748 let mut engine = Engine::from_mounts(vec![(
1749 folder_mount("specs", mem_dir),
1750 Box::new(writer) as Box<dyn MemBackend>,
1751 )])
1752 .unwrap();
1753
1754 let create_args = CreateEntityArgs {
1756 anchors: Vec::new(),
1757 mem: "specs".to_string(),
1758 title: "Seed".to_string(),
1759 entity_type: "spec".to_string(),
1760 sections: IndexMap::from_iter([
1761 ("identity".to_string(), "seed identity".to_string()),
1762 ("purpose".to_string(), "seed purpose".to_string()),
1763 ]),
1764 metadata: IndexMap::new(),
1765 relations: Vec::new(),
1766 dry_run: false,
1767 };
1768 let created = engine
1769 .create_entity(create_args, Actor::Cli, None, None)
1770 .unwrap();
1771
1772 let valid_update = UpdateEntityArgs {
1774 anchors: Vec::new(),
1775 id: created.id.clone(),
1776 expected_hash: Some(created.content_hash.clone()),
1777 sections: IndexMap::from_iter([("identity".to_string(), "updated body".to_string())]),
1778 append_sections: IndexMap::new(),
1779 patch_sections: IndexMap::new(),
1780 metadata: IndexMap::new(),
1781 metadata_unset: Vec::new(),
1782 declare_relations: Vec::new(),
1783 dry_run: false,
1784 relations_unset: Vec::new(),
1785 anchors_unset: Vec::new(),
1786 };
1787 let missing_update = UpdateEntityArgs {
1788 anchors: Vec::new(),
1789 id: EntityId("specs--nonexistent".to_string()),
1790 expected_hash: None,
1791 sections: IndexMap::new(),
1792 append_sections: IndexMap::new(),
1793 patch_sections: IndexMap::new(),
1794 metadata: IndexMap::new(),
1795 metadata_unset: Vec::new(),
1796 declare_relations: Vec::new(),
1797 dry_run: false,
1798 relations_unset: Vec::new(),
1799 anchors_unset: Vec::new(),
1800 };
1801
1802 let result = engine
1803 .batch_update(
1804 vec![(valid_update, None), (missing_update, None)],
1805 Actor::Cli,
1806 None,
1807 false,
1808 )
1809 .unwrap();
1810 assert!(!result.applied, "a failing item must refuse the batch");
1812 assert_eq!(result.results.len(), 2);
1813 assert_eq!(result.succeeded, 0);
1814 assert_eq!(result.failed, 1);
1815 assert_eq!(result.commit_sha, "", "refused batch must not commit");
1816 assert_eq!(result.results[0].action, "not_applied");
1819 assert!(result.results[0].error.is_none());
1820 assert_eq!(result.results[1].action, "error");
1822 let err = result.results[1]
1823 .error
1824 .as_ref()
1825 .expect("failed entry must carry a structured error envelope");
1826 assert_eq!(err.code, "ENTITY_NOT_FOUND");
1827 assert!(err.message.contains("not found"), "got: {}", err.message);
1828
1829 let seed = engine.get_entity(&created.id).unwrap();
1832 assert_eq!(
1833 seed.sections.get("identity").map(String::as_str),
1834 Some("seed identity"),
1835 "refused batch must leave the in-memory store untouched",
1836 );
1837 assert_eq!(
1838 seed.content_hash, created.content_hash,
1839 "refused batch must not change the entity's content hash",
1840 );
1841 }
1842
1843 #[test]
1844 fn batch_update_applies_all_valid_items_as_one_commit() {
1845 let tmp = TempDir::new().unwrap();
1849 let mem_dir = tmp.path().to_path_buf();
1850 let writer = FilesystemMemWriter::new(mem_dir.clone());
1851 let mut engine = Engine::from_mounts(vec![(
1852 folder_mount("specs", mem_dir),
1853 Box::new(writer) as Box<dyn MemBackend>,
1854 )])
1855 .unwrap();
1856
1857 let mk = |title: &str| CreateEntityArgs {
1858 anchors: Vec::new(),
1859 mem: "specs".to_string(),
1860 title: title.to_string(),
1861 entity_type: "spec".to_string(),
1862 sections: IndexMap::from_iter([
1863 ("identity".to_string(), "id".to_string()),
1864 ("purpose".to_string(), "purp".to_string()),
1865 ]),
1866 metadata: IndexMap::new(),
1867 relations: Vec::new(),
1868 dry_run: false,
1869 };
1870 let a = engine
1871 .create_entity(mk("A"), Actor::Cli, None, None)
1872 .unwrap();
1873 let b = engine
1874 .create_entity(mk("B"), Actor::Cli, None, None)
1875 .unwrap();
1876
1877 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
1878 anchors: Vec::new(),
1879 id,
1880 expected_hash: Some(hash),
1881 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
1882 append_sections: IndexMap::new(),
1883 patch_sections: IndexMap::new(),
1884 metadata: IndexMap::new(),
1885 metadata_unset: Vec::new(),
1886 declare_relations: Vec::new(),
1887 dry_run: false,
1888 relations_unset: Vec::new(),
1889 anchors_unset: Vec::new(),
1890 };
1891
1892 let result = engine
1893 .batch_update(
1894 vec![
1895 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
1896 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
1897 ],
1898 Actor::Cli,
1899 None,
1900 false,
1901 )
1902 .unwrap();
1903 assert!(result.applied);
1904 assert_eq!(result.succeeded, 2);
1905 assert_eq!(result.failed, 0);
1906 assert!(
1907 !result.commit_sha.is_empty(),
1908 "applied batch carries the commit"
1909 );
1910 assert!(result.results.iter().all(|e| e.action == "updated"));
1911 assert_eq!(
1913 engine
1914 .get_entity(&a.id)
1915 .unwrap()
1916 .sections
1917 .get("identity")
1918 .map(String::as_str),
1919 Some("A body"),
1920 );
1921 assert_eq!(
1922 engine
1923 .get_entity(&b.id)
1924 .unwrap()
1925 .sections
1926 .get("identity")
1927 .map(String::as_str),
1928 Some("B body"),
1929 );
1930 }
1931
1932 #[test]
1941 fn batch_update_dry_run_reports_receipt_and_writes_nothing() {
1942 let tmp = TempDir::new().unwrap();
1943 let mem_dir = tmp.path().to_path_buf();
1944 let writer = FilesystemMemWriter::new(mem_dir.clone());
1945 let mut engine = Engine::from_mounts(vec![(
1946 folder_mount("specs", mem_dir),
1947 Box::new(writer) as Box<dyn MemBackend>,
1948 )])
1949 .unwrap();
1950
1951 let mk = |title: &str| CreateEntityArgs {
1952 anchors: Vec::new(),
1953 mem: "specs".to_string(),
1954 title: title.to_string(),
1955 entity_type: "spec".to_string(),
1956 sections: IndexMap::from_iter([
1957 ("identity".to_string(), "id".to_string()),
1958 ("purpose".to_string(), "purp".to_string()),
1959 ]),
1960 metadata: IndexMap::new(),
1961 relations: Vec::new(),
1962 dry_run: false,
1963 };
1964 let a = engine
1965 .create_entity(mk("A"), Actor::Cli, None, None)
1966 .unwrap();
1967 let b = engine
1968 .create_entity(mk("B"), Actor::Cli, None, None)
1969 .unwrap();
1970
1971 let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
1972 anchors: Vec::new(),
1973 id,
1974 expected_hash: Some(hash),
1975 sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
1976 append_sections: IndexMap::new(),
1977 patch_sections: IndexMap::new(),
1978 metadata: IndexMap::new(),
1979 metadata_unset: Vec::new(),
1980 declare_relations: Vec::new(),
1981 dry_run: false,
1982 relations_unset: Vec::new(),
1983 anchors_unset: Vec::new(),
1984 };
1985 let batch = || {
1986 vec![
1987 (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
1988 (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
1989 ]
1990 };
1991
1992 let rehearsed = engine
1993 .batch_update(batch(), Actor::Cli, None, true)
1994 .unwrap();
1995 assert!(rehearsed.applied, "{rehearsed:?}");
1996 assert_eq!(rehearsed.succeeded, 2);
1997 assert!(
1998 rehearsed.commit_sha.is_empty(),
1999 "marker form: empty commit_sha"
2000 );
2001 assert!(rehearsed.results.iter().all(|e| e.action == "updated"));
2002 let a_now = engine.get_entity(&a.id).unwrap();
2004 assert_eq!(
2005 a_now.sections.get("identity").map(String::as_str),
2006 Some("id")
2007 );
2008 assert_eq!(a_now.content_hash, a.content_hash);
2009
2010 let real = engine
2012 .batch_update(batch(), Actor::Cli, None, false)
2013 .unwrap();
2014 assert!(real.applied, "{real:?}");
2015 assert!(!real.commit_sha.is_empty());
2016 assert_eq!(
2017 engine
2018 .get_entity(&a.id)
2019 .unwrap()
2020 .sections
2021 .get("identity")
2022 .map(String::as_str),
2023 Some("A body"),
2024 );
2025 }
2026
2027 #[test]
2031 fn batch_update_dry_run_refuses_identically_to_real() {
2032 let tmp = TempDir::new().unwrap();
2033 let mem_dir = tmp.path().to_path_buf();
2034 let writer = FilesystemMemWriter::new(mem_dir.clone());
2035 let mut engine = Engine::from_mounts(vec![(
2036 folder_mount("specs", mem_dir),
2037 Box::new(writer) as Box<dyn MemBackend>,
2038 )])
2039 .unwrap();
2040 let created = engine
2041 .create_entity(
2042 CreateEntityArgs {
2043 anchors: Vec::new(),
2044 mem: "specs".to_string(),
2045 title: "Valid".to_string(),
2046 entity_type: "spec".to_string(),
2047 sections: IndexMap::from_iter([
2048 ("identity".to_string(), "x".to_string()),
2049 ("purpose".to_string(), "p".to_string()),
2050 ]),
2051 metadata: IndexMap::new(),
2052 relations: Vec::new(),
2053 dry_run: false,
2054 },
2055 Actor::Cli,
2056 None,
2057 None,
2058 )
2059 .unwrap();
2060 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2061 anchors: Vec::new(),
2062 id,
2063 expected_hash: hash,
2064 sections: IndexMap::from_iter([("identity".to_string(), "new".to_string())]),
2065 append_sections: IndexMap::new(),
2066 patch_sections: IndexMap::new(),
2067 metadata: IndexMap::new(),
2068 metadata_unset: Vec::new(),
2069 declare_relations: Vec::new(),
2070 dry_run: false,
2071 relations_unset: Vec::new(),
2072 anchors_unset: Vec::new(),
2073 };
2074 let batch = || {
2075 vec![
2076 (
2077 upd(created.id.clone(), Some("wrong-hash".to_string())),
2078 None,
2079 ),
2080 (upd(EntityId("specs--missing".to_string()), None), None),
2081 ]
2082 };
2083
2084 let rehearsed = engine
2085 .batch_update(batch(), Actor::Cli, None, true)
2086 .unwrap();
2087 let real = engine
2088 .batch_update(batch(), Actor::Cli, None, false)
2089 .unwrap();
2090 assert!(!rehearsed.applied && !real.applied);
2091 let envelope = |r: &crate::ops::BatchResult| {
2092 r.results
2093 .iter()
2094 .map(|e| {
2095 (
2096 e.id.to_string(),
2097 e.action.clone(),
2098 e.error.as_ref().map(|err| {
2099 (err.code.clone(), err.message.clone(), err.details.clone())
2100 }),
2101 )
2102 })
2103 .collect::<Vec<_>>()
2104 };
2105 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
2106 assert_eq!(
2108 engine
2109 .get_entity(&created.id)
2110 .unwrap()
2111 .sections
2112 .get("identity")
2113 .map(String::as_str),
2114 Some("x"),
2115 );
2116 }
2117
2118 #[test]
2122 fn batch_update_reports_every_failing_item() {
2123 let tmp = TempDir::new().unwrap();
2124 let mem_dir = tmp.path().to_path_buf();
2125 let writer = FilesystemMemWriter::new(mem_dir.clone());
2126 let mut engine = Engine::from_mounts(vec![(
2127 folder_mount("specs", mem_dir),
2128 Box::new(writer) as Box<dyn MemBackend>,
2129 )])
2130 .unwrap();
2131 let created = engine
2132 .create_entity(
2133 CreateEntityArgs {
2134 anchors: Vec::new(),
2135 mem: "specs".to_string(),
2136 title: "Seed".to_string(),
2137 entity_type: "spec".to_string(),
2138 sections: IndexMap::from_iter([
2139 ("identity".to_string(), "seed identity".to_string()),
2140 ("purpose".to_string(), "seed purpose".to_string()),
2141 ]),
2142 metadata: IndexMap::new(),
2143 relations: Vec::new(),
2144 dry_run: false,
2145 },
2146 Actor::Cli,
2147 None,
2148 None,
2149 )
2150 .unwrap();
2151
2152 let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2153 anchors: Vec::new(),
2154 id,
2155 expected_hash: hash,
2156 sections: IndexMap::from_iter([("identity".to_string(), "new body".to_string())]),
2157 append_sections: IndexMap::new(),
2158 patch_sections: IndexMap::new(),
2159 metadata: IndexMap::new(),
2160 metadata_unset: Vec::new(),
2161 declare_relations: Vec::new(),
2162 dry_run: false,
2163 relations_unset: Vec::new(),
2164 anchors_unset: Vec::new(),
2165 };
2166 let result = engine
2167 .batch_update(
2168 vec![
2169 (upd(created.id.clone(), None), None),
2170 (upd(EntityId("specs--missing-one".to_string()), None), None),
2171 (upd(EntityId("specs--missing-two".to_string()), None), None),
2172 ],
2173 Actor::Cli,
2174 None,
2175 false,
2176 )
2177 .unwrap();
2178 assert!(!result.applied);
2179 assert_eq!(result.failed, 2, "{result:?}");
2180 assert_eq!(result.commit_sha, "");
2181 let codes: Vec<(usize, &str)> = result
2182 .results
2183 .iter()
2184 .enumerate()
2185 .filter(|(_, r)| r.action == "error")
2186 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
2187 .collect();
2188 assert_eq!(
2189 codes,
2190 vec![(1, "ENTITY_NOT_FOUND"), (2, "ENTITY_NOT_FOUND")],
2191 "BOTH failing items named, not just the first: {result:?}"
2192 );
2193 assert_eq!(result.results[0].action, "not_applied");
2194 assert_eq!(
2196 engine
2197 .get_entity(&created.id)
2198 .unwrap()
2199 .sections
2200 .get("identity")
2201 .map(String::as_str),
2202 Some("seed identity"),
2203 );
2204 }
2205
2206 #[test]
2207 fn batch_update_rolls_back_in_memory_store_auto_stub_on_refusal() {
2208 let tmp = TempDir::new().unwrap();
2216 let mem_dir = tmp.path().to_path_buf();
2217 let writer = FilesystemMemWriter::new(mem_dir.clone());
2218 let mut engine = Engine::from_mounts(vec![(
2219 folder_mount("specs", mem_dir.clone()),
2220 Box::new(writer) as Box<dyn MemBackend>,
2221 )])
2222 .unwrap();
2223 engine.set_workspace_root(mem_dir);
2224 let (actor, client) = cli_actor();
2225
2226 let a = engine
2227 .create_entity(
2228 empty_create_args("specs", "Anchor"),
2229 actor,
2230 Some(&client),
2231 None,
2232 )
2233 .unwrap();
2234
2235 let stub_target = EntityId::new("specs", "would-be-stub");
2236 let item1 = UpdateEntityArgs {
2237 anchors: Vec::new(),
2238 relations_unset: Vec::new(),
2239 anchors_unset: Vec::new(),
2240 id: a.id.clone(),
2241 expected_hash: Some(a.content_hash.clone()),
2242 sections: IndexMap::new(),
2243 append_sections: IndexMap::new(),
2244 patch_sections: IndexMap::new(),
2245 metadata: IndexMap::new(),
2246 metadata_unset: Vec::new(),
2247 declare_relations: vec![crate::ops::RelateArg {
2248 rel_type: "USES".to_string(),
2249 to: stub_target.clone(),
2250 description: None,
2251 }],
2252 dry_run: false,
2253 };
2254 let item2 = UpdateEntityArgs {
2255 anchors: Vec::new(),
2256 id: EntityId::new("specs", "nonexistent"),
2257 expected_hash: None,
2258 sections: IndexMap::from_iter([("identity".to_string(), "x".to_string())]),
2259 append_sections: IndexMap::new(),
2260 patch_sections: IndexMap::new(),
2261 metadata: IndexMap::new(),
2262 metadata_unset: Vec::new(),
2263 declare_relations: Vec::new(),
2264 dry_run: false,
2265 relations_unset: Vec::new(),
2266 anchors_unset: Vec::new(),
2267 };
2268
2269 assert!(engine.get_entity(&stub_target).is_none());
2271
2272 let result = engine
2273 .batch_update(
2274 vec![(item1, None), (item2, None)],
2275 actor,
2276 Some(&client),
2277 false,
2278 )
2279 .unwrap();
2280 assert!(!result.applied, "missing item 2 must refuse the batch");
2281
2282 assert!(
2285 engine.get_entity(&stub_target).is_none(),
2286 "refused batch must roll the in-memory auto-stub back out of the store",
2287 );
2288 let anchor = engine.get_entity(&a.id).unwrap();
2290 assert!(
2291 !anchor.relationships.iter().any(|r| r.target == stub_target),
2292 "refused batch must not leave the declared relation on the anchor",
2293 );
2294 }
2295
2296 #[test]
2297 fn update_entity_replaces_a_section_and_logs_provenance() {
2298 let tmp = TempDir::new().unwrap();
2299 let (mut engine, seeded) = engine_with_seed(&tmp, "Updatable");
2300 let (actor, client) = cli_actor();
2301
2302 let mut sections = IndexMap::new();
2303 sections.insert("identity".to_string(), "Updated body.".to_string());
2304
2305 let outcome = engine
2306 .update_entity(
2307 UpdateEntityArgs {
2308 anchors: Vec::new(),
2309 id: seeded.id.clone(),
2310 expected_hash: Some(seeded.content_hash.clone()),
2311 sections,
2312 append_sections: IndexMap::new(),
2313 patch_sections: IndexMap::new(),
2314 metadata: IndexMap::new(),
2315 metadata_unset: Vec::new(),
2316 declare_relations: Vec::new(),
2317 dry_run: false,
2318 relations_unset: Vec::new(),
2319 anchors_unset: Vec::new(),
2320 },
2321 actor,
2322 Some(&client),
2323 Some("section update"),
2324 )
2325 .unwrap();
2326
2327 assert_eq!(
2328 outcome.modified_sections.replaced,
2329 vec!["identity".to_string()]
2330 );
2331 assert_ne!(
2332 outcome.content_hash, seeded.content_hash,
2333 "hash must change"
2334 );
2335 let entity = engine.get_entity(&seeded.id).unwrap();
2337 assert!(
2338 entity
2339 .sections
2340 .get("identity")
2341 .unwrap()
2342 .contains("Updated body.")
2343 );
2344 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
2346 assert!(log.contains("\"kind\":\"update\""));
2347 assert!(log.contains("\"note\":\"section update\""));
2348 }
2349
2350 #[test]
2351 fn update_entity_rejects_hash_mismatch() {
2352 let tmp = TempDir::new().unwrap();
2353 let (mut engine, seeded) = engine_with_seed(&tmp, "Hash Guarded");
2354 let (actor, client) = cli_actor();
2355 let err = engine
2356 .update_entity(
2357 UpdateEntityArgs {
2358 anchors: Vec::new(),
2359 id: seeded.id.clone(),
2360 expected_hash: Some("wrong-hash".to_string()),
2361 sections: IndexMap::new(),
2362 append_sections: IndexMap::new(),
2363 patch_sections: IndexMap::new(),
2364 metadata: IndexMap::new(),
2365 metadata_unset: Vec::new(),
2366 declare_relations: Vec::new(),
2367 dry_run: false,
2368 relations_unset: Vec::new(),
2369 anchors_unset: Vec::new(),
2370 },
2371 actor,
2372 Some(&client),
2373 None,
2374 )
2375 .unwrap_err();
2376 match err {
2377 EngineError::HashMismatch {
2378 id,
2379 current,
2380 is_stub,
2381 } => {
2382 assert_eq!(id, seeded.id.to_string());
2383 assert_eq!(current, seeded.content_hash);
2384 assert!(!is_stub, "real entity must not flag as stub");
2385 }
2386 other => panic!("expected HashMismatch, got {other:?}"),
2387 }
2388 }
2389
2390 #[test]
2391 fn update_entity_rejects_unknown_id() {
2392 let tmp = TempDir::new().unwrap();
2393 let (mut engine, _) = engine_with_seed(&tmp, "Anchor");
2394 let (actor, client) = cli_actor();
2395 let err = engine
2396 .update_entity(
2397 UpdateEntityArgs {
2398 anchors: Vec::new(),
2399 id: crate::EntityId::new("specs", "ghost"),
2400 expected_hash: None,
2401 sections: IndexMap::new(),
2402 append_sections: IndexMap::new(),
2403 patch_sections: IndexMap::new(),
2404 metadata: IndexMap::new(),
2405 metadata_unset: Vec::new(),
2406 declare_relations: Vec::new(),
2407 dry_run: false,
2408 relations_unset: Vec::new(),
2409 anchors_unset: Vec::new(),
2410 },
2411 actor,
2412 Some(&client),
2413 None,
2414 )
2415 .unwrap_err();
2416 assert!(matches!(err, EngineError::NotFound { .. }));
2417 }
2418
2419 #[test]
2420 fn update_entity_rejects_read_only_mount() {
2421 let tmp = TempDir::new().unwrap();
2422 let archive_path = build_archive(
2423 tmp.path(),
2424 "ext",
2425 &[(
2426 "a.md",
2427 b"---\ntype: spec\n---\n# A\n\n## Identity\n\nbody.\n",
2428 )],
2429 );
2430 let mut engine = Engine::from_mounts(vec![(
2431 archive_mount("external", archive_path.clone()),
2432 Box::new(ArchiveBackend::new(archive_path)),
2433 )])
2434 .unwrap();
2435 let (actor, client) = cli_actor();
2436 let id = crate::EntityId::new("external", "a");
2437 let err = engine
2438 .update_entity(
2439 UpdateEntityArgs {
2440 anchors: Vec::new(),
2441 id,
2442 expected_hash: None,
2443 sections: IndexMap::new(),
2444 append_sections: IndexMap::new(),
2445 patch_sections: IndexMap::new(),
2446 metadata: IndexMap::new(),
2447 metadata_unset: Vec::new(),
2448 declare_relations: Vec::new(),
2449 dry_run: false,
2450 relations_unset: Vec::new(),
2451 anchors_unset: Vec::new(),
2452 },
2453 actor,
2454 Some(&client),
2455 None,
2456 )
2457 .unwrap_err();
2458 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "external"));
2459 }
2460
2461 #[test]
2462 fn update_entity_patches_section_with_find_and_replace() {
2463 let tmp = TempDir::new().unwrap();
2464 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Subject");
2465 let (actor, client) = cli_actor();
2466
2467 let mut replace = IndexMap::new();
2470 replace.insert("identity".to_string(), "hello world hello".to_string());
2471 let replaced = engine
2472 .update_entity(
2473 UpdateEntityArgs {
2474 anchors: Vec::new(),
2475 id: seeded.id.clone(),
2476 expected_hash: Some(seeded.content_hash.clone()),
2477 sections: replace,
2478 append_sections: IndexMap::new(),
2479 patch_sections: IndexMap::new(),
2480 metadata: IndexMap::new(),
2481 metadata_unset: Vec::new(),
2482 declare_relations: Vec::new(),
2483 dry_run: false,
2484 relations_unset: Vec::new(),
2485 anchors_unset: Vec::new(),
2486 },
2487 actor,
2488 Some(&client),
2489 None,
2490 )
2491 .unwrap();
2492
2493 let mut patches = IndexMap::new();
2495 patches.insert(
2496 "identity".to_string(),
2497 crate::ops::PatchArg {
2498 old: "hello".to_string(),
2499 new: "HI".to_string(),
2500 all: false,
2501 },
2502 );
2503 let outcome = engine
2504 .update_entity(
2505 UpdateEntityArgs {
2506 anchors: Vec::new(),
2507 id: seeded.id.clone(),
2508 expected_hash: Some(replaced.content_hash.clone()),
2509 sections: IndexMap::new(),
2510 append_sections: IndexMap::new(),
2511 patch_sections: patches,
2512 metadata: IndexMap::new(),
2513 metadata_unset: Vec::new(),
2514 declare_relations: Vec::new(),
2515 dry_run: false,
2516 relations_unset: Vec::new(),
2517 anchors_unset: Vec::new(),
2518 },
2519 actor,
2520 Some(&client),
2521 None,
2522 )
2523 .unwrap();
2524 assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
2525 let body = engine
2526 .get_entity(&seeded.id)
2527 .unwrap()
2528 .sections
2529 .get("identity")
2530 .unwrap()
2531 .clone();
2532 assert!(body.contains("HI world hello"), "first-only: {body:?}");
2533 }
2534
2535 #[test]
2536 fn update_entity_patch_rejects_missing_old_substring() {
2537 let tmp = TempDir::new().unwrap();
2538 let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Miss");
2539 let (actor, client) = cli_actor();
2540 let mut patches = IndexMap::new();
2541 patches.insert(
2542 "identity".to_string(),
2543 crate::ops::PatchArg {
2544 old: "this-substring-does-not-exist".to_string(),
2545 new: "nope".to_string(),
2546 all: false,
2547 },
2548 );
2549 let err = engine
2550 .update_entity(
2551 UpdateEntityArgs {
2552 anchors: Vec::new(),
2553 id: seeded.id.clone(),
2554 expected_hash: Some(seeded.content_hash.clone()),
2555 sections: IndexMap::new(),
2556 append_sections: IndexMap::new(),
2557 patch_sections: patches,
2558 metadata: IndexMap::new(),
2559 metadata_unset: Vec::new(),
2560 declare_relations: Vec::new(),
2561 dry_run: false,
2562 relations_unset: Vec::new(),
2563 anchors_unset: Vec::new(),
2564 },
2565 actor,
2566 Some(&client),
2567 None,
2568 )
2569 .unwrap_err();
2570 match err {
2571 EngineError::PatchOldNotFound { section, .. } => {
2572 assert_eq!(section, "identity");
2573 }
2574 other => panic!("expected PatchOldNotFound, got {other:?}"),
2575 }
2576 }
2577
2578 #[test]
2579 fn update_entity_appends_to_existing_section_with_newline_separator() {
2580 let tmp = TempDir::new().unwrap();
2581 let (mut engine, seeded) = engine_with_seed(&tmp, "Append Subject");
2582 let (actor, client) = cli_actor();
2583
2584 let mut appends = IndexMap::new();
2585 appends.insert("identity".to_string(), "appended tail.".to_string());
2586
2587 let outcome = engine
2588 .update_entity(
2589 UpdateEntityArgs {
2590 anchors: Vec::new(),
2591 id: seeded.id.clone(),
2592 expected_hash: Some(seeded.content_hash.clone()),
2593 sections: IndexMap::new(),
2594 append_sections: appends,
2595 patch_sections: IndexMap::new(),
2596 metadata: IndexMap::new(),
2597 metadata_unset: Vec::new(),
2598 declare_relations: Vec::new(),
2599 dry_run: false,
2600 relations_unset: Vec::new(),
2601 anchors_unset: Vec::new(),
2602 },
2603 actor,
2604 Some(&client),
2605 None,
2606 )
2607 .unwrap();
2608
2609 assert_eq!(outcome.modified_sections.appended, vec!["identity"]);
2612 assert!(outcome.modified_sections.replaced.is_empty());
2613
2614 let updated = engine.get_entity(&seeded.id).unwrap();
2616 let body = updated.sections.get("identity").expect("identity section");
2617 assert!(
2618 body.contains("appended tail."),
2619 "appended body missing: {body:?}"
2620 );
2621 }
2622
2623 #[test]
2630 fn update_entity_against_stub_surfaces_typed_stub_not_updatable() {
2631 let tmp = TempDir::new().unwrap();
2632 let (mut engine, source) = engine_with_seed(&tmp, "Source");
2633 let (actor, client) = cli_actor();
2634 let stub_id = crate::EntityId::new("specs", "stub-update-target");
2637 engine
2638 .relate_entity(
2639 RelateEntityArgs {
2640 source: source.id.clone(),
2641 expected_hash: Some(source.content_hash.clone()),
2642 rel_type: "USES".to_string(),
2643 target: stub_id.clone(),
2644 remove: false,
2645 description: None,
2646 dry_run: false,
2647 },
2648 actor,
2649 Some(&client),
2650 None,
2651 )
2652 .unwrap();
2653
2654 let err = engine
2655 .update_entity(
2656 UpdateEntityArgs {
2657 anchors: Vec::new(),
2658 id: stub_id.clone(),
2659 expected_hash: Some(String::new()),
2660 sections: IndexMap::from_iter([("identity".to_string(), "body".to_string())]),
2661 append_sections: IndexMap::new(),
2662 patch_sections: IndexMap::new(),
2663 metadata: IndexMap::new(),
2664 metadata_unset: Vec::new(),
2665 declare_relations: Vec::new(),
2666 dry_run: false,
2667 relations_unset: Vec::new(),
2668 anchors_unset: Vec::new(),
2669 },
2670 actor,
2671 Some(&client),
2672 None,
2673 )
2674 .unwrap_err();
2675 match err {
2676 EngineError::StubNotUpdatable { id } => assert_eq!(id, stub_id.to_string()),
2677 other => panic!("expected StubNotUpdatable, got {other:?}"),
2678 }
2679 }
2680
2681 #[test]
2682 fn update_entity_rejects_conflicting_section_modes() {
2683 let tmp = TempDir::new().unwrap();
2684 let (mut engine, seeded) = engine_with_seed(&tmp, "Conflict");
2685 let (actor, client) = cli_actor();
2686
2687 let mut sections = IndexMap::new();
2688 sections.insert("identity".to_string(), "replace".to_string());
2689 let mut appends = IndexMap::new();
2690 appends.insert("identity".to_string(), "append".to_string());
2691
2692 let err = engine
2693 .update_entity(
2694 UpdateEntityArgs {
2695 anchors: Vec::new(),
2696 id: seeded.id.clone(),
2697 expected_hash: Some(seeded.content_hash.clone()),
2698 sections,
2699 append_sections: appends,
2700 patch_sections: IndexMap::new(),
2701 metadata: IndexMap::new(),
2702 metadata_unset: Vec::new(),
2703 declare_relations: Vec::new(),
2704 dry_run: false,
2705 relations_unset: Vec::new(),
2706 anchors_unset: Vec::new(),
2707 },
2708 actor,
2709 Some(&client),
2710 None,
2711 )
2712 .unwrap_err();
2713
2714 match err {
2715 EngineError::ConflictingSectionModes { section, modes } => {
2716 assert_eq!(section, "identity");
2717 assert_eq!(modes, vec!["sections", "append_sections"]);
2718 }
2719 other => panic!("expected ConflictingSectionModes, got {other:?}"),
2720 }
2721 }
2722
2723 #[test]
2724 fn update_entity_rejects_overlapping_metadata_and_metadata_unset_keys() {
2725 let tmp = TempDir::new().unwrap();
2730 let (mut engine, seeded) = engine_with_seed(&tmp, "Overlap Subject");
2731 let (actor, client) = cli_actor();
2732
2733 let mut metadata = IndexMap::new();
2734 metadata.insert("tags".to_string(), "foo".to_string());
2738
2739 let err = engine
2740 .update_entity(
2741 UpdateEntityArgs {
2742 anchors: Vec::new(),
2743 id: seeded.id.clone(),
2744 expected_hash: Some(seeded.content_hash.clone()),
2745 sections: IndexMap::new(),
2746 append_sections: IndexMap::new(),
2747 patch_sections: IndexMap::new(),
2748 metadata,
2749 metadata_unset: vec!["tags".to_string()],
2750 declare_relations: Vec::new(),
2751 dry_run: false,
2752 relations_unset: Vec::new(),
2753 anchors_unset: Vec::new(),
2754 },
2755 actor,
2756 Some(&client),
2757 None,
2758 )
2759 .unwrap_err();
2760 match err {
2761 EngineError::SetAndUnsetConflict { keys } => {
2762 assert_eq!(keys, vec!["tags".to_string()]);
2763 }
2764 other => panic!("expected SetAndUnsetConflict, got {other:?}"),
2765 }
2766 }
2767
2768 #[test]
2769 fn update_entity_pointer_schema_auto_synthesises_references_from_body_link() {
2770 use crate::EntityId;
2778 use crate::engine::UpdateEntityArgs;
2779 use indexmap::IndexMap;
2780 use tempfile::TempDir;
2781
2782 let tmp = TempDir::new().unwrap();
2783 let mem_dir = tmp.path().to_path_buf();
2784 let writer = FilesystemMemWriter::new(mem_dir.clone());
2785 let mut engine = Engine::from_mounts(vec![(
2786 folder_mount("specs", mem_dir.clone()),
2787 Box::new(writer) as Box<dyn MemBackend>,
2788 )])
2789 .unwrap();
2790 engine.set_workspace_root(mem_dir.clone());
2791 let (actor, client) = cli_actor();
2792
2793 let target = engine
2794 .create_entity(
2795 empty_create_args("specs", "Target"),
2796 actor,
2797 Some(&client),
2798 None,
2799 )
2800 .unwrap();
2801 let source = engine
2802 .create_entity(
2803 empty_create_args("specs", "Source"),
2804 actor,
2805 Some(&client),
2806 None,
2807 )
2808 .unwrap();
2809
2810 let mut sections: IndexMap<String, String> = IndexMap::new();
2811 sections.insert(
2812 "purpose".to_string(),
2813 "see [[target]] for context".to_string(),
2814 );
2815 let outcome = engine
2816 .update_entity(
2817 UpdateEntityArgs {
2818 anchors: Vec::new(),
2819 id: source.id.clone(),
2820 expected_hash: Some(source.content_hash.clone()),
2821 sections,
2822 append_sections: IndexMap::new(),
2823 patch_sections: IndexMap::new(),
2824 metadata: IndexMap::new(),
2825 metadata_unset: Vec::new(),
2826 declare_relations: Vec::new(),
2827 dry_run: false,
2828 relations_unset: Vec::new(),
2829 anchors_unset: Vec::new(),
2830 },
2831 actor,
2832 Some(&client),
2833 None,
2834 )
2835 .expect("auto-synthesis must satisfy the alias-existence invariant");
2836 assert!(
2838 outcome
2839 .modified_sections
2840 .replaced
2841 .iter()
2842 .any(|s| s == "purpose"),
2843 );
2844 let in_mem = engine.get_entity(&source.id).unwrap();
2845 assert_eq!(
2846 in_mem
2847 .sections
2848 .get("purpose")
2849 .map(String::as_str)
2850 .unwrap_or(""),
2851 "see [[target]] for context",
2852 );
2853 assert!(
2855 in_mem
2856 .relationships
2857 .iter()
2858 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2859 "synthesis must emit REFERENCES → target; relationships: {:?}",
2860 in_mem.relationships,
2861 );
2862 let _ = EntityId::new("specs", "x");
2864 }
2865
2866 #[test]
2867 fn update_entity_declare_relations_passes_strict_validator_in_one_call() {
2868 use crate::engine::UpdateEntityArgs;
2875 use crate::ops::RelateArg;
2876 use indexmap::IndexMap;
2877 use tempfile::TempDir;
2878
2879 let tmp = TempDir::new().unwrap();
2880 let mem_dir = tmp.path().to_path_buf();
2881 let writer = FilesystemMemWriter::new(mem_dir.clone());
2882 let mut engine = Engine::from_mounts(vec![(
2883 folder_mount("specs", mem_dir.clone()),
2884 Box::new(writer) as Box<dyn MemBackend>,
2885 )])
2886 .unwrap();
2887 engine.set_workspace_root(mem_dir.clone());
2888 let (actor, client) = cli_actor();
2889
2890 let target = engine
2891 .create_entity(
2892 empty_create_args("specs", "Target"),
2893 actor,
2894 Some(&client),
2895 None,
2896 )
2897 .unwrap();
2898 let source = engine
2899 .create_entity(
2900 empty_create_args("specs", "Source"),
2901 actor,
2902 Some(&client),
2903 None,
2904 )
2905 .unwrap();
2906
2907 let mut sections: IndexMap<String, String> = IndexMap::new();
2915 sections.insert(
2916 "purpose".to_string(),
2917 "see [[target]] for context".to_string(),
2918 );
2919 let outcome = engine
2920 .update_entity(
2921 UpdateEntityArgs {
2922 anchors: Vec::new(),
2923 relations_unset: Vec::new(),
2924 anchors_unset: Vec::new(),
2925 id: source.id.clone(),
2926 expected_hash: Some(source.content_hash.clone()),
2927 sections,
2928 append_sections: IndexMap::new(),
2929 patch_sections: IndexMap::new(),
2930 metadata: IndexMap::new(),
2931 metadata_unset: Vec::new(),
2932 dry_run: false,
2933 declare_relations: vec![RelateArg {
2934 rel_type: "USES".to_string(),
2935 to: target.id.clone(),
2936 description: None,
2937 }],
2938 },
2939 actor,
2940 Some(&client),
2941 None,
2942 )
2943 .expect("declare_relations + body update must succeed in one call");
2944
2945 assert_eq!(outcome.relations_declared.len(), 1);
2946 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
2947 assert_eq!(outcome.relations_declared[0].target, target.id);
2948 assert!(
2949 !outcome.relations_declared[0].target_was_stubbed,
2950 "target was already present in store; target_was_stubbed must be false"
2951 );
2952
2953 let in_mem = engine.get_entity(&source.id).unwrap();
2954 assert!(
2955 in_mem.relationships.iter().any(|r| r.target == target.id),
2956 "declared relation must land in entity.relationships; got {:?}",
2957 in_mem.relationships
2958 );
2959 }
2960
2961 #[test]
2962 fn update_entity_declare_relations_auto_stubs_absent_target() {
2963 use crate::EntityId;
2967 use crate::engine::UpdateEntityArgs;
2968 use crate::ops::RelateArg;
2969 use indexmap::IndexMap;
2970
2971 let tmp = TempDir::new().unwrap();
2972 let (mut engine, source) = engine_with_seed(&tmp, "Source");
2973 let (actor, client) = cli_actor();
2974 let absent_target = EntityId::new("specs", "not-yet-existing");
2975 assert!(!engine.store().contains(&absent_target));
2976
2977 let outcome = engine
2978 .update_entity(
2979 UpdateEntityArgs {
2980 anchors: Vec::new(),
2981 relations_unset: Vec::new(),
2982 anchors_unset: Vec::new(),
2983 id: source.id.clone(),
2984 expected_hash: Some(source.content_hash.clone()),
2985 sections: IndexMap::new(),
2986 append_sections: IndexMap::new(),
2987 patch_sections: IndexMap::new(),
2988 metadata: IndexMap::new(),
2989 metadata_unset: Vec::new(),
2990 dry_run: false,
2991 declare_relations: vec![RelateArg {
2992 rel_type: "USES".to_string(),
2993 to: absent_target.clone(),
2994 description: None,
2995 }],
2996 },
2997 actor,
2998 Some(&client),
2999 None,
3000 )
3001 .unwrap();
3002
3003 assert_eq!(outcome.relations_declared.len(), 1);
3004 assert!(
3005 outcome.relations_declared[0].target_was_stubbed,
3006 "absent target must be auto-stubbed; got target_was_stubbed=false"
3007 );
3008 assert!(engine.store().contains(&absent_target));
3010 let stub = engine.get_entity(&absent_target).unwrap();
3011 assert!(stub.stub);
3012 }
3013
3014 #[test]
3015 fn update_entity_alias_synthesis_runs_unconditionally_for_pointer_schemas() {
3016 use crate::engine::UpdateEntityArgs;
3022 use indexmap::IndexMap;
3023 use tempfile::TempDir;
3024
3025 let tmp = TempDir::new().unwrap();
3026 let mem_dir = tmp.path().to_path_buf();
3027 let writer = FilesystemMemWriter::new(mem_dir.clone());
3028 let mut engine = Engine::from_mounts(vec![(
3029 folder_mount("specs", mem_dir.clone()),
3030 Box::new(writer) as Box<dyn MemBackend>,
3031 )])
3032 .unwrap();
3033 engine.set_workspace_root(mem_dir.clone());
3034 let (actor, client) = cli_actor();
3035 let target = engine
3036 .create_entity(
3037 empty_create_args("specs", "Target"),
3038 actor,
3039 Some(&client),
3040 None,
3041 )
3042 .unwrap();
3043 let source = engine
3044 .create_entity(
3045 empty_create_args("specs", "Source"),
3046 actor,
3047 Some(&client),
3048 None,
3049 )
3050 .unwrap();
3051
3052 let mut sections: IndexMap<String, String> = IndexMap::new();
3053 sections.insert(
3054 "purpose".to_string(),
3055 "see [[target]] for context".to_string(),
3056 );
3057 engine
3058 .update_entity(
3059 UpdateEntityArgs {
3060 anchors: Vec::new(),
3061 id: source.id.clone(),
3062 expected_hash: Some(source.content_hash.clone()),
3063 sections,
3064 append_sections: IndexMap::new(),
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 .expect("synthesis must back the wiki-link and let the body land");
3078 let in_mem = engine.get_entity(&source.id).unwrap();
3079 assert!(
3080 in_mem
3081 .relationships
3082 .iter()
3083 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3084 "synthesis must emit REFERENCES → target; relationships: {:?}",
3085 in_mem.relationships,
3086 );
3087 }
3088
3089 #[test]
3090 fn update_entity_dry_run_returns_prospective_hash_without_writing() {
3091 let tmp = TempDir::new().unwrap();
3092 let (mut engine, seeded) = engine_with_seed(&tmp, "Preview Subject");
3093 let (actor, client) = cli_actor();
3094 let original_hash = seeded.content_hash.clone();
3095
3096 let mut sections = IndexMap::new();
3097 sections.insert("identity".to_string(), "preview body".to_string());
3098
3099 let outcome = engine
3100 .update_entity(
3101 UpdateEntityArgs {
3102 anchors: Vec::new(),
3103 id: seeded.id.clone(),
3104 expected_hash: Some("wrong-hash".to_string()),
3107 sections,
3108 append_sections: IndexMap::new(),
3109 patch_sections: IndexMap::new(),
3110 metadata: IndexMap::new(),
3111 metadata_unset: Vec::new(),
3112 declare_relations: Vec::new(),
3113 dry_run: true,
3114 relations_unset: Vec::new(),
3115 anchors_unset: Vec::new(),
3116 },
3117 actor,
3118 Some(&client),
3119 None,
3120 )
3121 .unwrap();
3122
3123 assert_eq!(outcome.content_hash, original_hash);
3126 let prospective = outcome
3127 .prospective_hash
3128 .expect("prospective_hash populated on dry_run");
3129 assert_ne!(prospective, original_hash);
3130 assert!(outcome.commit_sha.is_empty());
3131 let store_entity = engine.get_entity(&seeded.id).unwrap();
3133 assert_eq!(store_entity.content_hash, original_hash);
3134 }
3135
3136 #[test]
3155 fn references_edges_round_trip_across_full_crud_cycle() {
3156 let tmp = TempDir::new().unwrap();
3157 let mem_dir = tmp.path().to_path_buf();
3158 let writer = FilesystemMemWriter::new(mem_dir.clone());
3159 let mut engine = Engine::from_mounts(vec![(
3160 folder_mount("specs", mem_dir),
3161 Box::new(writer) as Box<dyn MemBackend>,
3162 )])
3163 .unwrap();
3164 let (actor, client) = cli_actor();
3165
3166 let foo = engine
3170 .create_entity(
3171 empty_create_args("specs", "Foo"),
3172 actor,
3173 Some(&client),
3174 None,
3175 )
3176 .unwrap();
3177 let bar = engine
3178 .create_entity(
3179 empty_create_args("specs", "Bar"),
3180 actor,
3181 Some(&client),
3182 None,
3183 )
3184 .unwrap();
3185
3186 let count_references = |engine: &Engine| -> usize {
3187 engine
3188 .store()
3189 .all_ids()
3190 .flat_map(|id| engine.store().outgoing(id))
3191 .filter(|e| e.rel_type == "REFERENCES")
3192 .count()
3193 };
3194
3195 let baseline_edges = engine.store().edge_count();
3196 let baseline_refs = count_references(&engine);
3197
3198 let mut sections = IndexMap::new();
3204 sections.insert(
3205 "identity".to_string(),
3206 "See [[foo]] and [[bar]] inline.".to_string(),
3207 );
3208 sections.insert("purpose".to_string(), "probe purpose".to_string());
3209 let probe = engine
3210 .create_entity(
3211 CreateEntityArgs {
3212 anchors: Vec::new(),
3213 mem: "specs".to_string(),
3214 title: "Probe".to_string(),
3215 entity_type: "spec".to_string(),
3216 sections,
3217 metadata: IndexMap::new(),
3218 relations: Vec::new(),
3219 dry_run: false,
3220 },
3221 actor,
3222 Some(&client),
3223 None,
3224 )
3225 .unwrap();
3226 assert_eq!(count_references(&engine), baseline_refs + 2);
3227
3228 let relate1 = engine
3233 .relate_entity(
3234 RelateEntityArgs {
3235 source: probe.id.clone(),
3236 expected_hash: Some(probe.content_hash.clone()),
3237 rel_type: "INFORMED_BY".to_string(),
3238 target: foo.id.clone(),
3239 remove: false,
3240 description: None,
3241 dry_run: false,
3242 },
3243 actor,
3244 Some(&client),
3245 None,
3246 )
3247 .unwrap();
3248 assert_eq!(
3249 count_references(&engine),
3250 baseline_refs + 2,
3251 "set-membership aliasing — adding INFORMED_BY does not \
3252 absorb the REFERENCES relation"
3253 );
3254
3255 let mut sections = IndexMap::new();
3259 sections.insert("identity".to_string(), "See [[foo]] inline.".to_string());
3260 let updated = engine
3261 .update_entity(
3262 UpdateEntityArgs {
3263 anchors: Vec::new(),
3264 id: probe.id.clone(),
3265 expected_hash: Some(relate1.content_hash.clone()),
3266 sections,
3267 append_sections: IndexMap::new(),
3268 patch_sections: IndexMap::new(),
3269 metadata: IndexMap::new(),
3270 metadata_unset: Vec::new(),
3271 declare_relations: Vec::new(),
3272 dry_run: false,
3273 relations_unset: Vec::new(),
3274 anchors_unset: Vec::new(),
3275 },
3276 actor,
3277 Some(&client),
3278 None,
3279 )
3280 .unwrap();
3281 assert_eq!(
3282 count_references(&engine),
3283 baseline_refs + 1,
3284 "REFERENCES → bar must be auto-GC'd when its body link drops"
3285 );
3286
3287 let renamed = engine
3289 .rename_entity(
3290 crate::engine::RenameEntityArgs {
3291 id: probe.id.clone(),
3292 expected_hash: Some(updated.content_hash.clone()),
3293 new_title: "Probe Renamed".to_string(),
3294 },
3295 actor,
3296 Some(&client),
3297 None,
3298 )
3299 .unwrap();
3300 assert_eq!(count_references(&engine), baseline_refs + 1);
3301
3302 engine
3305 .delete_entity(
3306 crate::engine::DeleteEntityArgs {
3307 id: renamed.new_id.clone(),
3308 expected_hash: Some(renamed.content_hash.clone()),
3309 },
3310 actor,
3311 Some(&client),
3312 None,
3313 )
3314 .unwrap();
3315
3316 assert_eq!(
3318 engine.store().edge_count(),
3319 baseline_edges,
3320 "total edges must round-trip to baseline"
3321 );
3322 assert_eq!(
3323 count_references(&engine),
3324 baseline_refs,
3325 "REFERENCES counter must round-trip to baseline"
3326 );
3327
3328 engine.reload_one_mem("specs").unwrap();
3332 assert_eq!(
3333 engine.store().edge_count(),
3334 baseline_edges,
3335 "total edges must match disk after reload"
3336 );
3337 assert_eq!(
3338 count_references(&engine),
3339 baseline_refs,
3340 "REFERENCES must match disk after reload"
3341 );
3342 assert!(engine.store().contains(&foo.id));
3344 assert!(engine.store().contains(&bar.id));
3345 }
3346
3347 #[test]
3348 fn update_entity_returns_commit_sha_title_modified_date_warnings_shape() {
3349 let tmp = TempDir::new().unwrap();
3350 let (mut engine, seeded) = engine_with_seed(&tmp, "Subject");
3351 let (actor, client) = cli_actor();
3352
3353 let mut sections = IndexMap::new();
3354 sections.insert("identity".to_string(), "edited body".to_string());
3355
3356 let outcome = engine
3357 .update_entity(
3358 UpdateEntityArgs {
3359 anchors: Vec::new(),
3360 id: seeded.id.clone(),
3361 expected_hash: Some(seeded.content_hash.clone()),
3362 sections,
3363 append_sections: IndexMap::new(),
3364 patch_sections: IndexMap::new(),
3365 metadata: IndexMap::new(),
3366 metadata_unset: Vec::new(),
3367 declare_relations: Vec::new(),
3368 dry_run: false,
3369 relations_unset: Vec::new(),
3370 anchors_unset: Vec::new(),
3371 },
3372 actor,
3373 Some(&client),
3374 None,
3375 )
3376 .unwrap();
3377
3378 assert!(
3380 !outcome.commit_sha.is_empty(),
3381 "commit_sha must be populated on a real update"
3382 );
3383 assert_eq!(outcome.title, "Subject");
3385 assert!(
3390 !outcome.modified_date.is_empty(),
3391 "modified_date must be auto-stamped on update for the default spec schema",
3392 );
3393 assert!(outcome.warnings.is_empty());
3397 assert_eq!(
3399 outcome.modified_sections.replaced,
3400 vec!["identity".to_string()]
3401 );
3402 }
3403
3404 #[test]
3413 fn update_entity_noop_resetting_section_to_current_value_preserves_last_modified() {
3414 let tmp = TempDir::new().unwrap();
3415 let (mut engine, seeded) = engine_with_seed(&tmp, "Section Resetter");
3416 let (actor, client) = cli_actor();
3417
3418 let pre_last_modified = engine
3421 .get_entity(&seeded.id)
3422 .and_then(|e| e.metadata.get("last_modified"))
3423 .map(|v| v.to_frontmatter_string())
3424 .expect("seeded entity has last_modified");
3425
3426 let mut sections = IndexMap::new();
3430 sections.insert("identity".to_string(), "fixture identity body".to_string());
3431 let outcome = engine
3432 .update_entity(
3433 UpdateEntityArgs {
3434 anchors: Vec::new(),
3435 id: seeded.id.clone(),
3436 expected_hash: Some(seeded.content_hash.clone()),
3437 sections,
3438 append_sections: IndexMap::new(),
3439 patch_sections: IndexMap::new(),
3440 metadata: IndexMap::new(),
3441 metadata_unset: Vec::new(),
3442 declare_relations: Vec::new(),
3443 dry_run: false,
3444 relations_unset: Vec::new(),
3445 anchors_unset: Vec::new(),
3446 },
3447 actor,
3448 Some(&client),
3449 None,
3450 )
3451 .unwrap();
3452
3453 assert_eq!(outcome.commit_sha, "", "no-op must not commit");
3454 assert_eq!(
3455 outcome.content_hash, seeded.content_hash,
3456 "no-op must not advance content_hash",
3457 );
3458 assert!(
3459 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3460 "UPDATE_NOOP must fire on bytes-identical re-set",
3461 );
3462 assert_eq!(
3463 outcome.modified_date, pre_last_modified,
3464 "no-op must preserve last_modified at the pre-call value",
3465 );
3466 assert!(
3471 outcome.modified_sections.replaced.is_empty()
3472 && outcome.modified_sections.appended.is_empty()
3473 && outcome.modified_sections.patched.is_empty(),
3474 "no-op must report an empty section delta, got {:?}",
3475 outcome.modified_sections,
3476 );
3477
3478 let post_last_modified = engine
3482 .get_entity(&seeded.id)
3483 .and_then(|e| e.metadata.get("last_modified"))
3484 .map(|v| v.to_frontmatter_string())
3485 .expect("entity still in store");
3486 assert_eq!(post_last_modified, pre_last_modified);
3487 }
3488
3489 #[test]
3501 fn update_entity_empty_payload_refuses_with_typed_code() {
3502 let tmp = TempDir::new().unwrap();
3503 let (mut engine, seeded) = engine_with_seed(&tmp, "Empty Payload");
3504 let (actor, client) = cli_actor();
3505
3506 let err = engine
3507 .update_entity(
3508 UpdateEntityArgs {
3509 anchors: Vec::new(),
3510 id: seeded.id.clone(),
3511 expected_hash: Some(seeded.content_hash.clone()),
3512 sections: IndexMap::new(),
3513 append_sections: IndexMap::new(),
3514 patch_sections: IndexMap::new(),
3515 metadata: IndexMap::new(),
3516 metadata_unset: Vec::new(),
3517 declare_relations: Vec::new(),
3518 dry_run: false,
3519 relations_unset: Vec::new(),
3520 anchors_unset: Vec::new(),
3521 },
3522 actor,
3523 Some(&client),
3524 None,
3525 )
3526 .unwrap_err();
3527 match err {
3528 EngineError::EmptyUpdate { id } => {
3529 assert_eq!(id, seeded.id.to_string());
3530 }
3531 other => panic!("expected EMPTY_UPDATE, got {other:?}"),
3532 }
3533 let log_path = tmp.path().join(".memstead/changes.jsonl");
3535 if let Ok(log) = std::fs::read_to_string(&log_path) {
3536 let updates = log.matches("\"kind\":\"update\"").count();
3537 assert_eq!(updates, 0, "EMPTY_UPDATE refusal must not log an update");
3538 }
3539 }
3540
3541 #[test]
3547 fn update_entity_noop_same_content_surfaces_warning() {
3548 let tmp = TempDir::new().unwrap();
3549 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Content Noop");
3550 let (actor, client) = cli_actor();
3551
3552 let mut sections = IndexMap::new();
3554 sections.insert("identity".to_string(), "fixture identity body".to_string());
3555
3556 let outcome = engine
3557 .update_entity(
3558 UpdateEntityArgs {
3559 anchors: Vec::new(),
3560 id: seeded.id.clone(),
3561 expected_hash: Some(seeded.content_hash.clone()),
3562 sections,
3563 append_sections: IndexMap::new(),
3564 patch_sections: IndexMap::new(),
3565 metadata: IndexMap::new(),
3566 metadata_unset: Vec::new(),
3567 declare_relations: Vec::new(),
3568 dry_run: false,
3569 relations_unset: Vec::new(),
3570 anchors_unset: Vec::new(),
3571 },
3572 actor,
3573 Some(&client),
3574 None,
3575 )
3576 .unwrap();
3577
3578 assert_eq!(outcome.commit_sha, "");
3579 assert_eq!(outcome.content_hash, seeded.content_hash);
3580 let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code()).collect();
3581 assert!(
3582 codes.contains(&"UPDATE_NOOP"),
3583 "same-content update must surface UPDATE_NOOP; got {codes:?}",
3584 );
3585 }
3586
3587 #[test]
3588 fn update_entity_noop_metadata_unset_on_absent_key() {
3589 let tmp = TempDir::new().unwrap();
3594 let (mut engine, seeded) = engine_with_seed(&tmp, "Absent Key Noop");
3595 let (actor, client) = cli_actor();
3596
3597 let outcome = engine
3598 .update_entity(
3599 UpdateEntityArgs {
3600 anchors: Vec::new(),
3601 id: seeded.id.clone(),
3602 expected_hash: Some(seeded.content_hash.clone()),
3603 sections: IndexMap::new(),
3604 append_sections: IndexMap::new(),
3605 patch_sections: IndexMap::new(),
3606 metadata: IndexMap::new(),
3607 metadata_unset: vec!["tags".to_string()],
3611 declare_relations: Vec::new(),
3612 dry_run: false,
3613 relations_unset: Vec::new(),
3614 anchors_unset: Vec::new(),
3615 },
3616 actor,
3617 Some(&client),
3618 None,
3619 )
3620 .unwrap();
3621
3622 assert_eq!(outcome.commit_sha, "");
3623 assert_eq!(outcome.content_hash, seeded.content_hash);
3624 assert!(
3625 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3626 "absent-key metadata_unset must surface UPDATE_NOOP",
3627 );
3628 assert!(
3631 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
3632 "no-op must report an empty metadata delta, got {:?}",
3633 outcome.modified_metadata,
3634 );
3635
3636 let mut sections = IndexMap::new();
3639 sections.insert("identity".to_string(), "real change".to_string());
3640 let real = engine
3641 .update_entity(
3642 UpdateEntityArgs {
3643 anchors: Vec::new(),
3644 id: seeded.id.clone(),
3645 expected_hash: Some(seeded.content_hash.clone()),
3646 sections,
3647 append_sections: IndexMap::new(),
3648 patch_sections: IndexMap::new(),
3649 metadata: IndexMap::new(),
3650 metadata_unset: Vec::new(),
3651 declare_relations: Vec::new(),
3652 dry_run: false,
3653 relations_unset: Vec::new(),
3654 anchors_unset: Vec::new(),
3655 },
3656 actor,
3657 Some(&client),
3658 None,
3659 )
3660 .unwrap();
3661 assert!(!real.commit_sha.is_empty());
3662 assert_ne!(real.content_hash, seeded.content_hash);
3663 }
3664
3665 #[test]
3672 fn update_entity_noop_setting_metadata_to_current_value_reports_empty_delta() {
3673 let tmp = TempDir::new().unwrap();
3674 let (mut engine, seeded) = engine_with_seed(&tmp, "Stability Resetter");
3675 let (actor, client) = cli_actor();
3676
3677 let mut metadata = IndexMap::new();
3680 metadata.insert("level".to_string(), "M0".to_string());
3681 let outcome = engine
3682 .update_entity(
3683 UpdateEntityArgs {
3684 anchors: Vec::new(),
3685 id: seeded.id.clone(),
3686 expected_hash: Some(seeded.content_hash.clone()),
3687 sections: IndexMap::new(),
3688 append_sections: IndexMap::new(),
3689 patch_sections: IndexMap::new(),
3690 metadata,
3691 metadata_unset: Vec::new(),
3692 declare_relations: Vec::new(),
3693 dry_run: false,
3694 relations_unset: Vec::new(),
3695 anchors_unset: Vec::new(),
3696 },
3697 actor,
3698 Some(&client),
3699 None,
3700 )
3701 .unwrap();
3702
3703 assert_eq!(outcome.commit_sha, "", "no-op must not commit");
3704 assert_eq!(
3705 outcome.content_hash, seeded.content_hash,
3706 "no-op must not advance hash"
3707 );
3708 assert!(
3709 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3710 "re-set to current value must surface UPDATE_NOOP",
3711 );
3712 assert!(
3713 outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
3714 "no-op must not claim `level` was set — applied delta is empty, got {:?}",
3715 outcome.modified_metadata,
3716 );
3717 }
3718
3719 #[test]
3720 fn update_entity_noop_declare_already_related_edge() {
3721 use crate::ops::RelateArg;
3726 let tmp = TempDir::new().unwrap();
3727 let mem_dir = tmp.path().to_path_buf();
3728 let writer = FilesystemMemWriter::new(mem_dir.clone());
3729 let mut engine = Engine::from_mounts(vec![(
3730 folder_mount("specs", mem_dir),
3731 Box::new(writer) as Box<dyn MemBackend>,
3732 )])
3733 .unwrap();
3734 let (actor, client) = cli_actor();
3735 let target = engine
3736 .create_entity(
3737 empty_create_args("specs", "Target Already Related"),
3738 actor,
3739 Some(&client),
3740 None,
3741 )
3742 .unwrap();
3743 let source = engine
3744 .create_entity(
3745 empty_create_args("specs", "Source Already Related"),
3746 actor,
3747 Some(&client),
3748 None,
3749 )
3750 .unwrap();
3751 let after_relate = engine
3752 .relate_entity(
3753 RelateEntityArgs {
3754 source: source.id.clone(),
3755 expected_hash: Some(source.content_hash.clone()),
3756 rel_type: "USES".to_string(),
3757 target: target.id.clone(),
3758 remove: false,
3759 description: None,
3760 dry_run: false,
3761 },
3762 actor,
3763 Some(&client),
3764 None,
3765 )
3766 .unwrap();
3767 let outcome = engine
3769 .update_entity(
3770 UpdateEntityArgs {
3771 anchors: Vec::new(),
3772 relations_unset: Vec::new(),
3773 anchors_unset: Vec::new(),
3774 id: source.id.clone(),
3775 expected_hash: Some(after_relate.content_hash.clone()),
3776 sections: IndexMap::new(),
3777 append_sections: IndexMap::new(),
3778 patch_sections: IndexMap::new(),
3779 metadata: IndexMap::new(),
3780 metadata_unset: Vec::new(),
3781 declare_relations: vec![RelateArg {
3782 rel_type: "USES".to_string(),
3783 to: target.id.clone(),
3784 description: None,
3785 }],
3786 dry_run: false,
3787 },
3788 actor,
3789 Some(&client),
3790 None,
3791 )
3792 .unwrap();
3793
3794 assert_eq!(outcome.commit_sha, "");
3795 assert_eq!(outcome.content_hash, after_relate.content_hash);
3796 assert!(
3797 outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3798 "duplicate declare must surface UPDATE_NOOP",
3799 );
3800 assert_eq!(outcome.relations_declared.len(), 1);
3803 assert_eq!(outcome.relations_declared[0].rel_type, "USES");
3804 assert_eq!(outcome.relations_declared[0].target, target.id);
3805 assert!(!outcome.relations_declared[0].target_was_stubbed);
3806 }
3807
3808 #[test]
3809 fn update_entity_real_change_still_commits_and_advances_hash() {
3810 let tmp = TempDir::new().unwrap();
3815 let (mut engine, seeded) = engine_with_seed(&tmp, "Real Change Subject");
3816 let (actor, client) = cli_actor();
3817
3818 let mut sections = IndexMap::new();
3819 sections.insert("identity".to_string(), "definitely new body".to_string());
3820
3821 let outcome = engine
3822 .update_entity(
3823 UpdateEntityArgs {
3824 anchors: Vec::new(),
3825 id: seeded.id.clone(),
3826 expected_hash: Some(seeded.content_hash.clone()),
3827 sections,
3828 append_sections: IndexMap::new(),
3829 patch_sections: IndexMap::new(),
3830 metadata: IndexMap::new(),
3831 metadata_unset: Vec::new(),
3832 declare_relations: Vec::new(),
3833 dry_run: false,
3834 relations_unset: Vec::new(),
3835 anchors_unset: Vec::new(),
3836 },
3837 actor,
3838 Some(&client),
3839 None,
3840 )
3841 .unwrap();
3842
3843 assert!(!outcome.commit_sha.is_empty(), "real change must commit");
3844 assert_ne!(
3845 outcome.content_hash, seeded.content_hash,
3846 "real change must advance content_hash",
3847 );
3848 assert!(
3849 !outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3850 "real change must not surface UPDATE_NOOP",
3851 );
3852 }
3853
3854 #[test]
3855 fn update_entity_noop_preserves_expected_hash_across_chain() {
3856 let tmp = TempDir::new().unwrap();
3861 let (mut engine, seeded) = engine_with_seed(&tmp, "Chained Noops Subject");
3862 let (actor, client) = cli_actor();
3863
3864 let mut noop_sections = IndexMap::new();
3869 noop_sections.insert("identity".to_string(), "fixture identity body".to_string());
3870 for _ in 0..2 {
3871 let outcome = 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: noop_sections.clone(),
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();
3892 assert_eq!(outcome.commit_sha, "");
3893 assert_eq!(outcome.content_hash, seeded.content_hash);
3894 }
3895
3896 let mut sections = IndexMap::new();
3899 sections.insert(
3900 "identity".to_string(),
3901 "third call: real change".to_string(),
3902 );
3903 let real = engine
3904 .update_entity(
3905 UpdateEntityArgs {
3906 anchors: Vec::new(),
3907 id: seeded.id.clone(),
3908 expected_hash: Some(seeded.content_hash.clone()),
3909 sections,
3910 append_sections: IndexMap::new(),
3911 patch_sections: IndexMap::new(),
3912 metadata: IndexMap::new(),
3913 metadata_unset: Vec::new(),
3914 declare_relations: Vec::new(),
3915 dry_run: false,
3916 relations_unset: Vec::new(),
3917 anchors_unset: Vec::new(),
3918 },
3919 actor,
3920 Some(&client),
3921 None,
3922 )
3923 .unwrap();
3924 assert!(!real.commit_sha.is_empty());
3925 assert_ne!(real.content_hash, seeded.content_hash);
3926 }
3927
3928 #[test]
3937 fn synthesis_gc_drops_auto_emitted_reference_when_body_link_removed() {
3938 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3942 use indexmap::IndexMap;
3943 use tempfile::TempDir;
3944
3945 let tmp = TempDir::new().unwrap();
3946 let mem_dir = tmp.path().to_path_buf();
3947 let writer = FilesystemMemWriter::new(mem_dir.clone());
3948 let mut engine = Engine::from_mounts(vec![(
3949 folder_mount("specs", mem_dir.clone()),
3950 Box::new(writer) as Box<dyn MemBackend>,
3951 )])
3952 .unwrap();
3953 engine.set_workspace_root(mem_dir.clone());
3954 let (actor, client) = cli_actor();
3955
3956 let target = engine
3957 .create_entity(
3958 empty_create_args("specs", "Target"),
3959 actor,
3960 Some(&client),
3961 None,
3962 )
3963 .unwrap();
3964 let mut sections: IndexMap<String, String> = IndexMap::new();
3967 sections.insert("identity".to_string(), "source identity".to_string());
3968 sections.insert(
3969 "purpose".to_string(),
3970 "see [[target]] for context".to_string(),
3971 );
3972 let source = engine
3973 .create_entity(
3974 CreateEntityArgs {
3975 anchors: Vec::new(),
3976 mem: "specs".to_string(),
3977 title: "Source".to_string(),
3978 entity_type: "spec".to_string(),
3979 sections,
3980 metadata: IndexMap::new(),
3981 relations: Vec::new(),
3982 dry_run: false,
3983 },
3984 actor,
3985 Some(&client),
3986 None,
3987 )
3988 .unwrap();
3989 assert!(
3990 engine
3991 .get_entity(&source.id)
3992 .unwrap()
3993 .relationships
3994 .iter()
3995 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3996 "create-time synthesis must emit REFERENCES → target",
3997 );
3998
3999 let mut new_sections: IndexMap<String, String> = IndexMap::new();
4002 new_sections.insert("purpose".to_string(), "no link any more".to_string());
4003 engine
4004 .update_entity(
4005 UpdateEntityArgs {
4006 anchors: Vec::new(),
4007 id: source.id.clone(),
4008 expected_hash: Some(source.content_hash.clone()),
4009 sections: new_sections,
4010 append_sections: IndexMap::new(),
4011 patch_sections: IndexMap::new(),
4012 metadata: IndexMap::new(),
4013 metadata_unset: Vec::new(),
4014 declare_relations: Vec::new(),
4015 dry_run: false,
4016 relations_unset: Vec::new(),
4017 anchors_unset: Vec::new(),
4018 },
4019 actor,
4020 Some(&client),
4021 None,
4022 )
4023 .expect("update must succeed; GC drops the now-orphan REFERENCES");
4024 let in_mem = engine.get_entity(&source.id).unwrap();
4025 assert!(
4026 !in_mem
4027 .relationships
4028 .iter()
4029 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4030 "GC must drop the auto-emitted REFERENCES after body link removal; got {:?}",
4031 in_mem.relationships,
4032 );
4033 }
4034
4035 #[test]
4036 fn update_gc_removes_orphan_stub_when_last_body_link_dropped() {
4037 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4045 use indexmap::IndexMap;
4046 use tempfile::TempDir;
4047
4048 let tmp = TempDir::new().unwrap();
4049 let mem_dir = tmp.path().to_path_buf();
4050 let writer = FilesystemMemWriter::new(mem_dir.clone());
4051 let mut engine = Engine::from_mounts(vec![(
4052 folder_mount("specs", mem_dir.clone()),
4053 Box::new(writer) as Box<dyn MemBackend>,
4054 )])
4055 .unwrap();
4056 engine.set_workspace_root(mem_dir.clone());
4057 let (actor, client) = cli_actor();
4058
4059 let ghost = crate::EntityId::new("specs", "ghost");
4060 let mut sections: IndexMap<String, String> = IndexMap::new();
4061 sections.insert("identity".to_string(), "source identity".to_string());
4062 sections.insert(
4063 "purpose".to_string(),
4064 "see [[ghost]] for context".to_string(),
4065 );
4066 let source = engine
4067 .create_entity(
4068 CreateEntityArgs {
4069 anchors: Vec::new(),
4070 mem: "specs".to_string(),
4071 title: "Source".to_string(),
4072 entity_type: "spec".to_string(),
4073 sections,
4074 metadata: IndexMap::new(),
4075 relations: Vec::new(),
4076 dry_run: false,
4077 },
4078 actor,
4079 Some(&client),
4080 None,
4081 )
4082 .unwrap();
4083 assert!(
4084 engine.store().contains(&ghost) && engine.get_entity(&ghost).unwrap().stub,
4085 "body wiki-link to an absent target must auto-stub it",
4086 );
4087 assert_eq!(
4088 engine.health().stub_count,
4089 1,
4090 "one stub before the link drop"
4091 );
4092
4093 let mut new_sections: IndexMap<String, String> = IndexMap::new();
4094 new_sections.insert("purpose".to_string(), "no link any more".to_string());
4095 let outcome = engine
4096 .update_entity(
4097 UpdateEntityArgs {
4098 anchors: Vec::new(),
4099 id: source.id.clone(),
4100 expected_hash: Some(source.content_hash.clone()),
4101 sections: new_sections,
4102 append_sections: IndexMap::new(),
4103 patch_sections: IndexMap::new(),
4104 metadata: IndexMap::new(),
4105 metadata_unset: Vec::new(),
4106 declare_relations: Vec::new(),
4107 dry_run: false,
4108 relations_unset: Vec::new(),
4109 anchors_unset: Vec::new(),
4110 },
4111 actor,
4112 Some(&client),
4113 None,
4114 )
4115 .expect("update must succeed and GC the now-orphan stub");
4116
4117 assert_eq!(
4118 outcome.orphan_stubs_removed,
4119 vec![ghost.clone()],
4120 "the update that dropped the last body link must report the GC'd stub",
4121 );
4122 assert!(
4123 !engine.store().contains(&ghost),
4124 "orphan stub must be gone from the in-memory store",
4125 );
4126 assert_eq!(
4127 engine.health().stub_count,
4128 0,
4129 "stub count decremented in-session"
4130 );
4131
4132 engine.reload_each_writable_mem().unwrap();
4136 assert!(
4137 !engine.store().contains(&ghost),
4138 "stub stays gone after reload-from-disk",
4139 );
4140 assert_eq!(
4141 engine.health().stub_count,
4142 0,
4143 "reloaded-from-disk store carries the same stub count as the in-session post-update state",
4144 );
4145 }
4146
4147 #[test]
4148 fn update_gc_noop_when_section_edit_changes_no_body_link() {
4149 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4154 use indexmap::IndexMap;
4155 use tempfile::TempDir;
4156
4157 let tmp = TempDir::new().unwrap();
4158 let mem_dir = tmp.path().to_path_buf();
4159 let writer = FilesystemMemWriter::new(mem_dir.clone());
4160 let mut engine = Engine::from_mounts(vec![(
4161 folder_mount("specs", mem_dir.clone()),
4162 Box::new(writer) as Box<dyn MemBackend>,
4163 )])
4164 .unwrap();
4165 engine.set_workspace_root(mem_dir.clone());
4166 let (actor, client) = cli_actor();
4167
4168 let ghost = crate::EntityId::new("specs", "ghost");
4169 let mut sections: IndexMap<String, String> = IndexMap::new();
4170 sections.insert("identity".to_string(), "original identity".to_string());
4171 sections.insert(
4172 "purpose".to_string(),
4173 "see [[ghost]] for context".to_string(),
4174 );
4175 let source = engine
4176 .create_entity(
4177 CreateEntityArgs {
4178 anchors: Vec::new(),
4179 mem: "specs".to_string(),
4180 title: "Source".to_string(),
4181 entity_type: "spec".to_string(),
4182 sections,
4183 metadata: IndexMap::new(),
4184 relations: Vec::new(),
4185 dry_run: false,
4186 },
4187 actor,
4188 Some(&client),
4189 None,
4190 )
4191 .unwrap();
4192 assert!(engine.store().contains(&ghost), "ghost stub materialised");
4193
4194 let mut edit: IndexMap<String, String> = IndexMap::new();
4197 edit.insert("identity".to_string(), "edited identity".to_string());
4198 let outcome = engine
4199 .update_entity(
4200 UpdateEntityArgs {
4201 anchors: Vec::new(),
4202 id: source.id.clone(),
4203 expected_hash: Some(source.content_hash.clone()),
4204 sections: edit,
4205 append_sections: IndexMap::new(),
4206 patch_sections: IndexMap::new(),
4207 metadata: IndexMap::new(),
4208 metadata_unset: Vec::new(),
4209 declare_relations: Vec::new(),
4210 dry_run: false,
4211 relations_unset: Vec::new(),
4212 anchors_unset: Vec::new(),
4213 },
4214 actor,
4215 Some(&client),
4216 None,
4217 )
4218 .expect("update must succeed");
4219 assert!(
4220 outcome.orphan_stubs_removed.is_empty(),
4221 "an edit that keeps every body wiki-link orphans nothing; got {:?}",
4222 outcome.orphan_stubs_removed,
4223 );
4224 assert!(
4225 engine.store().contains(&ghost),
4226 "the still-referenced stub survives the unrelated section edit",
4227 );
4228 }
4229
4230 #[test]
4231 fn update_gc_preserves_stub_with_surviving_referrer() {
4232 use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4236 use indexmap::IndexMap;
4237 use tempfile::TempDir;
4238
4239 let tmp = TempDir::new().unwrap();
4240 let mem_dir = tmp.path().to_path_buf();
4241 let writer = FilesystemMemWriter::new(mem_dir.clone());
4242 let mut engine = Engine::from_mounts(vec![(
4243 folder_mount("specs", mem_dir.clone()),
4244 Box::new(writer) as Box<dyn MemBackend>,
4245 )])
4246 .unwrap();
4247 engine.set_workspace_root(mem_dir.clone());
4248 let (actor, client) = cli_actor();
4249
4250 let ghost = crate::EntityId::new("specs", "ghost");
4251 let make_with_link = |title: &str| {
4252 let mut sections: IndexMap<String, String> = IndexMap::new();
4253 sections.insert("identity".to_string(), format!("{title} identity"));
4254 sections.insert("purpose".to_string(), "see [[ghost]]".to_string());
4255 CreateEntityArgs {
4256 anchors: Vec::new(),
4257 mem: "specs".to_string(),
4258 title: title.to_string(),
4259 entity_type: "spec".to_string(),
4260 sections,
4261 metadata: IndexMap::new(),
4262 relations: Vec::new(),
4263 dry_run: false,
4264 }
4265 };
4266 let source_a = engine
4267 .create_entity(make_with_link("Source A"), actor, Some(&client), None)
4268 .unwrap();
4269 engine
4270 .create_entity(make_with_link("Source B"), actor, Some(&client), None)
4271 .unwrap();
4272 assert!(engine.store().contains(&ghost), "ghost stub materialised");
4273
4274 let mut drop_link: IndexMap<String, String> = IndexMap::new();
4276 drop_link.insert("purpose".to_string(), "no link here".to_string());
4277 let outcome = engine
4278 .update_entity(
4279 UpdateEntityArgs {
4280 anchors: Vec::new(),
4281 id: source_a.id.clone(),
4282 expected_hash: Some(source_a.content_hash.clone()),
4283 sections: drop_link,
4284 append_sections: IndexMap::new(),
4285 patch_sections: IndexMap::new(),
4286 metadata: IndexMap::new(),
4287 metadata_unset: Vec::new(),
4288 declare_relations: Vec::new(),
4289 dry_run: false,
4290 relations_unset: Vec::new(),
4291 anchors_unset: Vec::new(),
4292 },
4293 actor,
4294 Some(&client),
4295 None,
4296 )
4297 .expect("update must succeed");
4298 assert!(
4299 outcome.orphan_stubs_removed.is_empty(),
4300 "the stub keeps a referrer (source B), so nothing is GC'd; got {:?}",
4301 outcome.orphan_stubs_removed,
4302 );
4303 assert!(
4304 engine.store().contains(&ghost),
4305 "stub survives via the surviving referrer",
4306 );
4307 }
4308
4309 #[test]
4310 fn synthesis_gc_preserves_non_pointer_explicit_relation_across_body_update() {
4311 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
4320 use indexmap::IndexMap;
4321 use tempfile::TempDir;
4322
4323 let tmp = TempDir::new().unwrap();
4324 let mem_dir = tmp.path().to_path_buf();
4325 let writer = FilesystemMemWriter::new(mem_dir.clone());
4326 let mut engine = Engine::from_mounts(vec![(
4327 folder_mount("specs", mem_dir.clone()),
4328 Box::new(writer) as Box<dyn MemBackend>,
4329 )])
4330 .unwrap();
4331 engine.set_workspace_root(mem_dir.clone());
4332 let (actor, client) = cli_actor();
4333
4334 let target = engine
4335 .create_entity(
4336 empty_create_args("specs", "Target"),
4337 actor,
4338 Some(&client),
4339 None,
4340 )
4341 .unwrap();
4342 let source = engine
4343 .create_entity(
4344 empty_create_args("specs", "Source"),
4345 actor,
4346 Some(&client),
4347 None,
4348 )
4349 .unwrap();
4350
4351 let relate = engine
4353 .relate_entity(
4354 RelateEntityArgs {
4355 source: source.id.clone(),
4356 expected_hash: Some(source.content_hash.clone()),
4357 rel_type: "USES".to_string(),
4358 target: target.id.clone(),
4359 remove: false,
4360 description: None,
4361 dry_run: false,
4362 },
4363 actor,
4364 Some(&client),
4365 None,
4366 )
4367 .unwrap();
4368
4369 let mut sections: IndexMap<String, String> = IndexMap::new();
4372 sections.insert("purpose".to_string(), "unrelated edit".to_string());
4373 engine
4374 .update_entity(
4375 UpdateEntityArgs {
4376 anchors: Vec::new(),
4377 id: source.id.clone(),
4378 expected_hash: Some(relate.content_hash.clone()),
4379 sections,
4380 append_sections: IndexMap::new(),
4381 patch_sections: IndexMap::new(),
4382 metadata: IndexMap::new(),
4383 metadata_unset: Vec::new(),
4384 declare_relations: Vec::new(),
4385 dry_run: false,
4386 relations_unset: Vec::new(),
4387 anchors_unset: Vec::new(),
4388 },
4389 actor,
4390 Some(&client),
4391 None,
4392 )
4393 .expect("update must succeed");
4394 let in_mem = engine.get_entity(&source.id).unwrap();
4395 assert!(
4396 in_mem
4397 .relationships
4398 .iter()
4399 .any(|r| r.rel_type == "USES" && r.target == target.id),
4400 "explicit USES must survive an unrelated body update; got {:?}",
4401 in_mem.relationships,
4402 );
4403 }
4404
4405 #[test]
4406 fn synthesis_dedupes_repeated_body_links_to_same_target() {
4407 use crate::engine::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 target = engine
4425 .create_entity(
4426 empty_create_args("specs", "Target"),
4427 actor,
4428 Some(&client),
4429 None,
4430 )
4431 .unwrap();
4432 let source = engine
4433 .create_entity(
4434 empty_create_args("specs", "Source"),
4435 actor,
4436 Some(&client),
4437 None,
4438 )
4439 .unwrap();
4440
4441 let mut sections: IndexMap<String, String> = IndexMap::new();
4442 sections.insert(
4443 "purpose".to_string(),
4444 "see [[target]] and again [[target]]".to_string(),
4445 );
4446 engine
4447 .update_entity(
4448 UpdateEntityArgs {
4449 anchors: Vec::new(),
4450 id: source.id.clone(),
4451 expected_hash: Some(source.content_hash.clone()),
4452 sections,
4453 append_sections: IndexMap::new(),
4454 patch_sections: IndexMap::new(),
4455 metadata: IndexMap::new(),
4456 metadata_unset: Vec::new(),
4457 declare_relations: Vec::new(),
4458 dry_run: false,
4459 relations_unset: Vec::new(),
4460 anchors_unset: Vec::new(),
4461 },
4462 actor,
4463 Some(&client),
4464 None,
4465 )
4466 .unwrap();
4467 let in_mem = engine.get_entity(&source.id).unwrap();
4468 let count = in_mem
4469 .relationships
4470 .iter()
4471 .filter(|r| r.rel_type == "REFERENCES" && r.target == target.id)
4472 .count();
4473 assert_eq!(
4474 count, 1,
4475 "dedupe must leave exactly one REFERENCES → target; got {:?}",
4476 in_mem.relationships,
4477 );
4478 }
4479
4480 #[test]
4481 fn synthesis_coexists_with_explicit_uses_to_same_target() {
4482 use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
4487 use indexmap::IndexMap;
4488 use tempfile::TempDir;
4489
4490 let tmp = TempDir::new().unwrap();
4491 let mem_dir = tmp.path().to_path_buf();
4492 let writer = FilesystemMemWriter::new(mem_dir.clone());
4493 let mut engine = Engine::from_mounts(vec![(
4494 folder_mount("specs", mem_dir.clone()),
4495 Box::new(writer) as Box<dyn MemBackend>,
4496 )])
4497 .unwrap();
4498 engine.set_workspace_root(mem_dir.clone());
4499 let (actor, client) = cli_actor();
4500
4501 let target = engine
4502 .create_entity(
4503 empty_create_args("specs", "Target"),
4504 actor,
4505 Some(&client),
4506 None,
4507 )
4508 .unwrap();
4509 let source = engine
4510 .create_entity(
4511 empty_create_args("specs", "Source"),
4512 actor,
4513 Some(&client),
4514 None,
4515 )
4516 .unwrap();
4517 let relate = engine
4519 .relate_entity(
4520 RelateEntityArgs {
4521 source: source.id.clone(),
4522 expected_hash: Some(source.content_hash.clone()),
4523 rel_type: "USES".to_string(),
4524 target: target.id.clone(),
4525 remove: false,
4526 description: None,
4527 dry_run: false,
4528 },
4529 actor,
4530 Some(&client),
4531 None,
4532 )
4533 .unwrap();
4534 let mut sections: IndexMap<String, String> = IndexMap::new();
4536 sections.insert(
4537 "purpose".to_string(),
4538 "we also reference [[target]]".to_string(),
4539 );
4540 engine
4541 .update_entity(
4542 UpdateEntityArgs {
4543 anchors: Vec::new(),
4544 id: source.id.clone(),
4545 expected_hash: Some(relate.content_hash.clone()),
4546 sections,
4547 append_sections: IndexMap::new(),
4548 patch_sections: IndexMap::new(),
4549 metadata: IndexMap::new(),
4550 metadata_unset: Vec::new(),
4551 declare_relations: Vec::new(),
4552 dry_run: false,
4553 relations_unset: Vec::new(),
4554 anchors_unset: Vec::new(),
4555 },
4556 actor,
4557 Some(&client),
4558 None,
4559 )
4560 .unwrap();
4561 let in_mem = engine.get_entity(&source.id).unwrap();
4562 assert!(
4563 in_mem
4564 .relationships
4565 .iter()
4566 .any(|r| r.rel_type == "USES" && r.target == target.id),
4567 "USES must survive — synthesis dedupes on (rel_type, target)",
4568 );
4569 assert!(
4570 in_mem
4571 .relationships
4572 .iter()
4573 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4574 "REFERENCES must be synthesised even though USES already targets the same entity",
4575 );
4576 }
4577
4578 mod alias_synthesis_custom_schema {
4590 use std::path::Path;
4591
4592 use indexmap::IndexMap;
4593 use memstead_schema::SchemaRef;
4594 use tempfile::TempDir;
4595
4596 use crate::backend::MemBackend;
4597 use crate::engine::test_helpers::*;
4598 use crate::engine::{CreateEntityArgs, Engine, EngineError, UpdateEntityArgs};
4599 use crate::storage::FilesystemMemWriter;
4600 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4601
4602 const TYPE_BODY: &str = r#"description: t
4603when_to_use: tests
4604sections:
4605 - key: body
4606 heading: Body
4607 required: true
4608 search_weight: 10.0
4609 catch_all: true
4610 write_rules: []
4611metadata_fields: []
4612title_weight: 100.0
4613text_fields:
4614 - body
4615hierarchy_relationship: _default
4616no_self_loop_relationships: []
4617updatable_fields:
4618 - title
4619 - body
4620health_required_fields:
4621 - body
4622staleness_threshold_days: 90
4623write_rules: []
4624"#;
4625
4626 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
4627 let dir = root.join(name);
4628 std::fs::create_dir_all(dir.join("types")).unwrap();
4629 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
4630 for (type_name, body) in types {
4631 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
4632 }
4633 }
4634
4635 fn make_type_yaml(name: &str) -> String {
4636 format!("name: {name}\n{TYPE_BODY}")
4637 }
4638
4639 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
4640 Mount {
4641 mem: mem.to_string(),
4642 schema: Some(pin),
4643 storage: MountStorage::Folder { path },
4644 capability: MountCapability::Write,
4645 lifecycle: MountLifecycle::Eager,
4646 cross_linkable: true,
4647 migration_target: None,
4648 }
4649 }
4650
4651 fn engine_with_schema(
4652 manifest: &str,
4653 type_yaml_name: &str,
4654 schema_name: &str,
4655 schema_version: semver::Version,
4656 ) -> (Engine, TempDir) {
4657 let tmp = TempDir::new().unwrap();
4658 let schemas_dir = tmp.path().join("schemas");
4659 std::fs::create_dir_all(&schemas_dir).unwrap();
4660 write_schema_files(
4661 &schemas_dir,
4662 schema_name,
4663 manifest,
4664 &[(type_yaml_name, &make_type_yaml(type_yaml_name))],
4665 );
4666 let mem_dir = tmp.path().join("mem");
4667 std::fs::create_dir_all(&mem_dir).unwrap();
4668 let writer = FilesystemMemWriter::new(mem_dir.clone());
4669 let pin = SchemaRef::new(schema_name, schema_version);
4670 let mount = folder_mount_with_pin("v", mem_dir, pin);
4671 let mut engine = Engine::from_mounts_with_schemas_dir(
4672 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
4673 Some(&schemas_dir),
4674 )
4675 .expect("engine with custom schema constructs");
4676 engine.set_workspace_root(tmp.path().to_path_buf());
4677 (engine, tmp)
4678 }
4679
4680 #[test]
4681 fn non_references_alias_pointer_emits_named_rel_type_from_body_link() {
4682 let manifest = r#"name: aliased
4687version: 0.1.0
4688description: alias-synthesis fixture using a non-REFERENCES pointer
4689when_to_use: tests prove the engine does not hard-code REFERENCES
4690types:
4691 - doc
4692relationships:
4693 mode: strict
4694 definitions:
4695 - name: CITES
4696 description: Citation — auto-emitted from body wiki-links
4697 default_weight: 0.5
4698 - name: PART_OF
4699 description: Hierarchy
4700 default_weight: 3.0
4701 acyclic: true
4702 - name: _default
4703 description: Fallback
4704 default_weight: 1.0
4705alias_target_rel_type: CITES
4706community:
4707 resolution: 1.0
4708 seed: 42
4709"#;
4710 let (mut engine, _tmp) =
4711 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4712 let (actor, client) = cli_actor();
4713
4714 let target = engine
4715 .create_entity(
4716 CreateEntityArgs {
4717 anchors: Vec::new(),
4718 mem: "v".to_string(),
4719 title: "Target".to_string(),
4720 entity_type: "doc".to_string(),
4721 sections: IndexMap::from_iter([(
4722 "body".to_string(),
4723 "target body".to_string(),
4724 )]),
4725 metadata: IndexMap::new(),
4726 relations: Vec::new(),
4727 dry_run: false,
4728 },
4729 actor,
4730 Some(&client),
4731 None,
4732 )
4733 .unwrap();
4734
4735 let mut sections: IndexMap<String, String> = IndexMap::new();
4736 sections.insert("body".to_string(), "see [[target]]".to_string());
4737 let source = engine
4738 .create_entity(
4739 CreateEntityArgs {
4740 anchors: Vec::new(),
4741 mem: "v".to_string(),
4742 title: "Source".to_string(),
4743 entity_type: "doc".to_string(),
4744 sections,
4745 metadata: IndexMap::new(),
4746 relations: Vec::new(),
4747 dry_run: false,
4748 },
4749 actor,
4750 Some(&client),
4751 None,
4752 )
4753 .expect("create must succeed; CITES is auto-emitted by synthesis");
4754
4755 let in_mem = engine.get_entity(&source.id).unwrap();
4756 assert!(
4757 in_mem
4758 .relationships
4759 .iter()
4760 .any(|r| r.rel_type == "CITES" && r.target == target.id),
4761 "synthesis must emit CITES (the pointer rel-type), not REFERENCES; got {:?}",
4762 in_mem.relationships,
4763 );
4764 assert!(
4765 !in_mem
4766 .relationships
4767 .iter()
4768 .any(|r| r.rel_type == "REFERENCES"),
4769 "engine must not hard-code REFERENCES — pointer rel-type is CITES; got {:?}",
4770 in_mem.relationships,
4771 );
4772 }
4773
4774 #[test]
4775 fn no_pointer_schema_refuses_unbacked_body_wiki_link() {
4776 let manifest = r#"name: no-alias
4781version: 0.1.0
4782description: schema without alias_target_rel_type pointer
4783when_to_use: tests prove strict validator still fires for opt-out schemas
4784types:
4785 - doc
4786relationships:
4787 mode: strict
4788 definitions:
4789 - name: USES
4790 description: Use
4791 default_weight: 1.0
4792 - name: PART_OF
4793 description: Hierarchy
4794 default_weight: 3.0
4795 acyclic: true
4796 - name: _default
4797 description: Fallback
4798 default_weight: 1.0
4799community:
4800 resolution: 1.0
4801 seed: 42
4802"#;
4803 let (mut engine, _tmp) =
4804 engine_with_schema(manifest, "doc", "no-alias", semver::Version::new(0, 1, 0));
4805 let (actor, client) = cli_actor();
4806
4807 let target = engine
4808 .create_entity(
4809 CreateEntityArgs {
4810 anchors: Vec::new(),
4811 mem: "v".to_string(),
4812 title: "Target".to_string(),
4813 entity_type: "doc".to_string(),
4814 sections: IndexMap::from_iter([(
4815 "body".to_string(),
4816 "target body".to_string(),
4817 )]),
4818 metadata: IndexMap::new(),
4819 relations: Vec::new(),
4820 dry_run: false,
4821 },
4822 actor,
4823 Some(&client),
4824 None,
4825 )
4826 .unwrap();
4827 let source = engine
4828 .create_entity(
4829 CreateEntityArgs {
4830 anchors: Vec::new(),
4831 mem: "v".to_string(),
4832 title: "Source".to_string(),
4833 entity_type: "doc".to_string(),
4834 sections: IndexMap::from_iter([(
4835 "body".to_string(),
4836 "source body".to_string(),
4837 )]),
4838 metadata: IndexMap::new(),
4839 relations: Vec::new(),
4840 dry_run: false,
4841 },
4842 actor,
4843 Some(&client),
4844 None,
4845 )
4846 .unwrap();
4847
4848 let mut sections: IndexMap<String, String> = IndexMap::new();
4852 sections.insert("body".to_string(), "see [[target]]".to_string());
4853 let err = engine
4854 .update_entity(
4855 UpdateEntityArgs {
4856 anchors: Vec::new(),
4857 id: source.id.clone(),
4858 expected_hash: Some(source.content_hash.clone()),
4859 sections,
4860 append_sections: IndexMap::new(),
4861 patch_sections: IndexMap::new(),
4862 metadata: IndexMap::new(),
4863 metadata_unset: Vec::new(),
4864 declare_relations: Vec::new(),
4865 dry_run: false,
4866 relations_unset: Vec::new(),
4867 anchors_unset: Vec::new(),
4868 },
4869 actor,
4870 Some(&client),
4871 None,
4872 )
4873 .unwrap_err();
4874 match err {
4875 EngineError::WikiLinkWithoutRelation { from_id, missing } => {
4876 assert_eq!(from_id, source.id.to_string());
4877 assert_eq!(missing.len(), 1);
4878 assert_eq!(missing[0].section_key, "body");
4879 assert_eq!(missing[0].target_id, target.id.to_string());
4880 }
4881 other => panic!(
4882 "no-pointer schema must refuse with WikiLinkWithoutRelation; got {other:?}"
4883 ),
4884 }
4885 }
4886
4887 #[test]
4896 fn natural_form_body_wiki_link_refuses_with_typed_envelope() {
4897 let manifest = r#"name: aliased
4898version: 0.1.0
4899description: alias-synthesis fixture
4900when_to_use: tests prove strict wiki-link grammar at mutation entry
4901types:
4902 - doc
4903relationships:
4904 mode: strict
4905 definitions:
4906 - name: REFERENCES
4907 description: Reference — auto-emitted from body wiki-links
4908 default_weight: 0.5
4909 - name: PART_OF
4910 description: Hierarchy
4911 default_weight: 3.0
4912 acyclic: true
4913 - name: _default
4914 description: Fallback
4915 default_weight: 1.0
4916alias_target_rel_type: REFERENCES
4917community:
4918 resolution: 1.0
4919 seed: 42
4920"#;
4921 let (mut engine, _tmp) =
4922 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4923 let (actor, client) = cli_actor();
4924
4925 let mut sections: IndexMap<String, String> = IndexMap::new();
4926 sections.insert("body".to_string(), "see [[Knowledge Graph]]".to_string());
4927 let err = engine
4928 .create_entity(
4929 CreateEntityArgs {
4930 anchors: Vec::new(),
4931 mem: "v".to_string(),
4932 title: "Source".to_string(),
4933 entity_type: "doc".to_string(),
4934 sections,
4935 metadata: IndexMap::new(),
4936 relations: Vec::new(),
4937 dry_run: false,
4938 },
4939 actor,
4940 Some(&client),
4941 None,
4942 )
4943 .unwrap_err();
4944 match err {
4945 EngineError::InvalidWikiLinkTarget {
4946 raw,
4947 suggested,
4948 section,
4949 link_source,
4950 ..
4951 } => {
4952 assert_eq!(raw, "Knowledge Graph");
4953 assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
4954 assert_eq!(section, "body");
4955 assert_eq!(link_source, "body_link");
4956 }
4957 other => panic!(
4958 "natural-form body wiki-link must refuse with InvalidWikiLinkTarget; got {other:?}"
4959 ),
4960 }
4961 }
4962
4963 #[test]
4969 fn tier_two_bad_mem_prefix_refuses_with_distinct_envelope() {
4970 let manifest = r#"name: aliased
4971version: 0.1.0
4972description: alias-synthesis fixture
4973when_to_use: tests prove strict mem-prefix grammar at mutation entry
4974types:
4975 - doc
4976relationships:
4977 mode: strict
4978 definitions:
4979 - name: REFERENCES
4980 description: Reference
4981 default_weight: 0.5
4982 - name: PART_OF
4983 description: Hierarchy
4984 default_weight: 3.0
4985 acyclic: true
4986 - name: _default
4987 description: Fallback
4988 default_weight: 1.0
4989alias_target_rel_type: REFERENCES
4990community:
4991 resolution: 1.0
4992 seed: 42
4993"#;
4994 let (mut engine, _tmp) =
4995 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4996 let (actor, client) = cli_actor();
4997
4998 let mut sections: IndexMap<String, String> = IndexMap::new();
4999 sections.insert("body".to_string(), "see [[Other Mem:foo]]".to_string());
5000 let err = engine
5001 .create_entity(
5002 CreateEntityArgs {
5003 anchors: Vec::new(),
5004 mem: "v".to_string(),
5005 title: "Source".to_string(),
5006 entity_type: "doc".to_string(),
5007 sections,
5008 metadata: IndexMap::new(),
5009 relations: Vec::new(),
5010 dry_run: false,
5011 },
5012 actor,
5013 Some(&client),
5014 None,
5015 )
5016 .unwrap_err();
5017 match err {
5018 EngineError::InvalidWikiLinkMem { raw, section, .. } => {
5019 assert_eq!(raw, "Other Mem");
5020 assert_eq!(section, "body");
5021 }
5022 other => panic!(
5023 "Tier-2 bad mem prefix must refuse with InvalidWikiLinkMem; got {other:?}"
5024 ),
5025 }
5026 }
5027
5028 #[test]
5035 fn hierarchical_dash_form_body_link_refuses_with_colon_suggestion() {
5036 let manifest = r#"name: aliased
5037version: 0.1.0
5038description: alias-synthesis fixture
5039when_to_use: tests prove hierarchical dash-form refusal at mutation entry
5040types:
5041 - doc
5042relationships:
5043 mode: strict
5044 definitions:
5045 - name: REFERENCES
5046 description: Reference — auto-emitted from body wiki-links
5047 default_weight: 0.5
5048 - name: PART_OF
5049 description: Hierarchy
5050 default_weight: 3.0
5051 acyclic: true
5052 - name: _default
5053 description: Fallback
5054 default_weight: 1.0
5055alias_target_rel_type: REFERENCES
5056community:
5057 resolution: 1.0
5058 seed: 42
5059"#;
5060 let (mut engine, _tmp) =
5061 engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5062 let (actor, client) = cli_actor();
5063
5064 let mut sections: IndexMap<String, String> = IndexMap::new();
5065 sections.insert(
5066 "body".to_string(),
5067 "see [[team/sub-mem--target]]".to_string(),
5068 );
5069 let err = engine
5070 .create_entity(
5071 CreateEntityArgs {
5072 anchors: Vec::new(),
5073 mem: "v".to_string(),
5074 title: "Source".to_string(),
5075 entity_type: "doc".to_string(),
5076 sections,
5077 metadata: IndexMap::new(),
5078 relations: Vec::new(),
5079 dry_run: false,
5080 },
5081 actor,
5082 Some(&client),
5083 None,
5084 )
5085 .unwrap_err();
5086 match err {
5087 EngineError::InvalidWikiLinkTarget {
5088 raw,
5089 suggested,
5090 section,
5091 link_source,
5092 ..
5093 } => {
5094 assert_eq!(raw, "team/sub-mem--target");
5095 assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
5096 assert_eq!(section, "body");
5097 assert_eq!(link_source, "body_link");
5098 }
5099 other => panic!(
5100 "hierarchical dash-form body link must refuse with InvalidWikiLinkTarget; got {other:?}"
5101 ),
5102 }
5103
5104 let listed = engine.store().all_entities().collect::<Vec<_>>();
5107 assert!(
5108 listed.is_empty(),
5109 "refused create must not leave any entity behind, got: {listed:?}"
5110 );
5111 }
5112 }
5113
5114 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";
5123
5124 fn repair_engine() -> (TempDir, Engine) {
5125 let tmp = TempDir::new().unwrap();
5126 let mem_dir = tmp.path().to_path_buf();
5127 std::fs::write(
5128 mem_dir.join("anchor.md"),
5129 "---\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",
5130 )
5131 .unwrap();
5132 std::fs::write(mem_dir.join("drifted.md"), DRIFTED_MD).unwrap();
5133 let writer = FilesystemMemWriter::new(mem_dir.clone());
5134 let engine = Engine::from_mounts(vec![(
5135 folder_mount("specs", mem_dir),
5136 Box::new(writer) as Box<dyn MemBackend>,
5137 )])
5138 .unwrap();
5139 (tmp, engine)
5140 }
5141
5142 fn repair_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5143 UpdateEntityArgs {
5144 anchors: Vec::new(),
5145 id,
5146 expected_hash: hash,
5147 sections: IndexMap::new(),
5148 append_sections: IndexMap::new(),
5149 patch_sections: IndexMap::new(),
5150 metadata: IndexMap::new(),
5151 metadata_unset: Vec::new(),
5152 declare_relations: Vec::new(),
5153 dry_run: false,
5154 relations_unset: vec![crate::ops::RelationUnsetArg {
5155 rel_type: "USES".to_string(),
5156 target: EntityId::new("specs", "anchor"),
5157 }],
5158 anchors_unset: Vec::new(),
5159 }
5160 }
5161
5162 #[test]
5167 fn relations_unset_on_conformant_entity_refuses_repair_not_needed() {
5168 let (_tmp, mut engine) = repair_engine();
5169 let anchor = EntityId::new("specs", "anchor");
5172 let drifted = EntityId::new("specs", "drifted");
5173 engine
5174 .relate_entity(
5175 RelateEntityArgs {
5176 source: anchor.clone(),
5177 expected_hash: None,
5178 rel_type: "USES".to_string(),
5179 target: drifted.clone(),
5180 remove: false,
5181 description: None,
5182 dry_run: false,
5183 },
5184 Actor::Cli,
5185 None,
5186 None,
5187 )
5188 .expect("relate on conformant entity works");
5189 let mut args = repair_args(anchor.clone(), None);
5190 args.relations_unset[0].target = drifted.clone();
5191 let err = engine
5192 .update_entity(args, Actor::Cli, None, None)
5193 .unwrap_err();
5194 match err {
5195 EngineError::RepairNotNeeded { id, recovery } => {
5196 assert_eq!(id, anchor.to_string());
5197 assert!(
5198 recovery.contains("memstead_relate"),
5199 "recovery must point at the focused tool; got {recovery}"
5200 );
5201 }
5202 other => panic!("expected RepairNotNeeded, got {other:?}"),
5203 }
5204 let entity = engine.store().get(&anchor).unwrap();
5206 assert!(
5207 entity.relationships.iter().any(|r| r.target == drifted),
5208 "gate must not modify the entity"
5209 );
5210 }
5211
5212 #[test]
5217 fn relations_unset_repairs_non_conformant_entity_atomically() {
5218 let (_tmp, mut engine) = repair_engine();
5219 let drifted = EntityId::new("specs", "drifted");
5220 let pre = engine.conformance_findings("specs", None).unwrap();
5222 assert!(
5223 pre.iter().any(|f| f.id == drifted.to_string()),
5224 "fixture must lint non-conformant; got {pre:?}"
5225 );
5226 let mut args = repair_args(drifted.clone(), None);
5227 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
5228 engine
5229 .update_entity(args, Actor::Cli, None, None)
5230 .expect("repair update lands");
5231 let entity = engine.store().get(&drifted).unwrap();
5232 assert!(
5233 entity.relationships.is_empty(),
5234 "relation must be removed; got {:?}",
5235 entity.relationships
5236 );
5237 assert!(
5238 !entity.metadata.contains_key("zzz_bogus_field"),
5239 "conformance break must be repaired in the same update"
5240 );
5241 let post = engine.conformance_findings("specs", None).unwrap();
5242 assert!(
5243 post.iter().all(|f| f.id != drifted.to_string()),
5244 "post-repair entity must be conformant; got {post:?}"
5245 );
5246 }
5247
5248 #[test]
5252 fn relations_unset_post_state_must_still_validate() {
5253 let (_tmp, mut engine) = repair_engine();
5254 let drifted = EntityId::new("specs", "drifted");
5255 let mut args = repair_args(drifted.clone(), None);
5256 args.sections = IndexMap::from_iter([("nonexistent_section".to_string(), "x".to_string())]);
5259 let err = engine
5260 .update_entity(args, Actor::Cli, None, None)
5261 .unwrap_err();
5262 assert_eq!(
5263 err.code(),
5264 "UNKNOWN_SECTION",
5265 "strict-write post-condition must hold during repair; got {err:?}"
5266 );
5267 let entity = engine.store().get(&drifted).unwrap();
5269 assert!(
5270 !entity.relationships.is_empty(),
5271 "refused repair must not partially apply"
5272 );
5273 }
5274
5275 #[test]
5278 fn relations_unset_absent_pair_is_silent_noop() {
5279 let (_tmp, mut engine) = repair_engine();
5280 let drifted = EntityId::new("specs", "drifted");
5281 let mut args = repair_args(drifted.clone(), None);
5282 args.relations_unset[0].rel_type = "NEVER_DECLARED".to_string();
5283 args.metadata_unset = vec!["zzz_bogus_field".to_string()];
5285 engine
5286 .update_entity(args, Actor::Cli, None, None)
5287 .expect("absent pair no-ops, update lands");
5288 let entity = engine.store().get(&drifted).unwrap();
5289 assert_eq!(
5290 entity.relationships.len(),
5291 1,
5292 "the USES relation must survive an unmatched unset"
5293 );
5294 }
5295
5296 fn anchor_input(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
5299 crate::anchor::AnchorInput {
5300 artifact: Some(artifact.to_string()),
5301 grain: Some("file".to_string()),
5302 class: Some("anchored".to_string()),
5303 hash: Some(hash.to_string()),
5304 hash_stability: Some("stable".to_string()),
5305 ..Default::default()
5306 }
5307 }
5308
5309 fn anchor_unset(artifact: &str) -> crate::anchor::AnchorUnsetInput {
5310 crate::anchor::AnchorUnsetInput {
5311 artifact: Some(artifact.to_string()),
5312 grain: None,
5313 class: None,
5314 }
5315 }
5316
5317 fn anchor_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5319 UpdateEntityArgs {
5320 anchors: Vec::new(),
5321 anchors_unset: Vec::new(),
5322 id,
5323 expected_hash: hash,
5324 sections: IndexMap::new(),
5325 append_sections: IndexMap::new(),
5326 patch_sections: IndexMap::new(),
5327 metadata: IndexMap::new(),
5328 metadata_unset: Vec::new(),
5329 declare_relations: Vec::new(),
5330 dry_run: false,
5331 relations_unset: Vec::new(),
5332 }
5333 }
5334
5335 fn anchored_engine() -> (Engine, TempDir, EntityId, String) {
5338 let tmp = TempDir::new().unwrap();
5339 let mem_dir = tmp.path().to_path_buf();
5340 let writer = FilesystemMemWriter::new(mem_dir.clone());
5341 let mut engine = Engine::from_mounts(vec![(
5342 folder_mount("specs", mem_dir),
5343 Box::new(writer) as Box<dyn MemBackend>,
5344 )])
5345 .unwrap();
5346 let (actor, client) = cli_actor();
5347 let mut args = empty_create_args("specs", "Anchored");
5348 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
5349 let created = engine
5350 .create_entity(args, actor, Some(&client), None)
5351 .unwrap();
5352 let id = EntityId::new("specs", "anchored");
5353 assert_eq!(engine.entity_anchors(&id).len(), 2);
5354 (engine, tmp, id, created.content_hash)
5355 }
5356
5357 #[test]
5362 fn update_anchors_merge_appends_and_replaces_by_triple() {
5363 let (mut engine, _tmp, id, hash) = anchored_engine();
5364 let (actor, client) = cli_actor();
5365
5366 let mut args = anchor_args(id.clone(), Some(hash));
5368 args.anchors = vec![anchor_input("c.rs", "h-c")];
5369 let out = engine
5370 .update_entity(args, actor, Some(&client), None)
5371 .unwrap();
5372 let anchors = engine.entity_anchors(&id);
5373 assert_eq!(anchors.len(), 3, "N existing + 1 new = N+1");
5374 assert_eq!(anchors[0].artifact, "a.rs");
5375 assert_eq!(anchors[0].hash.as_deref(), Some("h-a"));
5376 assert_eq!(anchors[1].artifact, "b.rs");
5377 assert_eq!(anchors[2].artifact, "c.rs");
5378 assert!(!engine.anchors_referencing_artifact("a.rs").is_empty());
5379 assert!(!engine.anchors_referencing_artifact("c.rs").is_empty());
5380
5381 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5383 args.anchors = vec![anchor_input("a.rs", "h-a2")];
5384 engine
5385 .update_entity(args, actor, Some(&client), None)
5386 .unwrap();
5387 let anchors = engine.entity_anchors(&id);
5388 assert_eq!(anchors.len(), 3);
5389 assert_eq!(anchors[0].artifact, "a.rs");
5390 assert_eq!(anchors[0].hash.as_deref(), Some("h-a2"));
5391 assert_eq!(anchors[1].hash.as_deref(), Some("h-b"), "b untouched");
5392 assert_eq!(anchors[2].hash.as_deref(), Some("h-c"), "c untouched");
5393 }
5394
5395 #[test]
5399 fn update_anchors_full_resend_and_absent_are_noops_on_stored_set() {
5400 let (mut engine, tmp, id, hash) = anchored_engine();
5401 let (actor, client) = cli_actor();
5402 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
5403 let before = std::fs::read(&sidecar_path).unwrap();
5404
5405 let mut args = anchor_args(id.clone(), Some(hash));
5407 args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
5408 let out = engine
5409 .update_entity(args, actor, Some(&client), None)
5410 .unwrap();
5411 assert_eq!(
5412 std::fs::read(&sidecar_path).unwrap(),
5413 before,
5414 "full re-send keeps the stored bytes"
5415 );
5416
5417 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5419 args.sections
5420 .insert("identity".to_string(), "changed body".to_string());
5421 engine
5422 .update_entity(args, actor, Some(&client), None)
5423 .unwrap();
5424 assert_eq!(
5425 std::fs::read(&sidecar_path).unwrap(),
5426 before,
5427 "an anchorless update never touches the stored set"
5428 );
5429 }
5430
5431 #[test]
5436 fn update_anchors_unset_bare_narrowed_idempotent_and_unset_first() {
5437 let (mut engine, _tmp, id, hash) = anchored_engine();
5438 let (actor, client) = cli_actor();
5439
5440 let mut span = anchor_input("a.rs", "h-span");
5442 span.grain = Some("span".to_string());
5443 let mut args = anchor_args(id.clone(), Some(hash));
5444 args.anchors = vec![span];
5445 let out = engine
5446 .update_entity(args, actor, Some(&client), None)
5447 .unwrap();
5448 assert_eq!(engine.entity_anchors(&id).len(), 3);
5449
5450 let mut narrowed = anchor_unset("a.rs");
5452 narrowed.grain = Some("span".to_string());
5453 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5454 args.anchors_unset = vec![narrowed];
5455 let out = engine
5456 .update_entity(args, actor, Some(&client), None)
5457 .unwrap();
5458 let anchors = engine.entity_anchors(&id);
5459 assert_eq!(anchors.len(), 2);
5460 assert!(
5461 anchors
5462 .iter()
5463 .all(|a| a.grain == crate::anchor::AnchorGrain::File)
5464 );
5465
5466 let mut args = anchor_args(id.clone(), Some(out.content_hash.clone()));
5468 args.anchors_unset = vec![anchor_unset("never-there.rs")];
5469 engine
5470 .update_entity(args, actor, Some(&client), None)
5471 .expect("unset of a nonexistent target is a no-op, not an error");
5472 assert_eq!(engine.entity_anchors(&id).len(), 2);
5473
5474 let mut args = anchor_args(id.clone(), Some(out.content_hash));
5477 args.anchors_unset = vec![anchor_unset("a.rs")];
5478 args.anchors = vec![anchor_input("a.rs", "h-a-fresh")];
5479 engine
5480 .update_entity(args, actor, Some(&client), None)
5481 .unwrap();
5482 let anchors = engine.entity_anchors(&id);
5483 assert_eq!(anchors.len(), 2);
5484 assert_eq!(anchors[0].artifact, "b.rs", "b.rs untouched throughout");
5485 assert_eq!(anchors[1].hash.as_deref(), Some("h-a-fresh"));
5486 }
5487
5488 #[test]
5492 fn update_anchor_only_and_unset_only_commit_without_hash_movement() {
5493 let (mut engine, _tmp, id, hash) = anchored_engine();
5494 let (actor, client) = cli_actor();
5495
5496 let mut args = anchor_args(id.clone(), Some(hash.clone()));
5497 args.anchors_unset = vec![anchor_unset("b.rs")];
5498 let out = engine
5499 .update_entity(args, actor, Some(&client), None)
5500 .unwrap();
5501 assert!(
5502 !out.commit_sha.is_empty(),
5503 "unset-only update commits the sidecar"
5504 );
5505 assert_eq!(out.content_hash, hash, "anchors never move `_hash`");
5506 assert_eq!(engine.entity_anchors(&id).len(), 1);
5507
5508 let err = engine
5511 .update_entity(
5512 anchor_args(id.clone(), Some(hash)),
5513 actor,
5514 Some(&client),
5515 None,
5516 )
5517 .unwrap_err();
5518 assert!(matches!(err, EngineError::EmptyUpdate { .. }));
5519 }
5520
5521 #[test]
5529 fn anchor_only_update_across_second_boundary_never_moves_hash() {
5530 let (mut engine, _tmp, id, hash) = anchored_engine();
5531 let (actor, client) = cli_actor();
5532
5533 let t0 = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_778_243_696);
5534 engine.set_mutation_clock(std::sync::Arc::new(move || t0));
5535 let mut args = anchor_args(id.clone(), Some(hash));
5538 args.metadata = [("level".to_string(), "M1".to_string())]
5539 .into_iter()
5540 .collect();
5541 let restamped = engine
5542 .update_entity(args, actor, Some(&client), None)
5543 .unwrap();
5544
5545 let t1 = t0 + std::time::Duration::from_secs(1);
5547 engine.set_mutation_clock(std::sync::Arc::new(move || t1));
5548 let mut args = anchor_args(id.clone(), Some(restamped.content_hash.clone()));
5549 args.anchors = vec![anchor_input("c.rs", "h-c")];
5550 let out = engine
5551 .update_entity(args, actor, Some(&client), None)
5552 .unwrap();
5553 assert!(!out.commit_sha.is_empty(), "anchor-only update commits");
5554 assert_eq!(
5555 out.content_hash, restamped.content_hash,
5556 "anchors never move `_hash`, even across a second boundary"
5557 );
5558 let entity = engine.store().get(&id).unwrap();
5560 assert_eq!(
5561 entity
5562 .metadata
5563 .get("last_modified")
5564 .and_then(|v| v.as_str()),
5565 Some("2026-05-08T12:34:56Z"),
5566 "anchor-only update must not restamp last_modified"
5567 );
5568 }
5569
5570 #[test]
5574 fn malformed_anchor_unset_refuses_and_nothing_is_written() {
5575 let (mut engine, tmp, id, hash) = anchored_engine();
5576 let (actor, client) = cli_actor();
5577 let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
5578 let before = std::fs::read(&sidecar_path).unwrap();
5579
5580 let mut bad = anchor_unset("a.rs");
5581 bad.grain = Some("paragraph".to_string()); let mut args = anchor_args(id.clone(), Some(hash));
5583 args.anchors_unset = vec![bad];
5584 args.anchors = vec![anchor_input("c.rs", "h-c")];
5586 let err = engine
5587 .update_entity(args, actor, Some(&client), None)
5588 .unwrap_err();
5589 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
5590 assert_eq!(engine.entity_anchors(&id).len(), 2, "no partial apply");
5591 assert_eq!(std::fs::read(&sidecar_path).unwrap(), before);
5592 }
5593
5594 fn bare_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5598 UpdateEntityArgs {
5599 anchors: Vec::new(),
5600 anchors_unset: Vec::new(),
5601 id,
5602 expected_hash: hash,
5603 sections: IndexMap::new(),
5604 append_sections: IndexMap::new(),
5605 patch_sections: IndexMap::new(),
5606 metadata: IndexMap::new(),
5607 metadata_unset: Vec::new(),
5608 declare_relations: Vec::new(),
5609 dry_run: false,
5610 relations_unset: Vec::new(),
5611 }
5612 }
5613
5614 #[test]
5622 fn reserved_key_unset_repairs_smuggled_entity_and_set_stays_refused() {
5623 let tmp = TempDir::new().unwrap();
5624 let mem_dir = tmp.path().to_path_buf();
5625 std::fs::write(
5627 mem_dir.join("smuggled.md"),
5628 "---\ntype: spec\nmem: wrong-mem\nid: bogus-id\n---\n# Smuggled\n\n## Identity\n\nsmuggled identity.\n\n## Purpose\n\nsmuggled purpose.\n",
5629 )
5630 .unwrap();
5631 let writer = FilesystemMemWriter::new(mem_dir.clone());
5632 let mut engine = Engine::from_mounts(vec![(
5633 folder_mount("specs", mem_dir.clone()),
5634 Box::new(writer) as Box<dyn MemBackend>,
5635 )])
5636 .unwrap();
5637 let (actor, client) = cli_actor();
5638 let id = EntityId::new("specs", "smuggled");
5639 let entity = engine.get_entity(&id).expect("fixture boots");
5640 assert!(
5641 entity.metadata.contains_key("mem") && entity.metadata.contains_key("id"),
5642 "fixture must carry the smuggled keys after boot"
5643 );
5644 let hash = entity.content_hash.clone();
5645
5646 for reserved in ["type", "mem", "id"] {
5648 let mut args = bare_args(id.clone(), Some(hash.clone()));
5649 args.metadata
5650 .insert(reserved.to_string(), "resmuggled".to_string());
5651 let err = engine
5652 .update_entity(args, actor, Some(&client), None)
5653 .expect_err("reserved-key set must refuse on update");
5654 assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
5655 }
5656 let mut batch_item = bare_args(id.clone(), Some(hash.clone()));
5659 batch_item
5660 .metadata
5661 .insert("id".to_string(), "resmuggled".to_string());
5662 let batch = engine
5663 .batch_update(vec![(batch_item, None)], actor, Some(&client), false)
5664 .expect("batch returns a result envelope");
5665 assert!(
5666 !batch.applied,
5667 "batch with a reserved-key set must not apply"
5668 );
5669 assert_eq!(batch.failed, 1);
5670
5671 let mut args = bare_args(id.clone(), Some(hash));
5673 args.metadata_unset = vec!["mem".to_string(), "id".to_string()];
5674 let out = engine
5675 .update_entity(args, actor, Some(&client), None)
5676 .expect("reserved-key unset is the sanctioned repair");
5677 assert!(!out.commit_sha.is_empty(), "repair is a real commit");
5678 assert_eq!(
5679 out.modified_metadata.unset,
5680 vec!["mem".to_string(), "id".to_string()]
5681 );
5682
5683 let entity = engine.get_entity(&id).expect("entity survives repair");
5686 assert!(
5687 !entity.metadata.contains_key("mem") && !entity.metadata.contains_key("id"),
5688 "smuggled keys must be gone from the store"
5689 );
5690 let on_disk = std::fs::read_to_string(mem_dir.join("smuggled.md")).unwrap();
5691 assert!(
5692 !on_disk.contains("wrong-mem") && !on_disk.contains("bogus-id"),
5693 "smuggled keys must be gone from the file: {on_disk}"
5694 );
5695 let mut args = bare_args(id.clone(), Some(entity.content_hash.clone()));
5696 args.sections
5697 .insert("identity".to_string(), "repaired identity".to_string());
5698 engine
5699 .update_entity(args, actor, Some(&client), None)
5700 .expect("post-repair entity round-trips cleanly");
5701 }
5702
5703 #[test]
5711 fn reserved_type_unset_reseeds_and_is_a_noop_on_healthy_entities() {
5712 let tmp = TempDir::new().unwrap();
5713 let mem_dir = tmp.path().to_path_buf();
5714 let writer = FilesystemMemWriter::new(mem_dir.clone());
5715 let mut engine = Engine::from_mounts(vec![(
5716 folder_mount("specs", mem_dir.clone()),
5717 Box::new(writer) as Box<dyn MemBackend>,
5718 )])
5719 .unwrap();
5720 let (actor, client) = cli_actor();
5721 let created = engine
5722 .create_entity(
5723 empty_create_args("specs", "Healthy"),
5724 actor,
5725 Some(&client),
5726 None,
5727 )
5728 .unwrap();
5729 let id = EntityId::new("specs", "healthy");
5730
5731 for key in ["type", "mem", "id"] {
5732 let mut args = bare_args(id.clone(), Some(created.content_hash.clone()));
5733 args.metadata_unset = vec![key.to_string()];
5734 let out = engine
5735 .update_entity(args, actor, Some(&client), None)
5736 .unwrap_or_else(|e| panic!("unset '{key}' on a healthy entity must no-op: {e:?}"));
5737 assert!(
5738 out.commit_sha.is_empty(),
5739 "unset '{key}' on a healthy entity is a no-op, not a commit"
5740 );
5741 assert!(
5742 out.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
5743 "no-op must carry the UPDATE_NOOP warning for '{key}'"
5744 );
5745 }
5746 let entity = engine.get_entity(&id).unwrap();
5747 assert_eq!(entity.entity_type, "spec");
5748 assert_eq!(
5749 entity.metadata.get("type").and_then(|v| v.as_str()),
5750 Some("spec"),
5751 "the discriminator survives a type unset"
5752 );
5753 }
5754
5755 #[test]
5764 fn declare_relations_refuses_cycle_and_self_loop_like_relate() {
5765 let tmp = TempDir::new().unwrap();
5766 let mem_dir = tmp.path().to_path_buf();
5767 let writer = FilesystemMemWriter::new(mem_dir.clone());
5768 let mut engine = Engine::from_mounts(vec![(
5769 folder_mount("specs", mem_dir),
5770 Box::new(writer) as Box<dyn MemBackend>,
5771 )])
5772 .unwrap();
5773 let (actor, client) = cli_actor();
5774
5775 let alpha = engine
5777 .create_entity(
5778 empty_create_args("specs", "Alpha"),
5779 actor,
5780 Some(&client),
5781 None,
5782 )
5783 .unwrap();
5784 let beta = engine
5785 .create_entity(
5786 empty_create_args("specs", "Beta"),
5787 actor,
5788 Some(&client),
5789 None,
5790 )
5791 .unwrap();
5792 engine
5793 .relate_entity(
5794 crate::engine::RelateEntityArgs {
5795 source: alpha.id.clone(),
5796 target: beta.id.clone(),
5797 rel_type: "PART_OF".to_string(),
5798 remove: false,
5799 expected_hash: None,
5800 description: None,
5801 dry_run: false,
5802 },
5803 actor,
5804 Some(&client),
5805 None,
5806 )
5807 .unwrap();
5808
5809 let declare = |rel_type: &str, from: &EntityId, to: &EntityId, hash: String| {
5810 let mut args = bare_args(from.clone(), Some(hash));
5811 args.declare_relations = vec![crate::ops::RelateArg {
5812 to: to.clone(),
5813 rel_type: rel_type.to_string(),
5814 description: None,
5815 }];
5816 args
5817 };
5818
5819 let err = engine
5821 .update_entity(
5822 declare("PART_OF", &beta.id, &alpha.id, beta.content_hash.clone()),
5823 actor,
5824 Some(&client),
5825 None,
5826 )
5827 .expect_err("cycle-closing declare_relations must refuse");
5828 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5829 let details = err.details();
5830 assert_eq!(details["rel_type"], "PART_OF");
5831 assert!(details["existing_path"].is_array());
5832 assert!(
5833 engine
5834 .get_entity(&beta.id)
5835 .unwrap()
5836 .relationships
5837 .is_empty(),
5838 "the refused edge must not land"
5839 );
5840
5841 let alpha_hash = engine.get_entity(&alpha.id).unwrap().content_hash.clone();
5844 let err = engine
5845 .update_entity(
5846 declare("USES", &alpha.id, &alpha.id, alpha_hash),
5847 actor,
5848 Some(&client),
5849 None,
5850 )
5851 .expect_err("self-loop declare_relations must refuse");
5852 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5853
5854 engine
5856 .update_entity(
5857 declare(
5858 "PART_OF",
5859 &beta.id,
5860 &EntityId::new("specs", "gamma"),
5861 beta.content_hash.clone(),
5862 ),
5863 actor,
5864 Some(&client),
5865 None,
5866 )
5867 .expect("a non-cycle PART_OF declare must land as today");
5868 }
5869}