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