1use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::parser::parse_markdown;
8use crate::entity::store_builder::push_entities_into_store;
9use crate::entity::{Entity, EntityId, Relationship, normalise_description};
10use crate::ops::WarningHint;
11use crate::provenance::{Provenance, ProvenanceKind};
12use crate::runtime_validator::{
13 CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
14 validate_rel_type,
15};
16use crate::vcs::{Actor, ClientId, CommitContext};
17use crate::workspace::MountCapability;
18use memstead_schema::SchemaRef;
19
20use super::super::{Engine, EngineError, RelateAction, RelateEntityArgs, RelateEntityOutcome};
21use super::{
22 make_stub, unknown_type_error, validate_description_posture, validate_relation_target_grammar,
23};
24
25pub(super) struct PreparedRelate {
32 pub(super) mount_idx: usize,
33 pub(super) source_mem: String,
34 pub(super) from: EntityId,
35 pub(super) to: EntityId,
36 pub(super) rel_type: String,
37 pub(super) action: RelateAction,
38 pub(super) file_path: String,
39 pub(super) markdown: String,
40 pub(super) warnings: Vec<WarningHint>,
41 pub(super) stub_target: Option<EntityId>,
46 pub(super) stub_kind: crate::entity::StubKind,
51 pub(super) type_def: std::sync::Arc<memstead_schema::TypeDefinition>,
52}
53
54pub(super) enum RelatePrepareOutcome {
56 Done(RelateEntityOutcome),
60 Prepared(PreparedRelate),
62}
63
64impl Engine {
65 pub fn relate_entity(
84 &mut self,
85 args: RelateEntityArgs,
86 actor: Actor,
87 client: Option<&ClientId>,
88 note: Option<&str>,
89 ) -> Result<RelateEntityOutcome, EngineError> {
90 let source_mem = args.source.mem().to_string();
91 let target_mem = args.target.mem().to_string();
92
93 let mut drift_warnings = self.reload_if_stale(Some(&source_mem));
101 if target_mem != source_mem && !self.mem_is_deferred(&target_mem) {
102 drift_warnings.append(&mut self.reload_if_stale(Some(&target_mem)));
109 }
110 let cycle_guard_needs_full = !args.remove
125 && self.schemas.get(&source_mem).is_some_and(|s| {
126 s.relationship_acyclic(&args.rel_type)
127 || s.acyclic_set_containing(&args.rel_type).is_some()
128 });
129 let signal_diff_needs_full = [source_mem.as_str(), args.target.mem()].iter().any(|m| {
130 self.schemas
131 .get(*m)
132 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
133 });
134 if cycle_guard_needs_full || signal_diff_needs_full {
135 self.ensure_mems_loaded(None);
136 }
137
138 let dry_run = args.dry_run;
139 let prepared = match self.prepare_relate(args, drift_warnings)? {
140 RelatePrepareOutcome::Done(outcome) => {
141 if !dry_run
152 && matches!(
153 outcome.action,
154 super::super::RelateAction::NoOpAlreadyPresent
155 )
156 {
157 return self.refresh_derivation_baseline_on_noop(outcome, actor, client, note);
158 }
159 return Ok(outcome);
160 }
161 RelatePrepareOutcome::Prepared(p) => p,
162 };
163
164 if dry_run {
171 let parse_result = parse_markdown(
172 &prepared.markdown,
173 &prepared.file_path,
174 prepared.type_def.as_ref(),
175 &prepared.source_mem,
176 )
177 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
178 let mut warnings = prepared.warnings;
182 if let Some(w) = self.note_missing_warning("relate_entity", note) {
183 warnings.push(w);
184 }
185 return Ok(RelateEntityOutcome {
186 from: prepared.from,
187 to: prepared.to,
188 rel_type: prepared.rel_type,
189 action: prepared.action,
190 content_hash: parse_result.entity.content_hash,
191 write_id: String::new(),
192 source: "explicit".to_string(),
193 orphan_stubs_removed: Vec::new(),
194 warnings,
195 });
196 }
197
198 let signal_snapshot = crate::ops::signals::snapshot_levels(
202 &self.store,
203 &self.schemas,
204 [&prepared.from, &prepared.to],
205 );
206
207 self.stage_prepared_relate(&prepared)?;
208
209 if let Some(schema) = self.schemas.get(&prepared.source_mem)
215 && super::rel_type_declares_derivation(schema, &prepared.rel_type)
216 {
217 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
218 let (from, rel, to) = (
219 prepared.from.to_string(),
220 prepared.rel_type.clone(),
221 prepared.to.to_string(),
222 );
223 match prepared.action {
224 RelateAction::Added => {
225 let hash = self
226 .store
227 .get(&prepared.to)
228 .map(|e| e.content_hash.clone())
229 .unwrap_or_default();
230 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
231 }
232 RelateAction::Removed => {
233 super::stage_derivation_sidecar(backend, |s| s.remove(&from, &rel, &to))?;
234 }
235 _ => {}
236 }
237 }
238
239 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
240 let commit_subject = format!("memstead: relate {}", prepared.from);
241 let ctx = CommitContext {
242 actor,
243 client: client.cloned(),
244 tool: Some("relate_entity"),
245 note: note.map(String::from),
246 role: self.current_role,
247 identity: self.current_identity.clone(),
248 logical_operation_id: None,
249 entity_ids: None,
250 };
251 let write_id = backend.commit(&commit_subject, &ctx)?;
252
253 backend.append_provenance(
254 &Provenance::new(
255 std::time::SystemTime::now(),
256 ProvenanceKind::Relate,
257 Some(prepared.from.to_string()),
258 actor,
259 client.cloned(),
260 note.map(String::from),
261 )
262 .with_role(self.current_role)
263 .with_identity(self.current_identity.clone()),
264 )?;
265
266 self.record_self_write(prepared.mount_idx, &write_id);
267 let stamp_warnings = self.stamp_mutation_versions(prepared.mount_idx);
268
269 let content_hash = self.apply_prepared_relate_to_store(&prepared)?;
270
271 let orphan_stubs_removed: Vec<EntityId> =
285 if matches!(prepared.action, RelateAction::Removed) {
286 super::gc_orphan_stubs_among(&mut self.store, std::iter::once(&prepared.to))
287 } else {
288 Vec::new()
289 };
290
291 self.invalidate_communities();
292 self.maintain_search_indexes(std::slice::from_ref(&prepared.from));
296
297 let PreparedRelate {
298 from,
299 to,
300 rel_type,
301 action,
302 mut warnings,
303 ..
304 } = prepared;
305
306 warnings.extend(stamp_warnings);
307
308 warnings.extend(crate::ops::signals::crossing_warnings(
312 &self.store,
313 &self.schemas,
314 &signal_snapshot,
315 ));
316
317 if let Some(w) = self.note_missing_warning("relate_entity", note) {
323 warnings.push(w);
324 }
325
326 Ok(RelateEntityOutcome {
327 from,
328 to,
329 rel_type,
330 action,
331 content_hash,
332 write_id,
333 source: "explicit".to_string(),
334 warnings,
335 orphan_stubs_removed,
336 })
337 }
338
339 fn refresh_derivation_baseline_on_noop(
350 &mut self,
351 mut outcome: RelateEntityOutcome,
352 actor: Actor,
353 client: Option<&ClientId>,
354 note: Option<&str>,
355 ) -> Result<RelateEntityOutcome, EngineError> {
356 let source_mem = outcome.from.mem().to_string();
357 let declared = self
358 .schemas
359 .get(&source_mem)
360 .is_some_and(|s| super::rel_type_declares_derivation(s, &outcome.rel_type));
361 if !declared {
362 return Ok(outcome);
363 }
364 let Some(mount_idx) = self.mounts.iter().position(|m| m.mount.mem == source_mem) else {
365 return Ok(outcome);
366 };
367 let hash = self
368 .store
369 .get(&outcome.to)
370 .map(|e| e.content_hash.clone())
371 .unwrap_or_default();
372 let (from, rel, to) = (
373 outcome.from.to_string(),
374 outcome.rel_type.clone(),
375 outcome.to.to_string(),
376 );
377 let backend = self.mounts[mount_idx].backend.as_ref();
378 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
379 let ctx = CommitContext {
380 actor,
381 client: client.cloned(),
382 tool: Some("relate_entity"),
383 note: note.map(String::from),
384 role: self.current_role,
385 identity: self.current_identity.clone(),
386 logical_operation_id: None,
387 entity_ids: None,
388 };
389 let write_id = backend.commit(
390 &format!("memstead: derivation re-baseline {}", outcome.from),
391 &ctx,
392 )?;
393 self.record_self_write(mount_idx, &write_id);
394 outcome
398 .warnings
399 .extend(self.stamp_mutation_versions(mount_idx));
400 outcome.write_id = write_id;
401 outcome
402 .warnings
403 .push(WarningHint::DerivationBaselineRefreshed {
404 from: outcome.from.clone(),
405 rel_type: outcome.rel_type.clone(),
406 to: outcome.to.clone(),
407 });
408 Ok(outcome)
409 }
410
411 fn prepare_relate(
419 &mut self,
420 args: RelateEntityArgs,
421 mut drift_warnings: Vec<WarningHint>,
422 ) -> Result<RelatePrepareOutcome, EngineError> {
423 let mut args = args;
424 let source_mem = args.source.mem().to_string();
425 let target_mem = args.target.mem().to_string();
426
427 validate_relation_target_grammar(&args.target)?;
443
444 let mut target_mem_uncreated = false;
450 if source_mem != target_mem {
451 if !args.remove {
462 super::validate_cross_mem_add_policy(self, &source_mem, &args.target)?;
463 }
464 if let Some(mount) = self.mount(&target_mem)
472 && mount.capability == MountCapability::ReadOnly
473 && !self.store.contains(&args.target)
474 && !matches!(
475 super::probe_deferred_target(self, &args.target)?,
476 super::DeferredTargetProbe::Exists
477 )
478 {
479 return Err(EngineError::CrossMemTargetNotFound {
480 target_id: args.target.to_string(),
481 target_mem: target_mem.clone(),
482 });
483 }
484 if self.mount(&target_mem).is_none() {
492 target_mem_uncreated = true;
493 }
494 }
495
496 if let Ok(canonical) = crate::entity::id::validate_rel_type(&args.rel_type) {
503 args.rel_type = canonical;
504 }
505 args.description = normalise_description(args.description.as_deref());
509
510 let mount_idx = self
511 .mounts
512 .iter()
513 .position(|m| m.mount.mem == source_mem)
514 .ok_or_else(|| self.unknown_mem_error(&source_mem))?;
515 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
516 return Err(EngineError::ReadOnlyMount(source_mem));
517 }
518
519 let schema = self
520 .schemas
521 .get(&source_mem)
522 .expect("schema present for every registered mount");
523
524 let target_schema_ref: Option<SchemaRef> = if source_mem == target_mem {
538 None
539 } else {
540 super::target_schema_ref_for_routing(self, &target_mem)
545 };
546 let cross_mem_different = match (&target_schema_ref, schema.id()) {
547 (Some(target), (src_name, _)) => target.name != src_name,
548 (None, _) => false,
549 };
550
551 let mut warnings: Vec<WarningHint> = Vec::new();
552 warnings.append(&mut drift_warnings);
554 if !cross_mem_different {
559 match validate_rel_type(&args.rel_type, schema.as_ref())? {
560 RelationshipCheck::Ok => {}
561 RelationshipCheck::OpenWarning(message) => {
562 warnings.push(WarningHint::UndeclaredRelationshipOpen {
563 rel_type: args.rel_type.clone(),
564 message,
565 });
566 }
567 }
568 }
569
570 let entity: Entity = self
574 .store
575 .get(&args.source)
576 .ok_or_else(|| EngineError::NotFound {
577 id: args.source.to_string(),
578 })?
579 .clone();
580
581 if entity.stub {
589 return Err(EngineError::StubCannotRelate {
590 id: args.source.to_string(),
591 });
592 }
593
594 let target_type = self
595 .store
596 .get(&args.target)
597 .map(|e| e.entity_type.clone())
598 .filter(|t| !t.is_empty());
599 let target_verified_in_storage = if target_type.is_none() && target_mem != source_mem {
605 matches!(
606 super::probe_deferred_target(self, &args.target)?,
607 super::DeferredTargetProbe::Exists
608 )
609 } else {
610 false
611 };
612 let target_type = match target_type {
613 Some(t) => Some(t),
614 None if target_verified_in_storage && !args.remove => {
615 super::peek_deferred_target_type(self, &args.target)?
616 }
617 None => None,
618 };
619 if target_verified_in_storage {
620 target_mem_uncreated = false;
625 }
626 if !args.remove {
641 validate_description_posture(
642 self,
643 &args.rel_type,
644 args.description.as_deref(),
645 &source_mem,
646 &target_mem,
647 &args.source,
648 &args.target,
649 )?;
650 super::validate_manual_authoring_posture(
658 self,
659 &args.rel_type,
660 &source_mem,
661 &args.source,
662 &args.target,
663 )?;
664 }
665
666 if !args.remove {
667 if cross_mem_different {
668 let target_ref = target_schema_ref
671 .as_ref()
672 .expect("target_schema_ref is Some when cross_mem_different");
673 match validate_cross_mem_edge(
674 &args.rel_type,
675 entity.entity_type.as_str(),
676 target_type.as_deref(),
677 schema.as_ref(),
678 target_ref,
679 ) {
680 CrossMemRelCheck::Ok => {}
681 CrossMemRelCheck::EdgeNotDeclared => {
682 let (src_name, src_version) = schema.id();
683 return Err(EngineError::CrossMemEdgeNotDeclared {
684 source_schema: SchemaRef::new(src_name, src_version).as_display(),
685 target_schema: target_ref.as_display(),
686 rel_type: args.rel_type.clone(),
687 from_id: args.source.to_string(),
688 to_id: args.target.to_string(),
689 });
690 }
691 CrossMemRelCheck::Invalid(v) => {
692 return Err(EngineError::Validation(v));
693 }
694 }
695 } else {
696 validate_rel_shape(
697 &args.rel_type,
698 entity.entity_type.as_str(),
699 target_type.as_deref(),
700 schema.as_ref(),
701 )?;
702 }
703 }
704
705 if let Some(expected) = args.expected_hash.as_deref()
706 && entity.content_hash != expected
707 {
708 return Err(EngineError::HashMismatch {
709 id: args.source.to_string(),
710 current: entity.content_hash.clone(),
711 is_stub: entity.stub,
712 });
713 }
714
715 if !args.remove {
721 super::validate_edge_acyclicity(
722 &self.store,
723 schema,
724 &args.source,
725 entity.entity_type.as_str(),
726 &args.target,
727 &args.rel_type,
728 )?;
729 }
730
731 let type_def = schema
732 .get_type(&entity.entity_type)
733 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
734
735 let mut next = entity.clone();
736 let already = next
737 .relationships
738 .iter()
739 .position(|r| r.rel_type == args.rel_type && r.target == args.target);
740
741 if args.remove && already.is_some() {
748 let other_relation_to_target_exists = entity
749 .relationships
750 .iter()
751 .any(|r| r.target == args.target && r.rel_type != args.rel_type);
752 if !other_relation_to_target_exists {
753 let mut surviving_sections: Vec<String> = Vec::new();
760 for (section_key, body) in entity.sections.iter() {
761 let inline_targets =
762 crate::entity::parser::extract_inline_links_lenient(body, &source_mem);
763 if inline_targets.iter().any(|t| t == &args.target) {
764 surviving_sections.push(section_key.clone());
765 }
766 }
767 if !surviving_sections.is_empty() {
768 return Err(EngineError::RelationHasBodyLinks {
769 from_id: args.source.to_string(),
770 to_id: args.target.to_string(),
771 rel_type: args.rel_type.clone(),
772 body_links: surviving_sections,
773 });
774 }
775 }
776 }
777
778 let action = if args.remove {
779 match already {
780 Some(idx) => {
781 next.relationships.remove(idx);
782 RelateAction::Removed
783 }
784 None => RelateAction::NoOpAbsent,
785 }
786 } else {
787 match already {
788 Some(_) => RelateAction::NoOpAlreadyPresent,
789 None => {
790 next.relationships.push(Relationship {
791 rel_type: args.rel_type.clone(),
792 target: args.target.clone(),
793 description: normalise_description(args.description.as_deref()),
794 });
795 RelateAction::Added
796 }
797 }
798 };
799
800 if matches!(action, RelateAction::Removed) {
808 let blocked: Vec<_> =
809 crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref())
810 .into_iter()
811 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
812 .collect();
813 if !blocked.is_empty() {
814 return Err(EngineError::RequiredOutgoingUnsatisfied {
815 entity_type: next.entity_type.clone(),
816 entity_id: args.source.to_string(),
817 missing: blocked,
818 });
819 }
820 let blocked: Vec<_> = crate::ops::health::unsatisfied_constraints(
830 &self.store,
831 &next,
832 type_def.as_ref(),
833 Some(&args.source),
834 None,
835 )
836 .into_iter()
837 .filter(|v| {
838 matches!(
839 v,
840 crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { .. }
841 ) && v.severity() == memstead_schema::ConstraintSeverity::Block
842 })
843 .collect();
844 if !blocked.is_empty() {
845 return Err(EngineError::ConstraintUnsatisfied {
846 entity_type: next.entity_type.clone(),
847 entity_id: args.source.to_string(),
848 violations: blocked,
849 });
850 }
851 }
852
853 let mut stub_target: Option<EntityId> = None;
866 let mut stub_kind = crate::entity::StubKind::ForwardReference;
867 if matches!(action, RelateAction::Added) && !self.store.contains(&args.target) {
868 stub_target = Some(args.target.clone());
869 if target_verified_in_storage {
870 stub_kind = crate::entity::StubKind::LoadTime;
878 } else {
879 warnings.push(WarningHint::AutoStubCreated {
884 stub_id: args.target.clone(),
885 pending: args.dry_run,
886 });
887 }
888 if target_mem_uncreated {
895 warnings.push(WarningHint::CrossMemTargetMemUncreated {
896 from_mem: source_mem.clone(),
897 to_mem: target_mem.clone(),
898 target_id: args.target.clone(),
899 });
900 }
901 }
902
903 if matches!(
909 action,
910 RelateAction::NoOpAlreadyPresent | RelateAction::NoOpAbsent
911 ) {
912 match action {
913 RelateAction::NoOpAlreadyPresent => {
914 warnings.push(WarningHint::DuplicateRelationship {
915 rel_type: args.rel_type.clone(),
916 from: args.source.clone(),
917 to: args.target.clone(),
918 });
919 }
920 RelateAction::NoOpAbsent => {
921 warnings.push(WarningHint::NoSuchRelationship {
922 rel_type: args.rel_type.clone(),
923 from: args.source.clone(),
924 to: args.target.clone(),
925 });
926 }
927 _ => unreachable!(),
928 }
929 return Ok(RelatePrepareOutcome::Done(RelateEntityOutcome {
930 from: args.source,
931 to: args.target,
932 rel_type: args.rel_type,
933 action,
934 content_hash: entity.content_hash.clone(),
935 write_id: String::new(),
936 source: "explicit".to_string(),
937 warnings,
938 orphan_stubs_removed: Vec::new(),
941 }));
942 }
943
944 let today = self.now_iso();
952 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
953
954 let file_path = next.file_path.clone();
955 let markdown = super::render_for_write(&next, type_def.as_ref())?;
956
957 Ok(RelatePrepareOutcome::Prepared(PreparedRelate {
958 mount_idx,
959 source_mem,
960 from: args.source,
961 to: args.target,
962 rel_type: args.rel_type,
963 action,
964 file_path,
965 markdown,
966 warnings,
967 stub_target,
968 stub_kind,
969 type_def,
970 }))
971 }
972
973 fn stage_prepared_relate(&mut self, p: &PreparedRelate) -> Result<(), EngineError> {
979 if let Some(stub_id) = &p.stub_target {
980 self.store
981 .upsert(stub_id.clone(), make_stub(stub_id, p.stub_kind.clone()));
982 }
983 self.mounts[p.mount_idx]
984 .backend
985 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())?;
986 Ok(())
987 }
988
989 fn apply_prepared_relate_to_store(
997 &mut self,
998 p: &PreparedRelate,
999 ) -> Result<String, EngineError> {
1000 let parse_result = parse_markdown(
1001 &p.markdown,
1002 &p.file_path,
1003 p.type_def.as_ref(),
1004 &p.source_mem,
1005 )
1006 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
1007 let content_hash = parse_result.entity.content_hash.clone();
1008 let fallback = engine_fallback_type();
1009 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
1010 crate::entity::store_builder::remap_alias_target_edge_sources(
1011 &mut self.store,
1012 &self.schemas,
1013 );
1014 Ok(content_hash)
1015 }
1016
1017 pub fn batch_relate(
1054 &mut self,
1055 relates: Vec<(RelateEntityArgs, Option<String>)>,
1056 actor: Actor,
1057 client: Option<&ClientId>,
1058 dry_run: bool,
1059 ) -> Result<crate::ops::BatchResult, EngineError> {
1060 if relates.is_empty() {
1061 return Ok(crate::ops::BatchResult {
1062 warnings: Vec::new(),
1063 orphan_stubs_removed: Vec::new(),
1064 errors_suppressed: 0,
1065 applied: true,
1066 results: Vec::new(),
1067 succeeded: 0,
1068 failed: 0,
1069 write_id: String::new(),
1070 });
1071 }
1072
1073 let mut touched_mems: Vec<String> = relates
1077 .iter()
1078 .flat_map(|(a, _)| [a.source.mem().to_string(), a.target.mem().to_string()])
1079 .collect();
1080 touched_mems.sort();
1081 touched_mems.dedup();
1082 let source_mems: std::collections::HashSet<&str> =
1088 relates.iter().map(|(a, _)| a.source.mem()).collect();
1089 touched_mems.retain(|m| source_mems.contains(m.as_str()) || !self.mem_is_deferred(m));
1090 for m in &touched_mems {
1091 self.reload_if_stale(Some(m));
1092 }
1093 if relates.iter().any(|(a, _)| {
1103 (!a.remove
1104 && self.schemas.get(a.source.mem()).is_some_and(|s| {
1105 s.relationship_acyclic(&a.rel_type)
1106 || s.acyclic_set_containing(&a.rel_type).is_some()
1107 }))
1108 || [a.source.mem(), a.target.mem()].iter().any(|m| {
1109 self.schemas
1110 .get(*m)
1111 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1112 })
1113 }) {
1114 self.ensure_mems_loaded(None);
1115 }
1116
1117 let store_snapshot = self.store.clone();
1122
1123 enum ItemState {
1124 Applied(&'static str),
1125 Noop,
1126 Error,
1127 }
1128 let mut items: Vec<(EntityId, ItemState)> = Vec::with_capacity(relates.len());
1129 let mut prepared: Vec<PreparedRelate> = Vec::new();
1130 let mut notes: Vec<Option<String>> = Vec::new();
1131 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1132
1133 for (i, (args, note)) in relates.into_iter().enumerate() {
1134 let source_id = args.source.clone();
1135 let mut args = args;
1140 args.dry_run = false;
1141 match self.prepare_relate(args, Vec::new()) {
1142 Ok(RelatePrepareOutcome::Done(_)) => {
1143 items.push((source_id, ItemState::Noop));
1144 }
1145 Ok(RelatePrepareOutcome::Prepared(p)) => {
1146 if let Err(e) = self.stage_prepared_relate(&p) {
1149 self.store = store_snapshot;
1150 self.discard_all_pending();
1151 return Err(e);
1152 }
1153 if let Err(e) = self.apply_prepared_relate_to_store(&p) {
1154 self.store = store_snapshot;
1155 self.discard_all_pending();
1156 return Err(e);
1157 }
1158 if let Some(schema) = self.schemas.get(&p.source_mem)
1165 && super::rel_type_declares_derivation(schema, &p.rel_type)
1166 {
1167 let backend = self.mounts[p.mount_idx].backend.as_ref();
1168 let (from, rel, to) =
1169 (p.from.to_string(), p.rel_type.clone(), p.to.to_string());
1170 let staged = match p.action {
1171 RelateAction::Added => {
1172 let hash = self
1173 .store
1174 .get(&p.to)
1175 .map(|e| e.content_hash.clone())
1176 .unwrap_or_default();
1177 super::stage_derivation_sidecar(backend, |s| {
1178 s.set(&from, &rel, &to, &hash)
1179 })
1180 }
1181 RelateAction::Removed => {
1182 super::stage_derivation_sidecar(backend, |s| {
1183 s.remove(&from, &rel, &to)
1184 })
1185 }
1186 _ => Ok(()),
1187 };
1188 if let Err(e) = staged {
1189 self.store = store_snapshot;
1190 self.discard_all_pending();
1191 return Err(e);
1192 }
1193 }
1194 let label = match p.action {
1195 RelateAction::Added => "added",
1196 RelateAction::Removed => "removed",
1197 _ => unreachable!("no-ops resolve to Done"),
1198 };
1199 items.push((source_id, ItemState::Applied(label)));
1200 prepared.push(p);
1201 notes.push(note);
1202 }
1203 Err(e) => {
1204 items.push((source_id, ItemState::Error));
1205 errors.push((i, e));
1206 }
1207 }
1208 }
1209
1210 if !errors.is_empty() {
1211 self.store = store_snapshot;
1213 self.discard_all_pending();
1214 let failed = errors.len();
1215 let mut error_map: std::collections::HashMap<usize, EngineError> =
1216 errors.into_iter().collect();
1217 let mut reported = 0usize;
1218 let mut suppressed = 0usize;
1219 let results: Vec<crate::ops::BatchEntry> = items
1220 .into_iter()
1221 .enumerate()
1222 .map(|(i, (id, _))| match error_map.remove(&i) {
1223 Some(e) => {
1224 if reported < Self::BATCH_ERROR_REPORT_CAP {
1225 reported += 1;
1226 crate::ops::BatchEntry {
1227 id,
1228 action: "error".to_string(),
1229 error: Some(super::update::batch_error_envelope(&e)),
1230 }
1231 } else {
1232 suppressed += 1;
1233 crate::ops::BatchEntry {
1234 id,
1235 action: "error".to_string(),
1236 error: None,
1237 }
1238 }
1239 }
1240 None => crate::ops::BatchEntry {
1241 id,
1242 action: "not_applied".to_string(),
1243 error: None,
1244 },
1245 })
1246 .collect();
1247 return Ok(crate::ops::BatchResult {
1248 warnings: Vec::new(),
1249 orphan_stubs_removed: Vec::new(),
1250 errors_suppressed: suppressed,
1251 applied: false,
1252 results,
1253 succeeded: 0,
1254 failed,
1255 write_id: String::new(),
1256 });
1257 }
1258
1259 if dry_run {
1265 let removed_targets: Vec<EntityId> = prepared
1266 .iter()
1267 .filter(|p| matches!(p.action, RelateAction::Removed))
1268 .map(|p| p.to.clone())
1269 .collect();
1270 let orphan_stubs_removed =
1271 super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1272 self.store = store_snapshot;
1273 self.discard_all_pending();
1274 let succeeded = items.len();
1275 let results: Vec<crate::ops::BatchEntry> = items
1276 .into_iter()
1277 .map(|(id, state)| crate::ops::BatchEntry {
1278 id,
1279 action: match state {
1280 ItemState::Applied(label) => label.to_string(),
1281 ItemState::Noop => "noop".to_string(),
1282 ItemState::Error => unreachable!("refusal path returned above"),
1283 },
1284 error: None,
1285 })
1286 .collect();
1287 return Ok(crate::ops::BatchResult {
1288 warnings: Vec::new(),
1289 orphan_stubs_removed,
1290 errors_suppressed: 0,
1291 applied: true,
1292 results,
1293 succeeded,
1294 failed: 0,
1295 write_id: String::new(),
1296 });
1297 }
1298
1299 let mut distinct_mounts: Vec<usize> = Vec::new();
1301 for p in &prepared {
1302 if !distinct_mounts.contains(&p.mount_idx) {
1303 distinct_mounts.push(p.mount_idx);
1304 }
1305 }
1306 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1307 for &m in &distinct_mounts {
1308 let mut entity_ids: Vec<String> = Vec::new();
1311 let mut edge_count = 0usize;
1312 for p in prepared.iter().filter(|p| p.mount_idx == m) {
1313 edge_count += 1;
1314 let s = p.from.to_string();
1315 if !entity_ids.contains(&s) {
1316 entity_ids.push(s);
1317 }
1318 }
1319 let subject = format!("memstead: batch-relate ({edge_count} edges)");
1320 let note_lines: Vec<String> = prepared
1325 .iter()
1326 .zip(notes.iter())
1327 .filter(|(p, _)| p.mount_idx == m)
1328 .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.from)))
1329 .collect();
1330 let ctx = CommitContext {
1331 actor,
1332 client: client.cloned(),
1333 tool: Some("batch_relate"),
1334 note: if note_lines.is_empty() {
1335 None
1336 } else {
1337 Some(note_lines.join("\n"))
1338 },
1339 role: self.current_role,
1340 identity: self.current_identity.clone(),
1341 logical_operation_id: None,
1342 entity_ids: Some(entity_ids),
1343 };
1344 match self.mounts[m].backend.commit(&subject, &ctx) {
1345 Ok(sha) => mount_commits.push((m, sha)),
1346 Err(e) => {
1347 self.store = store_snapshot;
1352 self.discard_all_pending();
1353 return Err(e.into());
1354 }
1355 }
1356 }
1357
1358 let mut batch_warnings: Vec<WarningHint> = Vec::new();
1360 for (p, note) in prepared.iter().zip(notes.iter()) {
1361 let write_id = mount_commits
1362 .iter()
1363 .find(|(m, _)| *m == p.mount_idx)
1364 .map(|(_, s)| s.clone())
1365 .unwrap_or_default();
1366 self.mounts[p.mount_idx].backend.append_provenance(
1367 &Provenance::new(
1368 std::time::SystemTime::now(),
1369 ProvenanceKind::Relate,
1370 Some(p.from.to_string()),
1371 actor,
1372 client.cloned(),
1373 note.clone(),
1374 )
1375 .with_role(self.current_role)
1376 .with_identity(self.current_identity.clone()),
1377 )?;
1378 self.record_self_write(p.mount_idx, &write_id);
1379 batch_warnings.extend(self.stamp_mutation_versions(p.mount_idx));
1380 }
1381
1382 let removed_targets: Vec<EntityId> = prepared
1385 .iter()
1386 .filter(|p| matches!(p.action, RelateAction::Removed))
1387 .map(|p| p.to.clone())
1388 .collect();
1389 let orphan_stubs_removed =
1390 super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1391
1392 self.invalidate_communities();
1393 self.invalidate_search_indexes();
1394
1395 let write_id = mount_commits
1396 .last()
1397 .map(|(_, s)| s.clone())
1398 .unwrap_or_default();
1399 let succeeded = items.len();
1400 let results: Vec<crate::ops::BatchEntry> = items
1401 .into_iter()
1402 .map(|(id, state)| crate::ops::BatchEntry {
1403 id,
1404 action: match state {
1405 ItemState::Applied(label) => label.to_string(),
1406 ItemState::Noop => "noop".to_string(),
1407 ItemState::Error => unreachable!("refusal path returned above"),
1408 },
1409 error: None,
1410 })
1411 .collect();
1412
1413 Ok(crate::ops::BatchResult {
1414 warnings: batch_warnings,
1415 orphan_stubs_removed,
1416 errors_suppressed: 0,
1417 applied: true,
1418 results,
1419 succeeded,
1420 failed: 0,
1421 write_id,
1422 })
1423 }
1424
1425 pub fn relate(
1432 &mut self,
1433 from: &EntityId,
1434 to: &EntityId,
1435 rel_type: &str,
1436 remove: bool,
1437 ctx: &CommitContext<'_>,
1438 ) -> Result<RelateEntityOutcome, EngineError> {
1439 let args = RelateEntityArgs {
1440 source: from.clone(),
1441 expected_hash: None,
1442 rel_type: rel_type.to_string(),
1443 target: to.clone(),
1444 remove,
1445 description: None,
1446 dry_run: false,
1447 };
1448 self.relate_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1449 }
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454
1455 use indexmap::IndexMap;
1456 use tempfile::TempDir;
1457
1458 use crate::backend::MemBackend;
1459 use crate::engine::test_helpers::*;
1460 use crate::engine::{CreateEntityArgs, Engine, EngineError, RelateAction, RelateEntityArgs};
1461 use crate::ops::WarningHint;
1462 use crate::storage::FilesystemMemWriter;
1463 use crate::vcs::{Actor, CommitContext};
1464
1465 #[test]
1466 fn relate_alias_delegates_to_relate_entity() {
1467 let tmp = TempDir::new().unwrap();
1472 let mem_dir = tmp.path().to_path_buf();
1473 let writer = FilesystemMemWriter::new(mem_dir.clone());
1474 let mut engine = Engine::from_mounts(vec![(
1475 folder_mount("specs", mem_dir),
1476 Box::new(writer) as Box<dyn MemBackend>,
1477 )])
1478 .unwrap();
1479
1480 let a = engine
1482 .create_entity(
1483 CreateEntityArgs {
1484 anchors: Vec::new(),
1485 mem: "specs".to_string(),
1486 title: "A".to_string(),
1487 entity_type: "spec".to_string(),
1488 sections: IndexMap::from_iter([
1489 ("identity".to_string(), "seed identity".to_string()),
1490 ("purpose".to_string(), "seed purpose".to_string()),
1491 ]),
1492 metadata: IndexMap::new(),
1493 relations: Vec::new(),
1494 dry_run: false,
1495 },
1496 Actor::Cli,
1497 None,
1498 None,
1499 )
1500 .unwrap();
1501 let b = engine
1502 .create_entity(
1503 CreateEntityArgs {
1504 anchors: Vec::new(),
1505 mem: "specs".to_string(),
1506 title: "B".to_string(),
1507 entity_type: "spec".to_string(),
1508 sections: IndexMap::from_iter([
1509 ("identity".to_string(), "seed identity".to_string()),
1510 ("purpose".to_string(), "seed purpose".to_string()),
1511 ]),
1512 metadata: IndexMap::new(),
1513 relations: Vec::new(),
1514 dry_run: false,
1515 },
1516 Actor::Cli,
1517 None,
1518 None,
1519 )
1520 .unwrap();
1521
1522 let ctx = CommitContext::internal();
1524 let result = engine.relate(&a.id, &b.id, "PART_OF", false, &ctx).unwrap();
1525 assert_eq!(result.from, a.id);
1526 assert_eq!(result.to, b.id);
1527 assert_eq!(result.rel_type, "PART_OF");
1528 let outgoing: Vec<_> = engine.store().outgoing(&a.id).to_vec();
1530 assert!(
1531 outgoing
1532 .iter()
1533 .any(|e| e.target == b.id && e.rel_type == "PART_OF")
1534 );
1535 }
1536
1537 #[test]
1538 fn relate_entity_appends_relationship_and_logs_provenance() {
1539 let tmp = TempDir::new().unwrap();
1540 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1541 let (actor, client) = cli_actor();
1542 let target = engine
1543 .create_entity(
1544 empty_create_args("specs", "Target"),
1545 actor,
1546 Some(&client),
1547 None,
1548 )
1549 .unwrap();
1550
1551 let outcome = engine
1552 .relate_entity(
1553 RelateEntityArgs {
1554 source: source.id.clone(),
1555 expected_hash: Some(source.content_hash.clone()),
1556 rel_type: "USES".to_string(),
1557 target: target.id.clone(),
1558 remove: false,
1559 description: None,
1560 dry_run: false,
1561 },
1562 actor,
1563 Some(&client),
1564 None,
1565 )
1566 .unwrap();
1567 assert_eq!(outcome.action, RelateAction::Added);
1568 assert_ne!(outcome.content_hash, source.content_hash);
1569 let edges = engine.store().outgoing(&source.id);
1571 assert!(
1572 edges
1573 .iter()
1574 .any(|e| e.rel_type == "USES" && e.target == target.id),
1575 "expected USES edge in store"
1576 );
1577 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
1579 assert!(log.contains("\"kind\":\"relate\""));
1580 }
1581
1582 #[test]
1583 fn relate_entity_no_op_when_already_present() {
1584 let tmp = TempDir::new().unwrap();
1585 let (mut engine, source) = engine_with_seed(&tmp, "Already");
1586 let (actor, client) = cli_actor();
1587 let target = engine
1588 .create_entity(empty_create_args("specs", "T2"), actor, Some(&client), None)
1589 .unwrap();
1590 let first = engine
1591 .relate_entity(
1592 RelateEntityArgs {
1593 source: source.id.clone(),
1594 expected_hash: Some(source.content_hash.clone()),
1595 rel_type: "USES".to_string(),
1596 target: target.id.clone(),
1597 remove: false,
1598 description: None,
1599 dry_run: false,
1600 },
1601 actor,
1602 Some(&client),
1603 None,
1604 )
1605 .unwrap();
1606 let second = engine
1607 .relate_entity(
1608 RelateEntityArgs {
1609 source: source.id.clone(),
1610 expected_hash: Some(first.content_hash.clone()),
1611 rel_type: "USES".to_string(),
1612 target: target.id.clone(),
1613 remove: false,
1614 description: None,
1615 dry_run: false,
1616 },
1617 actor,
1618 Some(&client),
1619 None,
1620 )
1621 .unwrap();
1622 assert_eq!(second.action, RelateAction::NoOpAlreadyPresent);
1623 assert_eq!(second.content_hash, first.content_hash);
1625 }
1626
1627 #[test]
1628 fn relate_entity_returns_write_id_on_real_write() {
1629 let tmp = TempDir::new().unwrap();
1630 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1631 let (actor, client) = cli_actor();
1632 let target = engine
1633 .create_entity(
1634 empty_create_args("specs", "Target"),
1635 actor,
1636 Some(&client),
1637 None,
1638 )
1639 .unwrap();
1640
1641 let outcome = engine
1642 .relate_entity(
1643 RelateEntityArgs {
1644 source: source.id.clone(),
1645 expected_hash: Some(source.content_hash.clone()),
1646 rel_type: "USES".to_string(),
1647 target: target.id.clone(),
1648 remove: false,
1649 description: None,
1650 dry_run: false,
1651 },
1652 actor,
1653 Some(&client),
1654 None,
1655 )
1656 .unwrap();
1657 assert!(
1661 !outcome.write_id.is_empty(),
1662 "write_id must be populated on a real write"
1663 );
1664 }
1665
1666 #[test]
1667 fn relate_entity_no_op_paths_carry_typed_warnings_and_empty_write_id() {
1668 let tmp = TempDir::new().unwrap();
1669 let (mut engine, source) = engine_with_seed(&tmp, "S");
1670 let (actor, client) = cli_actor();
1671 let target = engine
1672 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1673 .unwrap();
1674
1675 let first = engine
1677 .relate_entity(
1678 RelateEntityArgs {
1679 source: source.id.clone(),
1680 expected_hash: Some(source.content_hash.clone()),
1681 rel_type: "USES".to_string(),
1682 target: target.id.clone(),
1683 remove: false,
1684 description: None,
1685 dry_run: false,
1686 },
1687 actor,
1688 Some(&client),
1689 None,
1690 )
1691 .unwrap();
1692
1693 let dup = engine
1696 .relate_entity(
1697 RelateEntityArgs {
1698 source: source.id.clone(),
1699 expected_hash: Some(first.content_hash.clone()),
1700 rel_type: "USES".to_string(),
1701 target: target.id.clone(),
1702 remove: false,
1703 description: None,
1704 dry_run: false,
1705 },
1706 actor,
1707 Some(&client),
1708 None,
1709 )
1710 .unwrap();
1711 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1712 assert!(dup.write_id.is_empty());
1713 assert_eq!(dup.warnings.len(), 1);
1714 assert!(matches!(
1715 dup.warnings[0],
1716 WarningHint::DuplicateRelationship { .. }
1717 ));
1718
1719 let no_such = engine
1722 .relate_entity(
1723 RelateEntityArgs {
1724 source: source.id.clone(),
1725 expected_hash: Some(first.content_hash.clone()),
1726 rel_type: "DEPENDS_ON".to_string(),
1727 target: target.id.clone(),
1728 remove: true,
1729 description: None,
1730 dry_run: false,
1731 },
1732 actor,
1733 Some(&client),
1734 None,
1735 )
1736 .unwrap();
1737 assert_eq!(no_such.action, RelateAction::NoOpAbsent);
1738 assert!(no_such.write_id.is_empty());
1739 assert_eq!(no_such.warnings.len(), 1);
1740 assert!(matches!(
1741 no_such.warnings[0],
1742 WarningHint::NoSuchRelationship { .. }
1743 ));
1744 }
1745
1746 #[test]
1747 fn relate_entity_creates_stub_for_absent_target_on_add_path() {
1748 let tmp = TempDir::new().unwrap();
1749 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1750 let (actor, client) = cli_actor();
1751 let absent_target = crate::EntityId::new("specs", "ghost-target");
1752 assert!(!engine.store().contains(&absent_target));
1754
1755 let outcome = engine
1756 .relate_entity(
1757 RelateEntityArgs {
1758 source: source.id.clone(),
1759 expected_hash: Some(source.content_hash.clone()),
1760 rel_type: "USES".to_string(),
1761 target: absent_target.clone(),
1762 remove: false,
1763 description: None,
1764 dry_run: false,
1765 },
1766 actor,
1767 Some(&client),
1768 None,
1769 )
1770 .unwrap();
1771
1772 assert_eq!(outcome.action, RelateAction::Added);
1773 assert_eq!(outcome.source, "explicit");
1774 let stub_warning = outcome
1780 .warnings
1781 .iter()
1782 .find_map(|w| match w {
1783 crate::ops::WarningHint::AutoStubCreated { stub_id, .. } => Some(stub_id.clone()),
1784 _ => None,
1785 })
1786 .expect("AutoStubCreated warning must surface when target was absent");
1787 assert_eq!(stub_warning, absent_target);
1788 let msg = outcome
1791 .warnings
1792 .iter()
1793 .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
1794 .unwrap()
1795 .message();
1796 assert!(
1797 msg.contains("stub auto-created"),
1798 "real relate keeps the performed-effect wording: {msg}"
1799 );
1800
1801 let stub = engine.store().get(&absent_target).expect("stub upserted");
1803 assert!(stub.stub);
1804 assert!(stub.entity_type.is_empty());
1805 assert!(stub.file_path.is_empty());
1806 }
1807
1808 #[test]
1809 fn relate_entity_skips_stub_creation_when_target_already_exists() {
1810 let tmp = TempDir::new().unwrap();
1811 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1812 let (actor, client) = cli_actor();
1813 let target = engine
1814 .create_entity(
1815 empty_create_args("specs", "Real"),
1816 actor,
1817 Some(&client),
1818 None,
1819 )
1820 .unwrap();
1821
1822 let outcome = engine
1823 .relate_entity(
1824 RelateEntityArgs {
1825 source: source.id.clone(),
1826 expected_hash: Some(source.content_hash.clone()),
1827 rel_type: "USES".to_string(),
1828 target: target.id.clone(),
1829 remove: false,
1830 description: None,
1831 dry_run: false,
1832 },
1833 actor,
1834 Some(&client),
1835 None,
1836 )
1837 .unwrap();
1838
1839 assert!(
1840 !outcome
1841 .warnings
1842 .iter()
1843 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1844 "AutoStubCreated must not surface when target was already in store"
1845 );
1846 assert_eq!(outcome.source, "explicit");
1847 let target_after = engine.store().get(&target.id).unwrap();
1849 assert!(!target_after.stub);
1850 }
1851
1852 #[test]
1853 fn relate_entity_does_not_create_stub_on_remove_path() {
1854 let tmp = TempDir::new().unwrap();
1855 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1856 let (actor, client) = cli_actor();
1857 let absent_target = crate::EntityId::new("specs", "never-existed");
1858
1859 let outcome = engine
1860 .relate_entity(
1861 RelateEntityArgs {
1862 source: source.id.clone(),
1863 expected_hash: Some(source.content_hash.clone()),
1864 rel_type: "USES".to_string(),
1865 target: absent_target.clone(),
1866 remove: true,
1867 description: None,
1868 dry_run: false,
1869 },
1870 actor,
1871 Some(&client),
1872 None,
1873 )
1874 .unwrap();
1875
1876 assert_eq!(outcome.action, RelateAction::NoOpAbsent);
1879 assert!(
1880 !outcome
1881 .warnings
1882 .iter()
1883 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1884 "remove path must never auto-stub the target",
1885 );
1886 assert!(!engine.store().contains(&absent_target));
1887 }
1888
1889 #[test]
1890 fn relate_entity_remove_refuses_when_source_body_still_references_target() {
1891 use crate::engine::CreateEntityArgs;
1892 use indexmap::IndexMap;
1893
1894 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.clone()),
1899 Box::new(writer) as Box<dyn MemBackend>,
1900 )])
1901 .unwrap();
1902 let (actor, client) = cli_actor();
1903
1904 let target = engine
1905 .create_entity(
1906 empty_create_args("specs", "Target"),
1907 actor,
1908 Some(&client),
1909 None,
1910 )
1911 .unwrap();
1912
1913 let mut sections: IndexMap<String, String> = IndexMap::new();
1919 sections.insert("identity".to_string(), "source identity".to_string());
1920 sections.insert(
1921 "purpose".to_string(),
1922 "discussion stems from [[target]]".to_string(),
1923 );
1924 let source = engine
1925 .create_entity(
1926 CreateEntityArgs {
1927 anchors: Vec::new(),
1928 mem: "specs".to_string(),
1929 title: "Source".to_string(),
1930 entity_type: "spec".to_string(),
1931 sections,
1932 metadata: IndexMap::new(),
1933 relations: Vec::new(),
1934 dry_run: false,
1935 },
1936 actor,
1937 Some(&client),
1938 None,
1939 )
1940 .unwrap();
1941 let related = source.clone();
1942
1943 let err = engine
1947 .relate_entity(
1948 RelateEntityArgs {
1949 source: source.id.clone(),
1950 expected_hash: Some(related.content_hash.clone()),
1951 rel_type: "REFERENCES".to_string(),
1952 target: target.id.clone(),
1953 remove: true,
1954 description: None,
1955 dry_run: false,
1956 },
1957 actor,
1958 Some(&client),
1959 None,
1960 )
1961 .unwrap_err();
1962 match err {
1963 EngineError::RelationHasBodyLinks {
1964 from_id,
1965 to_id,
1966 rel_type,
1967 body_links,
1968 } => {
1969 assert_eq!(from_id, source.id.to_string());
1970 assert_eq!(to_id, target.id.to_string());
1971 assert_eq!(rel_type, "REFERENCES");
1972 assert_eq!(body_links, vec!["purpose".to_string()]);
1973 }
1974 other => panic!("expected RelationHasBodyLinks, got {other:?}"),
1975 }
1976 let in_mem = engine.get_entity(&source.id).unwrap();
1979 assert!(
1980 in_mem
1981 .relationships
1982 .iter()
1983 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
1984 "relation must survive the refused remove; got {:?}",
1985 in_mem.relationships
1986 );
1987 }
1988
1989 #[test]
1990 fn relate_entity_remove_succeeds_when_body_no_longer_references_target() {
1991 let tmp = TempDir::new().unwrap();
1992 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1993 let (actor, client) = cli_actor();
1994 let target = engine
1995 .create_entity(
1996 empty_create_args("specs", "Other"),
1997 actor,
1998 Some(&client),
1999 None,
2000 )
2001 .unwrap();
2002 let related = engine
2008 .relate_entity(
2009 RelateEntityArgs {
2010 source: source.id.clone(),
2011 expected_hash: Some(source.content_hash.clone()),
2012 rel_type: "USES".to_string(),
2013 target: target.id.clone(),
2014 remove: false,
2015 description: None,
2016 dry_run: false,
2017 },
2018 actor,
2019 Some(&client),
2020 None,
2021 )
2022 .unwrap();
2023 let removed = engine
2024 .relate_entity(
2025 RelateEntityArgs {
2026 source: source.id.clone(),
2027 expected_hash: Some(related.content_hash.clone()),
2028 rel_type: "USES".to_string(),
2029 target: target.id.clone(),
2030 remove: true,
2031 description: None,
2032 dry_run: false,
2033 },
2034 actor,
2035 Some(&client),
2036 None,
2037 )
2038 .unwrap();
2039 assert_eq!(removed.action, RelateAction::Removed);
2040 }
2041
2042 #[test]
2043 fn relate_entity_auto_stub_is_tagged_forward_reference() {
2044 use crate::entity::StubKind;
2049
2050 let tmp = TempDir::new().unwrap();
2051 let (mut engine, source) = engine_with_seed(&tmp, "Src");
2052 let (actor, client) = cli_actor();
2053 let absent_target = crate::EntityId::new("specs", "absent-target");
2054
2055 let _ = engine
2056 .relate_entity(
2057 RelateEntityArgs {
2058 source: source.id.clone(),
2059 expected_hash: Some(source.content_hash.clone()),
2060 rel_type: "USES".to_string(),
2061 target: absent_target.clone(),
2062 remove: false,
2063 description: None,
2064 dry_run: false,
2065 },
2066 actor,
2067 Some(&client),
2068 None,
2069 )
2070 .unwrap();
2071
2072 let stub = engine
2073 .get_entity(&absent_target)
2074 .expect("relate auto-stubbed target must be in the store");
2075 assert!(stub.stub, "auto-stubbed target must carry stub: true");
2076 assert_eq!(
2077 stub.stub_kind,
2078 Some(StubKind::ForwardReference),
2079 "auto-stub from relate must be tagged ForwardReference; got {:?}",
2080 stub.stub_kind
2081 );
2082 }
2083
2084 #[test]
2085 fn relate_entity_case_insensitive_rel_type_input_canonicalises_to_upper_snake_case() {
2086 let tmp = TempDir::new().unwrap();
2091 let (mut engine, source) = engine_with_seed(&tmp, "Source");
2092 let (actor, client) = cli_actor();
2093 let target = engine
2094 .create_entity(
2095 empty_create_args("specs", "Target"),
2096 actor,
2097 Some(&client),
2098 None,
2099 )
2100 .unwrap();
2101
2102 let lower = engine
2104 .relate_entity(
2105 RelateEntityArgs {
2106 source: source.id.clone(),
2107 expected_hash: Some(source.content_hash.clone()),
2108 rel_type: "uses".to_string(),
2109 target: target.id.clone(),
2110 remove: false,
2111 description: None,
2112 dry_run: false,
2113 },
2114 actor,
2115 Some(&client),
2116 None,
2117 )
2118 .unwrap();
2119 assert_eq!(lower.rel_type, "USES", "response must echo canonical form");
2120 assert_eq!(lower.action, RelateAction::Added);
2121 let edges = engine.store().outgoing(&source.id);
2122 assert!(
2123 edges
2124 .iter()
2125 .any(|e| e.rel_type == "USES" && e.target == target.id),
2126 "store must hold UPPER_SNAKE_CASE rel_type after lowercase input"
2127 );
2128
2129 let dup = engine
2133 .relate_entity(
2134 RelateEntityArgs {
2135 source: source.id.clone(),
2136 expected_hash: Some(lower.content_hash.clone()),
2137 rel_type: "Uses".to_string(),
2138 target: target.id.clone(),
2139 remove: false,
2140 description: None,
2141 dry_run: false,
2142 },
2143 actor,
2144 Some(&client),
2145 None,
2146 )
2147 .unwrap();
2148 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
2149 assert_eq!(dup.rel_type, "USES");
2150 assert!(matches!(
2151 dup.warnings[0],
2152 WarningHint::DuplicateRelationship { .. }
2153 ));
2154 }
2155
2156 #[test]
2157 fn relate_entity_rejects_cross_mem_when_policy_denies() {
2158 let tmp = TempDir::new().unwrap();
2165 let (mut engine, source) = engine_with_seed(&tmp, "S");
2166 let (actor, client) = cli_actor();
2167 let err = engine
2168 .relate_entity(
2169 RelateEntityArgs {
2170 source: source.id.clone(),
2171 expected_hash: Some(source.content_hash.clone()),
2172 rel_type: "USES".to_string(),
2173 target: crate::EntityId::new("other-mem", "thing"),
2174 remove: false,
2175 description: None,
2176 dry_run: false,
2177 },
2178 actor,
2179 Some(&client),
2180 None,
2181 )
2182 .unwrap_err();
2183 match err {
2184 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2185 assert_eq!(from_mem, "specs");
2186 assert_eq!(to_mem, "other-mem");
2187 }
2188 other => panic!("expected CrossMemLinkNotAllowed, got {other:?}"),
2189 }
2190 }
2191
2192 #[test]
2201 fn relate_entity_malformed_bare_target_surfaces_invalid_entity_id_not_cross_mem() {
2202 let tmp = TempDir::new().unwrap();
2203 let (mut engine, source) = engine_with_seed(&tmp, "S");
2204 let (actor, client) = cli_actor();
2205 let err = engine
2206 .relate_entity(
2207 RelateEntityArgs {
2208 source: source.id.clone(),
2209 expected_hash: Some(source.content_hash.clone()),
2210 rel_type: "USES".to_string(),
2211 target: crate::EntityId("bad target with spaces!!".to_string()),
2214 remove: false,
2215 description: None,
2216 dry_run: false,
2217 },
2218 actor,
2219 Some(&client),
2220 None,
2221 )
2222 .unwrap_err();
2223 assert!(
2224 matches!(err, EngineError::InvalidEntityId { .. }),
2225 "malformed bare-string target must surface INVALID_ENTITY_ID, got: {err:?}"
2226 );
2227 }
2228
2229 #[test]
2234 fn relate_entity_malformed_prefixed_target_surfaces_invalid_entity_id() {
2235 let tmp = TempDir::new().unwrap();
2236 let (mut engine, source) = engine_with_seed(&tmp, "S");
2237 let (actor, client) = cli_actor();
2238 let source_mem = source.id.mem().to_string();
2239 let err = engine
2240 .relate_entity(
2241 RelateEntityArgs {
2242 source: source.id.clone(),
2243 expected_hash: Some(source.content_hash.clone()),
2244 rel_type: "USES".to_string(),
2245 target: crate::EntityId(format!("{source_mem}--bad target with spaces!!")),
2246 remove: false,
2247 description: None,
2248 dry_run: false,
2249 },
2250 actor,
2251 Some(&client),
2252 None,
2253 )
2254 .unwrap_err();
2255 assert!(
2256 matches!(err, EngineError::InvalidEntityId { .. }),
2257 "prefixed malformed target must still surface INVALID_ENTITY_ID, got: {err:?}"
2258 );
2259 }
2260
2261 #[test]
2267 fn relate_add_bumps_last_modified_on_source_entity() {
2268 let tmp = TempDir::new().unwrap();
2269 let (mut engine, source) = engine_with_seed(&tmp, "S");
2270 let (actor, client) = cli_actor();
2271 let target = engine
2272 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2273 .unwrap();
2274
2275 let outcome = engine
2276 .relate_entity(
2277 RelateEntityArgs {
2278 source: source.id.clone(),
2279 expected_hash: Some(source.content_hash.clone()),
2280 rel_type: "USES".to_string(),
2281 target: target.id.clone(),
2282 remove: false,
2283 description: None,
2284 dry_run: false,
2285 },
2286 actor,
2287 Some(&client),
2288 None,
2289 )
2290 .unwrap();
2291 assert_eq!(outcome.action, RelateAction::Added);
2292
2293 let post = engine.get_entity(&source.id).unwrap();
2298 let last_modified = post
2299 .metadata
2300 .get("last_modified")
2301 .map(|v| v.to_frontmatter_string())
2302 .unwrap_or_default();
2303 assert!(
2304 last_modified.starts_with("20"),
2305 "last_modified must carry an ISO timestamp post-relate; got: {last_modified:?}"
2306 );
2307 }
2308
2309 #[test]
2314 fn relate_add_noop_does_not_bump_last_modified() {
2315 let tmp = TempDir::new().unwrap();
2316 let (mut engine, source) = engine_with_seed(&tmp, "S");
2317 let (actor, client) = cli_actor();
2318 let target = engine
2319 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2320 .unwrap();
2321 let first = engine
2322 .relate_entity(
2323 RelateEntityArgs {
2324 source: source.id.clone(),
2325 expected_hash: Some(source.content_hash.clone()),
2326 rel_type: "USES".to_string(),
2327 target: target.id.clone(),
2328 remove: false,
2329 description: None,
2330 dry_run: false,
2331 },
2332 actor,
2333 Some(&client),
2334 None,
2335 )
2336 .unwrap();
2337 let pre = engine.get_entity(&source.id).unwrap();
2338 let pre_stamp = pre
2339 .metadata
2340 .get("last_modified")
2341 .map(|v| v.to_frontmatter_string())
2342 .unwrap_or_default();
2343
2344 let dup = engine
2346 .relate_entity(
2347 RelateEntityArgs {
2348 source: source.id.clone(),
2349 expected_hash: Some(first.content_hash.clone()),
2350 rel_type: "USES".to_string(),
2351 target: target.id.clone(),
2352 remove: false,
2353 description: None,
2354 dry_run: false,
2355 },
2356 actor,
2357 Some(&client),
2358 None,
2359 )
2360 .unwrap();
2361 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
2362
2363 let post = engine.get_entity(&source.id).unwrap();
2364 let post_stamp = post
2365 .metadata
2366 .get("last_modified")
2367 .map(|v| v.to_frontmatter_string())
2368 .unwrap_or_default();
2369 assert_eq!(
2370 pre_stamp, post_stamp,
2371 "last_modified must not advance on a duplicate-add no-op (no disk write happened)"
2372 );
2373 }
2374
2375 #[test]
2380 fn cross_mem_relate_to_uncreated_mem_emits_typed_warning() {
2381 use memstead_schema::workspace_config::CrossLinkValue;
2382 let tmp = TempDir::new().unwrap();
2383 let (mut engine, source) = engine_with_seed(&tmp, "S");
2384 let (actor, client) = cli_actor();
2385 let mut settings = crate::workspace::WorkspaceSettings::default();
2389 settings.cross_mem_links.insert(
2390 "specs".to_string(),
2391 CrossLinkValue::List(vec!["uncreated-mem".to_string()]),
2392 );
2393 engine.set_settings(settings);
2394
2395 let absent = crate::EntityId::new("uncreated-mem", "ghost");
2396 let outcome = engine
2397 .relate_entity(
2398 RelateEntityArgs {
2399 source: source.id.clone(),
2400 expected_hash: Some(source.content_hash.clone()),
2401 rel_type: "USES".to_string(),
2402 target: absent.clone(),
2403 remove: false,
2404 description: None,
2405 dry_run: false,
2406 },
2407 actor,
2408 Some(&client),
2409 None,
2410 )
2411 .unwrap();
2412 assert_eq!(outcome.action, RelateAction::Added);
2413
2414 let saw_uncreated = outcome.warnings.iter().any(|w| {
2416 matches!(
2417 w,
2418 WarningHint::CrossMemTargetMemUncreated {
2419 from_mem,
2420 to_mem,
2421 target_id,
2422 } if from_mem == "specs"
2423 && to_mem == "uncreated-mem"
2424 && target_id == &absent
2425 )
2426 });
2427 assert!(
2428 saw_uncreated,
2429 "CrossMemTargetMemUncreated warning must surface; got: {:?}",
2430 outcome.warnings
2431 );
2432 assert!(engine.store().contains(&absent));
2434 }
2435
2436 #[test]
2442 fn cross_mem_relate_policy_refusal_preempts_uncreated_mem_warning() {
2443 let tmp = TempDir::new().unwrap();
2444 let (mut engine, source) = engine_with_seed(&tmp, "S");
2445 let (actor, client) = cli_actor();
2446 let absent = crate::EntityId::new("uncreated-mem", "ghost");
2448 let err = engine
2449 .relate_entity(
2450 RelateEntityArgs {
2451 source: source.id.clone(),
2452 expected_hash: Some(source.content_hash.clone()),
2453 rel_type: "USES".to_string(),
2454 target: absent.clone(),
2455 remove: false,
2456 description: None,
2457 dry_run: false,
2458 },
2459 actor,
2460 Some(&client),
2461 None,
2462 )
2463 .unwrap_err();
2464 assert!(matches!(err, EngineError::CrossMemLinkNotAllowed { .. }));
2465 assert!(!engine.store().contains(&absent));
2467 }
2468
2469 #[test]
2475 fn relate_remove_garbage_collects_orphan_stub() {
2476 let tmp = TempDir::new().unwrap();
2477 let (mut engine, source) = engine_with_seed(&tmp, "Src");
2478 let (actor, client) = cli_actor();
2479 let stub_id = crate::EntityId::new("specs", "ghost-target");
2480
2481 let added = engine
2483 .relate_entity(
2484 RelateEntityArgs {
2485 source: source.id.clone(),
2486 expected_hash: Some(source.content_hash.clone()),
2487 rel_type: "USES".to_string(),
2488 target: stub_id.clone(),
2489 remove: false,
2490 description: None,
2491 dry_run: false,
2492 },
2493 actor,
2494 Some(&client),
2495 None,
2496 )
2497 .unwrap();
2498 assert!(engine.store().contains(&stub_id));
2499
2500 let removed = engine
2501 .relate_entity(
2502 RelateEntityArgs {
2503 source: source.id.clone(),
2504 expected_hash: Some(added.content_hash.clone()),
2505 rel_type: "USES".to_string(),
2506 target: stub_id.clone(),
2507 remove: true,
2508 description: None,
2509 dry_run: false,
2510 },
2511 actor,
2512 Some(&client),
2513 None,
2514 )
2515 .unwrap();
2516 assert_eq!(removed.action, RelateAction::Removed);
2517 assert_eq!(
2518 removed.orphan_stubs_removed,
2519 vec![stub_id.clone()],
2520 "orphan stub must be GC'd in the same call"
2521 );
2522 assert!(
2523 !engine.store().contains(&stub_id),
2524 "stub must be gone from the store after GC"
2525 );
2526 }
2527
2528 #[test]
2534 fn relate_remove_does_not_gc_stub_with_surviving_incoming_edge() {
2535 let tmp = TempDir::new().unwrap();
2536 let (mut engine, source_a) = engine_with_seed(&tmp, "SrcA");
2537 let (actor, client) = cli_actor();
2538 let source_b = engine
2539 .create_entity(
2540 empty_create_args("specs", "SrcB"),
2541 actor,
2542 Some(&client),
2543 None,
2544 )
2545 .unwrap();
2546 let stub_id = crate::EntityId::new("specs", "ghost-target");
2547
2548 let a_added = engine
2550 .relate_entity(
2551 RelateEntityArgs {
2552 source: source_a.id.clone(),
2553 expected_hash: Some(source_a.content_hash.clone()),
2554 rel_type: "USES".to_string(),
2555 target: stub_id.clone(),
2556 remove: false,
2557 description: None,
2558 dry_run: false,
2559 },
2560 actor,
2561 Some(&client),
2562 None,
2563 )
2564 .unwrap();
2565 let _b_added = engine
2566 .relate_entity(
2567 RelateEntityArgs {
2568 source: source_b.id.clone(),
2569 expected_hash: Some(source_b.content_hash.clone()),
2570 rel_type: "USES".to_string(),
2571 target: stub_id.clone(),
2572 remove: false,
2573 description: None,
2574 dry_run: false,
2575 },
2576 actor,
2577 Some(&client),
2578 None,
2579 )
2580 .unwrap();
2581
2582 let removed = engine
2585 .relate_entity(
2586 RelateEntityArgs {
2587 source: source_a.id.clone(),
2588 expected_hash: Some(a_added.content_hash.clone()),
2589 rel_type: "USES".to_string(),
2590 target: stub_id.clone(),
2591 remove: true,
2592 description: None,
2593 dry_run: false,
2594 },
2595 actor,
2596 Some(&client),
2597 None,
2598 )
2599 .unwrap();
2600 assert_eq!(removed.action, RelateAction::Removed);
2601 assert!(
2602 removed.orphan_stubs_removed.is_empty(),
2603 "stub with surviving referrer must not be GC'd; got: {:?}",
2604 removed.orphan_stubs_removed
2605 );
2606 assert!(
2607 engine.store().contains(&stub_id),
2608 "stub must remain in store while another referrer holds it"
2609 );
2610 }
2611
2612 mod cross_mem {
2622 use std::collections::BTreeMap;
2623 use std::path::Path;
2624
2625 use indexmap::IndexMap;
2626 use memstead_schema::SchemaRef;
2627 use memstead_schema::workspace_config::CrossLinkValue;
2628 use tempfile::TempDir;
2629
2630 use crate::backend::MemBackend;
2631 use crate::engine::test_helpers::*;
2632 use crate::engine::{
2633 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateAction,
2634 RelateEntityArgs,
2635 };
2636 use crate::storage::FilesystemMemWriter;
2637
2638 use crate::workspace::{
2639 Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
2640 };
2641
2642 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
2643 let dir = root.join(name);
2644 std::fs::create_dir_all(dir.join("types")).unwrap();
2645 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
2646 for (type_name, body) in types {
2647 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
2648 }
2649 }
2650
2651 const TYPE_BODY: &str = r#"description: t
2652when_to_use: Here
2653sections:
2654 - key: body
2655 heading: Body
2656 required: true
2657 search_weight: 10.0
2658 catch_all: true
2659 write_rules: []
2660metadata_fields: []
2661title_weight: 100.0
2662text_fields:
2663 - body
2664hierarchy_relationship: _default
2665no_self_loop_relationships: []
2666updatable_fields:
2667 - title
2668 - body
2669health_required_fields:
2670 - body
2671staleness_threshold_days: 90
2672write_rules: []
2673"#;
2674
2675 fn make_type_yaml(name: &str) -> String {
2676 format!("name: {name}\n{TYPE_BODY}")
2677 }
2678
2679 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
2680 Mount {
2681 mem: mem.to_string(),
2682 schema: Some(pin),
2683 storage: MountStorage::Folder { path },
2684 capability: MountCapability::Write,
2685 lifecycle: MountLifecycle::Eager,
2686 cross_linkable: true,
2687 migration_target: None,
2688 }
2689 }
2690
2691 fn two_mem_engine() -> (TempDir, Engine, CreateEntityOutcome, CreateEntityOutcome) {
2694 let tmp = TempDir::new().unwrap();
2695
2696 let src_manifest = r#"name: src-cv
2699version: 0.1.0
2700description: source schema
2701when_to_use: tests
2702types:
2703 - doc
2704relationships:
2705 mode: strict
2706 definitions:
2707 - name: IMPLEMENTS
2708 description: intra-mem only
2709 default_weight: 1.0
2710 - name: _default
2711 description: fallback
2712 default_weight: 1.0
2713cross_mem_relationships:
2714 - to_schema: tgt-cv
2715 definitions:
2716 - name: ADDRESSES
2717 description: outbound shape-pinned
2718 default_weight: 1.0
2719 source_types: [doc]
2720 target_types: [req]
2721community:
2722 resolution: 1.0
2723 seed: 42
2724"#;
2725 let tgt_manifest = r#"name: tgt-cv
2728version: 0.1.0
2729description: target schema
2730when_to_use: tests
2731types:
2732 - req
2733relationships:
2734 mode: strict
2735 definitions:
2736 - name: PART_OF
2737 description: hierarchy
2738 default_weight: 3.0
2739 acyclic: true
2740 - name: _default
2741 description: fallback
2742 default_weight: 1.0
2743community:
2744 resolution: 1.0
2745 seed: 42
2746"#;
2747 let schemas_dir = tmp.path().join("schemas");
2748 std::fs::create_dir_all(&schemas_dir).unwrap();
2749 write_schema_files(
2750 &schemas_dir,
2751 "src-cv",
2752 src_manifest,
2753 &[("doc", &make_type_yaml("doc"))],
2754 );
2755 write_schema_files(
2756 &schemas_dir,
2757 "tgt-cv",
2758 tgt_manifest,
2759 &[("req", &make_type_yaml("req"))],
2760 );
2761
2762 let src_dir = tmp.path().join("mem-src");
2763 let tgt_dir = tmp.path().join("mem-tgt");
2764 std::fs::create_dir_all(&src_dir).unwrap();
2765 std::fs::create_dir_all(&tgt_dir).unwrap();
2766
2767 let src_writer = FilesystemMemWriter::new(src_dir.clone());
2768 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
2769 let src_pin = SchemaRef::new("src-cv", semver::Version::new(0, 1, 0));
2770 let tgt_pin = SchemaRef::new("tgt-cv", semver::Version::new(0, 1, 0));
2771
2772 let mut engine = Engine::from_mounts_with_schemas_dir(
2773 vec![
2774 (
2775 folder_mount_with_pin("src", src_dir, src_pin),
2776 Box::new(src_writer) as Box<dyn MemBackend>,
2777 ),
2778 (
2779 folder_mount_with_pin("tgt", tgt_dir, tgt_pin),
2780 Box::new(tgt_writer) as Box<dyn MemBackend>,
2781 ),
2782 ],
2783 Some(&schemas_dir),
2784 )
2785 .expect("two-mem engine constructs");
2786
2787 let mut settings = WorkspaceSettings::default();
2791 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2792 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2793 settings.cross_mem_links = links;
2794 engine.set_settings(settings);
2795
2796 let (actor, client) = cli_actor();
2797 let src_entity = engine
2798 .create_entity(
2799 CreateEntityArgs {
2800 anchors: Vec::new(),
2801 mem: "src".to_string(),
2802 title: "Doc One".to_string(),
2803 entity_type: "doc".to_string(),
2804 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2805 metadata: IndexMap::new(),
2806 relations: Vec::new(),
2807 dry_run: false,
2808 },
2809 actor,
2810 Some(&client),
2811 None,
2812 )
2813 .expect("source entity creates");
2814 let tgt_entity = engine
2815 .create_entity(
2816 CreateEntityArgs {
2817 anchors: Vec::new(),
2818 mem: "tgt".to_string(),
2819 title: "Req One".to_string(),
2820 entity_type: "req".to_string(),
2821 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2822 metadata: IndexMap::new(),
2823 relations: Vec::new(),
2824 dry_run: false,
2825 },
2826 actor,
2827 Some(&client),
2828 None,
2829 )
2830 .expect("target entity creates");
2831
2832 (tmp, engine, src_entity, tgt_entity)
2833 }
2834
2835 #[test]
2836 fn cross_different_schema_admits_declared_edge() {
2837 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2838 let (actor, client) = cli_actor();
2839 let outcome = engine
2840 .relate_entity(
2841 RelateEntityArgs {
2842 source: src.id.clone(),
2843 expected_hash: Some(src.content_hash.clone()),
2844 rel_type: "ADDRESSES".to_string(),
2845 target: tgt.id.clone(),
2846 remove: false,
2847 description: None,
2848 dry_run: false,
2849 },
2850 actor,
2851 Some(&client),
2852 None,
2853 )
2854 .expect("declared cross-mem edge admits");
2855 assert_eq!(outcome.rel_type, "ADDRESSES");
2856 }
2857
2858 #[test]
2864 fn same_name_different_version_uses_intra_mem_vocabulary() {
2865 let tmp = TempDir::new().unwrap();
2866
2867 let manifest_for = |version: &str| {
2868 format!(
2869 r#"name: same-dom
2870version: {version}
2871description: same-domain schema
2872when_to_use: tests
2873types:
2874 - doc
2875relationships:
2876 mode: strict
2877 definitions:
2878 - name: IMPLEMENTS
2879 description: intra-mem vocabulary
2880 default_weight: 1.0
2881 - name: _default
2882 description: fallback
2883 default_weight: 1.0
2884community:
2885 resolution: 1.0
2886 seed: 42
2887"#
2888 )
2889 };
2890 let schemas_dir = tmp.path().join("schemas");
2891 std::fs::create_dir_all(&schemas_dir).unwrap();
2892 write_schema_files(
2895 &schemas_dir,
2896 "same-dom-0.1.0",
2897 &manifest_for("0.1.0"),
2898 &[("doc", &make_type_yaml("doc"))],
2899 );
2900 write_schema_files(
2901 &schemas_dir,
2902 "same-dom-0.2.0",
2903 &manifest_for("0.2.0"),
2904 &[("doc", &make_type_yaml("doc"))],
2905 );
2906
2907 let src_dir = tmp.path().join("mem-src");
2908 let tgt_dir = tmp.path().join("mem-tgt");
2909 std::fs::create_dir_all(&src_dir).unwrap();
2910 std::fs::create_dir_all(&tgt_dir).unwrap();
2911 let src_pin = SchemaRef::new("same-dom", semver::Version::new(0, 1, 0));
2912 let tgt_pin = SchemaRef::new("same-dom", semver::Version::new(0, 2, 0));
2913 let mut engine = Engine::from_mounts_with_schemas_dir(
2914 vec![
2915 (
2916 folder_mount_with_pin("src", src_dir.clone(), src_pin),
2917 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2918 ),
2919 (
2920 folder_mount_with_pin("tgt", tgt_dir.clone(), tgt_pin),
2921 Box::new(FilesystemMemWriter::new(tgt_dir)) as Box<dyn MemBackend>,
2922 ),
2923 ],
2924 Some(&schemas_dir),
2925 )
2926 .expect("same-domain two-version engine constructs");
2927
2928 let mut settings = WorkspaceSettings::default();
2929 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2930 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2931 settings.cross_mem_links = links;
2932 engine.set_settings(settings);
2933
2934 let (actor, client) = cli_actor();
2935 let mk_entity = |engine: &mut Engine, mem: &str, title: &str| {
2936 engine
2937 .create_entity(
2938 CreateEntityArgs {
2939 anchors: Vec::new(),
2940 mem: mem.to_string(),
2941 title: title.to_string(),
2942 entity_type: "doc".to_string(),
2943 sections: IndexMap::from_iter([(
2944 "body".to_string(),
2945 "seed".to_string(),
2946 )]),
2947 metadata: IndexMap::new(),
2948 relations: Vec::new(),
2949 dry_run: false,
2950 },
2951 actor,
2952 Some(&client),
2953 None,
2954 )
2955 .expect("entity creates")
2956 };
2957 let src_entity = mk_entity(&mut engine, "src", "Doc A");
2958 let tgt_entity = mk_entity(&mut engine, "tgt", "Doc B");
2959
2960 let outcome = engine
2961 .relate_entity(
2962 RelateEntityArgs {
2963 source: src_entity.id.clone(),
2964 expected_hash: Some(src_entity.content_hash.clone()),
2965 rel_type: "IMPLEMENTS".to_string(),
2966 target: tgt_entity.id.clone(),
2967 remove: false,
2968 description: None,
2969 dry_run: false,
2970 },
2971 actor,
2972 Some(&client),
2973 None,
2974 )
2975 .expect("same-domain edge uses the intra-schema vocabulary across versions");
2976 assert_eq!(outcome.rel_type, "IMPLEMENTS");
2977 }
2978
2979 #[test]
2980 fn cross_different_schema_unknown_rel_type_returns_invalid_rel_type() {
2981 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2985 let (actor, client) = cli_actor();
2986 let err = engine
2987 .relate_entity(
2988 RelateEntityArgs {
2989 source: src.id.clone(),
2990 expected_hash: Some(src.content_hash.clone()),
2991 rel_type: "IMPLEMENTS".to_string(),
2992 target: tgt.id.clone(),
2993 remove: false,
2994 description: None,
2995 dry_run: false,
2996 },
2997 actor,
2998 Some(&client),
2999 None,
3000 )
3001 .unwrap_err();
3002 match err {
3003 EngineError::Validation(
3004 crate::runtime_validator::ValidationError::InvalidRelationshipType {
3005 input,
3006 allowed,
3007 ..
3008 },
3009 ) => {
3010 assert_eq!(input, "IMPLEMENTS");
3011 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
3012 assert!(names.iter().any(|n| n == "ADDRESSES"));
3013 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
3014 }
3015 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
3016 }
3017 }
3018
3019 #[test]
3020 fn cross_different_schema_shape_violation_returns_invalid_rel_shape() {
3021 }
3038
3039 #[test]
3040 fn cross_different_schema_no_matching_entry_returns_edge_not_declared() {
3041 let tmp = TempDir::new().unwrap();
3044 let src_manifest = r#"name: src-cv
3045version: 0.1.0
3046description: source schema
3047when_to_use: tests
3048types:
3049 - doc
3050relationships:
3051 mode: strict
3052 definitions:
3053 - name: IMPLEMENTS
3054 description: intra-mem
3055 default_weight: 1.0
3056 - name: _default
3057 description: fallback
3058 default_weight: 1.0
3059cross_mem_relationships:
3060 - to_schema: tgt-cv
3061 definitions:
3062 - name: ADDRESSES
3063 description: outbound
3064 default_weight: 1.0
3065 source_types: [doc]
3066 target_types: [req]
3067community:
3068 resolution: 1.0
3069 seed: 42
3070"#;
3071 let other_manifest = r#"name: other-cv
3073version: 0.1.0
3074description: foreign schema
3075when_to_use: tests
3076types:
3077 - thing
3078relationships:
3079 mode: strict
3080 definitions:
3081 - name: _default
3082 description: fallback
3083 default_weight: 1.0
3084community:
3085 resolution: 1.0
3086 seed: 42
3087"#;
3088 let schemas_dir = tmp.path().join("schemas");
3089 std::fs::create_dir_all(&schemas_dir).unwrap();
3090 write_schema_files(
3091 &schemas_dir,
3092 "src-cv",
3093 src_manifest,
3094 &[("doc", &make_type_yaml("doc"))],
3095 );
3096 write_schema_files(
3097 &schemas_dir,
3098 "other-cv",
3099 other_manifest,
3100 &[("thing", &make_type_yaml("thing"))],
3101 );
3102 let src_dir = tmp.path().join("mem-src");
3103 let other_dir = tmp.path().join("mem-other");
3104 std::fs::create_dir_all(&src_dir).unwrap();
3105 std::fs::create_dir_all(&other_dir).unwrap();
3106
3107 let mut engine = Engine::from_mounts_with_schemas_dir(
3108 vec![
3109 (
3110 folder_mount_with_pin(
3111 "src",
3112 src_dir.clone(),
3113 SchemaRef::new("src-cv", semver::Version::new(0, 1, 0)),
3114 ),
3115 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
3116 ),
3117 (
3118 folder_mount_with_pin(
3119 "other",
3120 other_dir.clone(),
3121 SchemaRef::new("other-cv", semver::Version::new(0, 1, 0)),
3122 ),
3123 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
3124 ),
3125 ],
3126 Some(&schemas_dir),
3127 )
3128 .expect("engine constructs");
3129
3130 let mut settings = WorkspaceSettings::default();
3131 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
3132 links.insert("src".to_string(), CrossLinkValue::Wildcard);
3133 settings.cross_mem_links = links;
3134 engine.set_settings(settings);
3135
3136 let (actor, client) = cli_actor();
3137 let src_entity = engine
3138 .create_entity(
3139 CreateEntityArgs {
3140 anchors: Vec::new(),
3141 mem: "src".to_string(),
3142 title: "D".to_string(),
3143 entity_type: "doc".to_string(),
3144 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3145 metadata: IndexMap::new(),
3146 relations: Vec::new(),
3147 dry_run: false,
3148 },
3149 actor,
3150 Some(&client),
3151 None,
3152 )
3153 .unwrap();
3154 let other_entity = engine
3155 .create_entity(
3156 CreateEntityArgs {
3157 anchors: Vec::new(),
3158 mem: "other".to_string(),
3159 title: "T".to_string(),
3160 entity_type: "thing".to_string(),
3161 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3162 metadata: IndexMap::new(),
3163 relations: Vec::new(),
3164 dry_run: false,
3165 },
3166 actor,
3167 Some(&client),
3168 None,
3169 )
3170 .unwrap();
3171
3172 let err = engine
3173 .relate_entity(
3174 RelateEntityArgs {
3175 source: src_entity.id.clone(),
3176 expected_hash: Some(src_entity.content_hash.clone()),
3177 rel_type: "ADDRESSES".to_string(),
3178 target: other_entity.id.clone(),
3179 remove: false,
3180 description: None,
3181 dry_run: false,
3182 },
3183 actor,
3184 Some(&client),
3185 None,
3186 )
3187 .unwrap_err();
3188 match err {
3189 EngineError::CrossMemEdgeNotDeclared {
3190 source_schema,
3191 target_schema,
3192 rel_type,
3193 from_id,
3194 to_id,
3195 } => {
3196 assert_eq!(source_schema, "src-cv@0.1.0");
3197 assert_eq!(target_schema, "other-cv@0.1.0");
3198 assert_eq!(rel_type, "ADDRESSES");
3199 assert_eq!(from_id, src_entity.id.to_string());
3200 assert_eq!(to_id, other_entity.id.to_string());
3201 }
3202 other => panic!("expected CrossMemEdgeNotDeclared, got {other:?}"),
3203 }
3204 }
3205
3206 #[test]
3207 fn intra_mem_with_cross_mem_only_rel_type_returns_invalid_rel_type() {
3208 let (_tmp, mut engine, src, _tgt) = two_mem_engine();
3213 let (actor, client) = cli_actor();
3214 let intra_target = engine
3216 .create_entity(
3217 CreateEntityArgs {
3218 anchors: Vec::new(),
3219 mem: "src".to_string(),
3220 title: "Doc Two".to_string(),
3221 entity_type: "doc".to_string(),
3222 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3223 metadata: IndexMap::new(),
3224 relations: Vec::new(),
3225 dry_run: false,
3226 },
3227 actor,
3228 Some(&client),
3229 None,
3230 )
3231 .unwrap();
3232 let src_fresh = engine.get_entity(&src.id).unwrap();
3235 let err = engine
3236 .relate_entity(
3237 RelateEntityArgs {
3238 source: src.id.clone(),
3239 expected_hash: Some(src_fresh.content_hash.clone()),
3240 rel_type: "ADDRESSES".to_string(),
3241 target: intra_target.id.clone(),
3242 remove: false,
3243 description: None,
3244 dry_run: false,
3245 },
3246 actor,
3247 Some(&client),
3248 None,
3249 )
3250 .unwrap_err();
3251 match err {
3252 EngineError::Validation(
3253 crate::runtime_validator::ValidationError::InvalidRelationshipType {
3254 input,
3255 ..
3256 },
3257 ) => {
3258 assert_eq!(input, "ADDRESSES");
3259 }
3260 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
3261 }
3262 }
3263
3264 #[test]
3265 fn vocabulary_admissible_edge_blocked_by_policy_returns_cross_mem_link_not_allowed() {
3266 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3270 engine.set_settings(WorkspaceSettings::default());
3272 let (actor, client) = cli_actor();
3273 let err = engine
3274 .relate_entity(
3275 RelateEntityArgs {
3276 source: src.id.clone(),
3277 expected_hash: Some(src.content_hash.clone()),
3278 rel_type: "ADDRESSES".to_string(),
3279 target: tgt.id.clone(),
3280 remove: false,
3281 description: None,
3282 dry_run: false,
3283 },
3284 actor,
3285 Some(&client),
3286 None,
3287 )
3288 .unwrap_err();
3289 assert!(
3290 matches!(err, EngineError::CrossMemLinkNotAllowed { .. }),
3291 "expected CrossMemLinkNotAllowed, got {err:?}"
3292 );
3293 }
3294
3295 #[test]
3302 fn cross_mem_remove_bypasses_policy_after_revoke() {
3303 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3304 let (actor, client) = cli_actor();
3305
3306 let added = engine
3308 .relate_entity(
3309 RelateEntityArgs {
3310 source: src.id.clone(),
3311 expected_hash: Some(src.content_hash.clone()),
3312 rel_type: "ADDRESSES".to_string(),
3313 target: tgt.id.clone(),
3314 remove: false,
3315 description: None,
3316 dry_run: false,
3317 },
3318 actor,
3319 Some(&client),
3320 None,
3321 )
3322 .expect("declared cross-mem edge admits under grant");
3323 assert_eq!(added.action, RelateAction::Added);
3324
3325 engine.set_settings(WorkspaceSettings::default());
3327
3328 let add_err = engine
3331 .relate_entity(
3332 RelateEntityArgs {
3333 source: src.id.clone(),
3334 expected_hash: Some(added.content_hash.clone()),
3335 rel_type: "ADDRESSES".to_string(),
3336 target: tgt.id.clone(),
3337 remove: false,
3338 description: None,
3339 dry_run: false,
3340 },
3341 actor,
3342 Some(&client),
3343 None,
3344 )
3345 .unwrap_err();
3346 assert!(
3347 matches!(add_err, EngineError::CrossMemLinkNotAllowed { .. }),
3348 "add path must still refuse under denial, got {add_err:?}"
3349 );
3350
3351 let removed = engine
3354 .relate_entity(
3355 RelateEntityArgs {
3356 source: src.id.clone(),
3357 expected_hash: Some(added.content_hash.clone()),
3358 rel_type: "ADDRESSES".to_string(),
3359 target: tgt.id.clone(),
3360 remove: true,
3361 description: None,
3362 dry_run: false,
3363 },
3364 actor,
3365 Some(&client),
3366 None,
3367 )
3368 .expect("remove must bypass the policy gate post-revoke");
3369 assert_eq!(removed.action, RelateAction::Removed);
3370
3371 let outgoing = engine.store().outgoing(&src.id);
3373 assert!(
3374 !outgoing
3375 .iter()
3376 .any(|e| e.target == tgt.id && e.rel_type == "ADDRESSES"),
3377 "ADDRESSES edge must be gone after remove"
3378 );
3379 }
3380
3381 #[test]
3385 fn cross_mem_remove_of_absent_edge_under_denial_is_no_op() {
3386 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3387 engine.set_settings(WorkspaceSettings::default());
3389 let (actor, client) = cli_actor();
3390 let outcome = engine
3391 .relate_entity(
3392 RelateEntityArgs {
3393 source: src.id.clone(),
3394 expected_hash: Some(src.content_hash.clone()),
3395 rel_type: "ADDRESSES".to_string(),
3396 target: tgt.id.clone(),
3397 remove: true,
3398 description: None,
3399 dry_run: false,
3400 },
3401 actor,
3402 Some(&client),
3403 None,
3404 )
3405 .expect("absent-edge remove must not refuse on policy");
3406 assert!(
3407 matches!(outcome.action, RelateAction::NoOpAbsent),
3408 "expected NoOpAbsent, got {:?}",
3409 outcome.action
3410 );
3411 }
3412
3413 fn engine_with_tgt_capability(
3422 capability: MountCapability,
3423 ) -> (TempDir, Engine, CreateEntityOutcome) {
3424 let tmp = TempDir::new().unwrap();
3425
3426 let src_manifest = r#"name: src-al
3427version: 0.1.0
3428description: source schema with alias pointer
3429when_to_use: tests
3430types:
3431 - doc
3432relationships:
3433 mode: strict
3434 definitions:
3435 - name: ADDRESSES
3436 description: explicit cross-mem
3437 default_weight: 1.0
3438 - name: REFERENCES
3439 description: alias pointer
3440 default_weight: 1.0
3441 - name: _default
3442 description: fallback
3443 default_weight: 1.0
3444cross_mem_relationships:
3445 - to_schema: tgt-al
3446 definitions:
3447 - name: ADDRESSES
3448 description: explicit cross-mem
3449 default_weight: 1.0
3450 - name: REFERENCES
3451 description: alias-emitted cross-mem
3452 default_weight: 1.0
3453alias_target_rel_type: REFERENCES
3454community:
3455 resolution: 1.0
3456 seed: 42
3457"#;
3458 let tgt_manifest = r#"name: tgt-al
3459version: 0.1.0
3460description: target schema
3461when_to_use: tests
3462types:
3463 - req
3464relationships:
3465 mode: strict
3466 definitions:
3467 - name: _default
3468 description: fallback
3469 default_weight: 1.0
3470community:
3471 resolution: 1.0
3472 seed: 42
3473"#;
3474 let schemas_dir = tmp.path().join("schemas");
3475 std::fs::create_dir_all(&schemas_dir).unwrap();
3476 write_schema_files(
3477 &schemas_dir,
3478 "src-al",
3479 src_manifest,
3480 &[("doc", &make_type_yaml("doc"))],
3481 );
3482 write_schema_files(
3483 &schemas_dir,
3484 "tgt-al",
3485 tgt_manifest,
3486 &[("req", &make_type_yaml("req"))],
3487 );
3488
3489 let src_dir = tmp.path().join("mem-src");
3490 let tgt_dir = tmp.path().join("mem-tgt");
3491 std::fs::create_dir_all(&src_dir).unwrap();
3492 std::fs::create_dir_all(&tgt_dir).unwrap();
3493 std::fs::write(
3496 tgt_dir.join("req-one.md"),
3497 "---\ntype: req\n---\n# Req One\n\n## Body\n\nseed.\n",
3498 )
3499 .unwrap();
3500
3501 let src_writer = FilesystemMemWriter::new(src_dir.clone());
3502 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
3503 let src_pin = SchemaRef::new("src-al", semver::Version::new(0, 1, 0));
3504 let tgt_pin = SchemaRef::new("tgt-al", semver::Version::new(0, 1, 0));
3505
3506 let tgt_mount = Mount {
3507 mem: "tgt".to_string(),
3508 schema: Some(tgt_pin),
3509 storage: MountStorage::Folder {
3510 path: tgt_dir.clone(),
3511 },
3512 capability,
3513 lifecycle: MountLifecycle::Eager,
3514 cross_linkable: true,
3515 migration_target: None,
3516 };
3517 let mut engine = Engine::from_mounts_with_schemas_dir(
3518 vec![
3519 (
3520 folder_mount_with_pin("src", src_dir, src_pin),
3521 Box::new(src_writer) as Box<dyn MemBackend>,
3522 ),
3523 (tgt_mount, Box::new(tgt_writer) as Box<dyn MemBackend>),
3524 ],
3525 Some(&schemas_dir),
3526 )
3527 .expect("two-mem engine constructs");
3528
3529 let mut settings = WorkspaceSettings::default();
3530 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
3531 links.insert("src".to_string(), CrossLinkValue::Wildcard);
3532 settings.cross_mem_links = links;
3533 engine.set_settings(settings);
3534
3535 let (actor, client) = cli_actor();
3536 let src_entity = engine
3537 .create_entity(
3538 CreateEntityArgs {
3539 anchors: Vec::new(),
3540 mem: "src".to_string(),
3541 title: "Doc One".to_string(),
3542 entity_type: "doc".to_string(),
3543 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
3544 metadata: IndexMap::new(),
3545 relations: Vec::new(),
3546 dry_run: false,
3547 },
3548 actor,
3549 Some(&client),
3550 None,
3551 )
3552 .expect("source entity creates");
3553
3554 (tmp, engine, src_entity)
3555 }
3556
3557 fn assert_cross_mem_target_not_found(err: EngineError, expected_target: &str) {
3558 match err {
3559 EngineError::CrossMemTargetNotFound {
3560 target_id,
3561 target_mem,
3562 } => {
3563 assert_eq!(target_id, expected_target);
3564 assert_eq!(target_mem, "tgt");
3565 }
3566 other => panic!("expected CrossMemTargetNotFound, got {other:?}"),
3567 }
3568 }
3569
3570 #[test]
3575 fn relate_dry_run_to_missing_target_in_readonly_mem_refuses_identically() {
3576 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3577 let (actor, client) = cli_actor();
3578 let args = |dry_run: bool| RelateEntityArgs {
3579 source: src.id.clone(),
3580 expected_hash: Some(src.content_hash.clone()),
3581 rel_type: "ADDRESSES".to_string(),
3582 target: crate::EntityId::new("tgt", "missing"),
3583 remove: false,
3584 description: None,
3585 dry_run,
3586 };
3587 let rehearsed = engine
3588 .relate_entity(args(true), actor, Some(&client), None)
3589 .unwrap_err();
3590 let real = engine
3591 .relate_entity(args(false), actor, Some(&client), None)
3592 .unwrap_err();
3593 assert_eq!(format!("{rehearsed:?}"), format!("{real:?}"));
3594 assert_cross_mem_target_not_found(rehearsed, "tgt--missing");
3595 }
3596
3597 #[test]
3598 fn relate_to_missing_target_in_readonly_mem_refuses() {
3599 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3600 let (actor, client) = cli_actor();
3601 let err = engine
3602 .relate_entity(
3603 RelateEntityArgs {
3604 source: src.id.clone(),
3605 expected_hash: Some(src.content_hash.clone()),
3606 rel_type: "ADDRESSES".to_string(),
3607 target: crate::EntityId::new("tgt", "missing"),
3608 remove: false,
3609 description: None,
3610 dry_run: false,
3611 },
3612 actor,
3613 Some(&client),
3614 None,
3615 )
3616 .unwrap_err();
3617 assert_cross_mem_target_not_found(err, "tgt--missing");
3618 }
3619
3620 #[test]
3625 fn create_inline_relation_to_missing_target_in_readonly_mem_refuses() {
3626 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3627 let (actor, client) = cli_actor();
3628 let err = engine
3629 .create_entity(
3630 CreateEntityArgs {
3631 anchors: Vec::new(),
3632 mem: "src".to_string(),
3633 title: "Doc Two".to_string(),
3634 entity_type: "doc".to_string(),
3635 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3636 metadata: IndexMap::new(),
3637 relations: vec![crate::ops::RelateArg {
3638 target: crate::EntityId::new("tgt", "missing"),
3639 rel_type: "ADDRESSES".to_string(),
3640 description: None,
3641 }],
3642 dry_run: false,
3643 },
3644 actor,
3645 Some(&client),
3646 None,
3647 )
3648 .unwrap_err();
3649 assert_cross_mem_target_not_found(err, "tgt--missing");
3650 }
3651
3652 #[test]
3656 fn create_body_link_to_missing_target_in_readonly_mem_refuses() {
3657 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3658 let (actor, client) = cli_actor();
3659 let err = engine
3660 .create_entity(
3661 CreateEntityArgs {
3662 anchors: Vec::new(),
3663 mem: "src".to_string(),
3664 title: "Doc Three".to_string(),
3665 entity_type: "doc".to_string(),
3666 sections: IndexMap::from_iter([(
3667 "body".to_string(),
3668 "see [[tgt--missing]].".to_string(),
3669 )]),
3670 metadata: IndexMap::new(),
3671 relations: Vec::new(),
3672 dry_run: false,
3673 },
3674 actor,
3675 Some(&client),
3676 None,
3677 )
3678 .unwrap_err();
3679 assert_cross_mem_target_not_found(err, "tgt--missing");
3680 }
3681
3682 #[test]
3683 fn update_body_link_to_missing_target_in_readonly_mem_refuses() {
3684 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3685 let (actor, client) = cli_actor();
3686 let err = engine
3687 .update_entity(
3688 crate::engine::UpdateEntityArgs {
3689 anchors: Vec::new(),
3690 id: src.id.clone(),
3691 expected_hash: Some(src.content_hash.clone()),
3692 sections: IndexMap::from_iter([(
3693 "body".to_string(),
3694 "now see [[tgt--missing]].".to_string(),
3695 )]),
3696 append_sections: IndexMap::new(),
3697 patch_sections: IndexMap::new(),
3698 metadata: IndexMap::new(),
3699 metadata_unset: Vec::new(),
3700 declare_relations: Vec::new(),
3701 dry_run: false,
3702 relations_unset: Vec::new(),
3703 anchors_unset: Vec::new(),
3704 },
3705 actor,
3706 Some(&client),
3707 None,
3708 )
3709 .unwrap_err();
3710 assert_cross_mem_target_not_found(err, "tgt--missing");
3711 }
3712
3713 #[test]
3717 fn body_link_to_existing_target_in_readonly_mem_admits_and_emits_edge() {
3718 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3719 let (actor, client) = cli_actor();
3720 let created = engine
3721 .create_entity(
3722 CreateEntityArgs {
3723 anchors: Vec::new(),
3724 mem: "src".to_string(),
3725 title: "Doc Four".to_string(),
3726 entity_type: "doc".to_string(),
3727 sections: IndexMap::from_iter([(
3728 "body".to_string(),
3729 "see [[tgt--req-one]].".to_string(),
3730 )]),
3731 metadata: IndexMap::new(),
3732 relations: Vec::new(),
3733 dry_run: false,
3734 },
3735 actor,
3736 Some(&client),
3737 None,
3738 )
3739 .expect("body link to existing read-only target admits");
3740 let outgoing = engine.store().outgoing(&created.id);
3741 assert!(
3742 outgoing.iter().any(|e| e.rel_type == "REFERENCES"
3743 && e.target == crate::EntityId::new("tgt", "req-one")),
3744 "alias REFERENCES edge to the read-only target must materialise; got {outgoing:?}"
3745 );
3746 }
3747
3748 #[test]
3752 fn body_link_to_missing_target_in_write_mem_still_stubs() {
3753 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::Write);
3754 let (actor, client) = cli_actor();
3755 let created = engine
3756 .create_entity(
3757 CreateEntityArgs {
3758 anchors: Vec::new(),
3759 mem: "src".to_string(),
3760 title: "Doc Five".to_string(),
3761 entity_type: "doc".to_string(),
3762 sections: IndexMap::from_iter([(
3763 "body".to_string(),
3764 "see [[tgt--missing]].".to_string(),
3765 )]),
3766 metadata: IndexMap::new(),
3767 relations: Vec::new(),
3768 dry_run: false,
3769 },
3770 actor,
3771 Some(&client),
3772 None,
3773 )
3774 .expect("forward reference into a Write sibling mem keeps stubbing");
3775 assert!(
3776 engine
3777 .store()
3778 .contains(&crate::EntityId::new("tgt", "missing")),
3779 "auto-stub must land for the Write-mem forward reference"
3780 );
3781 let outgoing = engine.store().outgoing(&created.id);
3782 assert!(
3783 outgoing.iter().any(|e| e.rel_type == "REFERENCES"),
3784 "alias edge must still emit for the stubbed target"
3785 );
3786 }
3787 }
3788
3789 #[test]
3798 fn batch_relate_applies_adds_and_removes_in_order_one_commit() {
3799 let tmp = TempDir::new().unwrap();
3800 let mem_dir = tmp.path().to_path_buf();
3801 let writer = FilesystemMemWriter::new(mem_dir.clone());
3802 let mut engine = Engine::from_mounts(vec![(
3803 folder_mount("specs", mem_dir),
3804 Box::new(writer) as Box<dyn MemBackend>,
3805 )])
3806 .unwrap();
3807 let (actor, client) = cli_actor();
3808
3809 for title in ["A", "B", "C"] {
3810 engine
3811 .create_entity(
3812 empty_create_args("specs", title),
3813 actor,
3814 Some(&client),
3815 None,
3816 )
3817 .unwrap();
3818 }
3819 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
3820 let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
3821 source: id(from),
3822 expected_hash: None,
3823 rel_type: "USES".to_string(),
3824 target: id(to),
3825 remove,
3826 description: None,
3827 dry_run: false,
3828 };
3829
3830 let result = engine
3831 .batch_relate(
3832 vec![
3833 (edge("a", "b", false), Some("add a-b".to_string())),
3834 (edge("a", "c", false), Some("add a-c".to_string())),
3835 (edge("a", "c", true), Some("undo a-c".to_string())),
3836 ],
3837 actor,
3838 Some(&client),
3839 false,
3840 )
3841 .unwrap();
3842 assert!(result.applied, "{result:?}");
3843 assert_eq!(result.succeeded, 3);
3844 assert_eq!(result.failed, 0);
3845 assert!(!result.write_id.is_empty(), "one real commit");
3846 let actions: Vec<&str> = result.results.iter().map(|r| r.action.as_str()).collect();
3847 assert_eq!(
3848 actions,
3849 vec!["added", "added", "removed"],
3850 "in-order application: the remove sees the same batch's add"
3851 );
3852
3853 let a = engine.get_entity(&id("a")).unwrap();
3855 assert_eq!(a.relationships.len(), 1, "{:?}", a.relationships);
3856 assert_eq!(a.relationships[0].rel_type, "USES");
3857 assert_eq!(a.relationships[0].target, id("b"));
3858 }
3859
3860 #[test]
3868 fn relate_dry_run_reports_would_be_stub_and_writes_nothing() {
3869 let tmp = TempDir::new().unwrap();
3870 let (mut engine, source) = engine_with_seed(&tmp, "Src");
3871 engine.set_mutation_clock(std::sync::Arc::new(|| {
3877 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_000_000)
3878 }));
3879 let (actor, client) = cli_actor();
3880 let absent = crate::EntityId::new("specs", "ghost-target");
3881 let args = |dry_run: bool| RelateEntityArgs {
3882 source: source.id.clone(),
3883 expected_hash: Some(source.content_hash.clone()),
3884 rel_type: "USES".to_string(),
3885 target: absent.clone(),
3886 remove: false,
3887 description: None,
3888 dry_run,
3889 };
3890
3891 let rehearsed = engine
3892 .relate_entity(args(true), actor, Some(&client), None)
3893 .unwrap();
3894 assert_eq!(rehearsed.action, RelateAction::Added);
3895 assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
3896 assert!(
3897 rehearsed.warnings.iter().any(
3898 |w| matches!(w, crate::ops::WarningHint::AutoStubCreated { stub_id, pending: true } if *stub_id == absent)
3899 ),
3900 "would-be stub must be reported as pending: {:?}",
3901 rehearsed.warnings
3902 );
3903 let rehearsed_stub = rehearsed
3906 .warnings
3907 .iter()
3908 .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
3909 .unwrap();
3910 assert_eq!(rehearsed_stub.code(), "AUTO_STUB_CREATED");
3911 let msg = rehearsed_stub.message();
3912 assert!(
3913 msg.contains("would be auto-created") && !msg.contains("stub auto-created."),
3914 "dry-run wording must be conditional: {msg}"
3915 );
3916 assert!(
3917 !engine.store().contains(&absent),
3918 "would-be stub reported, never created"
3919 );
3920 let stored = engine.store().get(&source.id).unwrap();
3923 assert_eq!(stored.content_hash, source.content_hash);
3924 assert_ne!(rehearsed.content_hash, source.content_hash);
3925 assert!(stored.relationships.is_empty(), "no edge landed");
3926
3927 let real = engine
3930 .relate_entity(args(false), actor, Some(&client), None)
3931 .unwrap();
3932 assert!(!real.write_id.is_empty(), "the real relate commits");
3933 assert_eq!(
3934 real.content_hash, rehearsed.content_hash,
3935 "prospective hash must equal the real post-write hash"
3936 );
3937 assert!(engine.store().get(&absent).expect("real call stubs").stub);
3938 let real_msg = real
3940 .warnings
3941 .iter()
3942 .find(|w| {
3943 matches!(
3944 w,
3945 crate::ops::WarningHint::AutoStubCreated { pending: false, .. }
3946 )
3947 })
3948 .expect("real relate carries the non-pending stub warning")
3949 .message();
3950 assert!(
3951 real_msg.contains("did not exist — stub auto-created."),
3952 "real wording unchanged: {real_msg}"
3953 );
3954 }
3955
3956 #[test]
3960 fn relate_dry_run_refuses_identically_to_real() {
3961 let tmp = TempDir::new().unwrap();
3962 let (mut engine, source) = engine_with_seed(&tmp, "Src");
3963 let (actor, client) = cli_actor();
3964 let args = |dry_run: bool| RelateEntityArgs {
3966 source: source.id.clone(),
3967 expected_hash: None,
3968 rel_type: "USES".to_string(),
3969 target: crate::EntityId("bad target with spaces".to_string()),
3970 remove: false,
3971 description: None,
3972 dry_run,
3973 };
3974 let rehearsed = engine
3975 .relate_entity(args(true), actor, Some(&client), None)
3976 .unwrap_err();
3977 let real = engine
3978 .relate_entity(args(false), actor, Some(&client), None)
3979 .unwrap_err();
3980 assert_eq!(
3981 format!("{rehearsed:?}"),
3982 format!("{real:?}"),
3983 "identical typed refusal"
3984 );
3985 assert_eq!(rehearsed.code(), real.code());
3986 }
3987
3988 #[test]
3995 fn batch_relate_dry_run_reports_receipt_and_commits_nothing() {
3996 let tmp = TempDir::new().unwrap();
3997 let mem_dir = tmp.path().to_path_buf();
3998 let writer = FilesystemMemWriter::new(mem_dir.clone());
3999 let mut engine = Engine::from_mounts(vec![(
4000 folder_mount("specs", mem_dir),
4001 Box::new(writer) as Box<dyn MemBackend>,
4002 )])
4003 .unwrap();
4004 let (actor, client) = cli_actor();
4005 for title in ["A", "B"] {
4006 engine
4007 .create_entity(
4008 empty_create_args("specs", title),
4009 actor,
4010 Some(&client),
4011 None,
4012 )
4013 .unwrap();
4014 }
4015 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4016 let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
4017 source: id(from),
4018 expected_hash: None,
4019 rel_type: "USES".to_string(),
4020 target: id(to),
4021 remove,
4022 description: None,
4023 dry_run: false,
4024 };
4025 let batch = || {
4026 vec![
4027 (edge("a", "b", false), None),
4028 (edge("a", "ghost", false), None), (edge("a", "b", true), None), ]
4031 };
4032 let head_before = engine
4033 .mem_head_sha("specs")
4034 .ok()
4035 .flatten()
4036 .unwrap_or_default();
4037
4038 let rehearsed = engine
4039 .batch_relate(batch(), actor, Some(&client), true)
4040 .unwrap();
4041 assert!(rehearsed.applied, "{rehearsed:?}");
4042 assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
4043 let actions: Vec<&str> = rehearsed
4044 .results
4045 .iter()
4046 .map(|r| r.action.as_str())
4047 .collect();
4048 assert_eq!(
4049 actions,
4050 vec!["added", "added", "removed"],
4051 "in-order rehearsal semantics"
4052 );
4053 assert!(!engine.store().contains(&id("ghost")), "no stub created");
4055 assert!(
4056 engine
4057 .get_entity(&id("a"))
4058 .unwrap()
4059 .relationships
4060 .is_empty(),
4061 "no edge landed"
4062 );
4063 let head_after = engine
4064 .mem_head_sha("specs")
4065 .ok()
4066 .flatten()
4067 .unwrap_or_default();
4068 assert_eq!(head_before, head_after, "no commit landed");
4069
4070 let real = engine
4072 .batch_relate(batch(), actor, Some(&client), false)
4073 .unwrap();
4074 assert!(real.applied, "{real:?}");
4075 assert!(!real.write_id.is_empty());
4076 }
4077
4078 #[test]
4082 fn batch_relate_dry_run_refuses_identically_to_real() {
4083 let tmp = TempDir::new().unwrap();
4084 let mem_dir = tmp.path().to_path_buf();
4085 let writer = FilesystemMemWriter::new(mem_dir.clone());
4086 let mut engine = Engine::from_mounts(vec![(
4087 folder_mount("specs", mem_dir),
4088 Box::new(writer) as Box<dyn MemBackend>,
4089 )])
4090 .unwrap();
4091 let (actor, client) = cli_actor();
4092 for title in ["A", "B"] {
4093 engine
4094 .create_entity(
4095 empty_create_args("specs", title),
4096 actor,
4097 Some(&client),
4098 None,
4099 )
4100 .unwrap();
4101 }
4102 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4103 let batch = || {
4104 vec![
4105 (
4106 RelateEntityArgs {
4107 source: id("a"),
4108 expected_hash: None,
4109 rel_type: "USES".to_string(),
4110 target: id("b"),
4111 remove: false,
4112 description: None,
4113 dry_run: false,
4114 },
4115 None,
4116 ),
4117 (
4118 RelateEntityArgs {
4119 source: id("a"),
4120 expected_hash: None,
4121 rel_type: "USES".to_string(),
4122 target: crate::EntityId("bad target".to_string()),
4123 remove: false,
4124 description: None,
4125 dry_run: false,
4126 },
4127 None,
4128 ),
4129 ]
4130 };
4131 let rehearsed = engine
4132 .batch_relate(batch(), actor, Some(&client), true)
4133 .unwrap();
4134 let real = engine
4135 .batch_relate(batch(), actor, Some(&client), false)
4136 .unwrap();
4137 assert!(!rehearsed.applied && !real.applied);
4138 let envelope = |r: &crate::ops::BatchResult| {
4139 r.results
4140 .iter()
4141 .map(|e| {
4142 (
4143 e.id.to_string(),
4144 e.action.clone(),
4145 e.error.as_ref().map(|err| {
4146 (err.code.clone(), err.message.clone(), err.details.clone())
4147 }),
4148 )
4149 })
4150 .collect::<Vec<_>>()
4151 };
4152 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
4153 assert!(
4154 engine
4155 .get_entity(&id("a"))
4156 .unwrap()
4157 .relationships
4158 .is_empty(),
4159 "neither run landed the valid entry"
4160 );
4161 }
4162
4163 #[test]
4168 fn batch_relate_refuses_whole_batch_reporting_every_failure() {
4169 let tmp = TempDir::new().unwrap();
4170 let mem_dir = tmp.path().to_path_buf();
4171 let writer = FilesystemMemWriter::new(mem_dir.clone());
4172 let mut engine = Engine::from_mounts(vec![(
4173 folder_mount("specs", mem_dir),
4174 Box::new(writer) as Box<dyn MemBackend>,
4175 )])
4176 .unwrap();
4177 let (actor, client) = cli_actor();
4178
4179 let a = engine
4180 .create_entity(empty_create_args("specs", "A"), actor, Some(&client), None)
4181 .unwrap();
4182 engine
4183 .create_entity(empty_create_args("specs", "B"), actor, Some(&client), None)
4184 .unwrap();
4185 let head_before = engine
4186 .mem_head_sha("specs")
4187 .ok()
4188 .flatten()
4189 .unwrap_or_default();
4190 let count_before = engine.store().all_entities().count();
4191
4192 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4193 let result = engine
4194 .batch_relate(
4195 vec![
4196 (
4198 RelateEntityArgs {
4199 source: id("a"),
4200 expected_hash: None,
4201 rel_type: "USES".to_string(),
4202 target: id("b"),
4203 remove: false,
4204 description: None,
4205 dry_run: false,
4206 },
4207 None,
4208 ),
4209 (
4211 RelateEntityArgs {
4212 source: id("ghost"),
4213 expected_hash: None,
4214 rel_type: "USES".to_string(),
4215 target: id("b"),
4216 remove: false,
4217 description: None,
4218 dry_run: false,
4219 },
4220 None,
4221 ),
4222 (
4224 RelateEntityArgs {
4225 source: id("b"),
4226 expected_hash: Some("definitely-wrong".to_string()),
4227 rel_type: "USES".to_string(),
4228 target: id("a"),
4229 remove: false,
4230 description: None,
4231 dry_run: false,
4232 },
4233 None,
4234 ),
4235 ],
4236 actor,
4237 Some(&client),
4238 false,
4239 )
4240 .unwrap();
4241 assert!(!result.applied);
4242 assert_eq!(result.failed, 2, "{result:?}");
4243 assert!(result.write_id.is_empty());
4244 let codes: Vec<(usize, &str)> = result
4245 .results
4246 .iter()
4247 .enumerate()
4248 .filter(|(_, r)| r.action == "error")
4249 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
4250 .collect();
4251 assert_eq!(
4252 codes,
4253 vec![(1, "ENTITY_NOT_FOUND"), (2, "HASH_MISMATCH")],
4254 "every failing entry named with index + typed code: {result:?}"
4255 );
4256 assert_eq!(result.results[0].action, "not_applied");
4257
4258 let a_after = engine.get_entity(&a.id).unwrap();
4261 assert!(
4262 a_after.relationships.is_empty(),
4263 "staged edge must roll back: {:?}",
4264 a_after.relationships
4265 );
4266 assert_eq!(a_after.content_hash, a.content_hash);
4267 let head_after = engine
4268 .mem_head_sha("specs")
4269 .ok()
4270 .flatten()
4271 .unwrap_or_default();
4272 assert_eq!(head_before, head_after, "mem head unmoved");
4273 assert_eq!(engine.store().all_entities().count(), count_before);
4274 }
4275}
4276
4277#[cfg(test)]
4278mod derivation_tests {
4279 use indexmap::IndexMap;
4280 use tempfile::TempDir;
4281
4282 use crate::backend::MemBackend;
4283 use crate::engine::{
4284 CreateEntityArgs, Engine, RelateAction, RelateEntityArgs, UpdateEntityArgs,
4285 };
4286 use crate::ops::WarningHint;
4287 use crate::storage::FilesystemMemWriter;
4288 use crate::vcs::Actor;
4289 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4290
4291 fn deriv_schema() -> std::sync::Arc<memstead_schema::Schema> {
4295 let manifest = r#"name: deriv
4296version: 0.1.0
4297description: derivation fixture
4298when_to_use: tests
4299types:
4300 - note
4301relationships:
4302 mode: strict
4303 definitions:
4304 - name: PART_OF
4305 description: hier
4306 default_weight: 3.0
4307 - name: DERIVED_FROM
4308 description: source derives from target
4309 default_weight: 2.0
4310 derivation: true
4311 - name: SUPPORTS
4312 description: plain edge
4313 default_weight: 1.0
4314 - name: _default
4315 description: fallback
4316 default_weight: 1.0
4317community:
4318 resolution: 1.0
4319 seed: 42
4320"#;
4321 let type_yaml = r#"name: note
4322description: t
4323when_to_use: tests
4324sections:
4325 - key: body
4326 heading: Body
4327 required: true
4328 search_weight: 10.0
4329 catch_all: true
4330 write_rules: []
4331metadata_fields: []
4332title_weight: 100.0
4333text_fields:
4334 - body
4335hierarchy_relationship: PART_OF
4336no_self_loop_relationships: []
4337updatable_fields:
4338 - title
4339 - body
4340health_required_fields:
4341 - body
4342staleness_threshold_days: 90
4343write_rules: []
4344"#;
4345 std::sync::Arc::new(
4346 memstead_schema::loader::load_schema_from_memory(
4347 manifest,
4348 &[("note".to_string(), type_yaml.to_string())],
4349 )
4350 .expect("fixture schema loads"),
4351 )
4352 }
4353
4354 fn engine_at(tmp: &TempDir) -> Engine {
4355 let mem_dir = tmp.path().to_path_buf();
4356 let writer = FilesystemMemWriter::new(mem_dir.clone());
4357 let mount = Mount {
4358 mem: "m".to_string(),
4359 schema: Some("deriv@0.1.0".parse().unwrap()),
4360 storage: MountStorage::Folder { path: mem_dir },
4361 capability: MountCapability::Write,
4362 lifecycle: MountLifecycle::Eager,
4363 cross_linkable: true,
4364 migration_target: None,
4365 };
4366 Engine::from_mounts_with_schemas_dir_and_extra(
4367 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
4368 None,
4369 vec![deriv_schema()],
4370 )
4371 .unwrap()
4372 }
4373
4374 fn note(title: &str, body: &str) -> CreateEntityArgs {
4375 CreateEntityArgs {
4376 anchors: Vec::new(),
4377 mem: "m".to_string(),
4378 title: title.to_string(),
4379 entity_type: "note".to_string(),
4380 sections: IndexMap::from_iter([("body".to_string(), body.to_string())]),
4381 metadata: IndexMap::new(),
4382 relations: Vec::new(),
4383 dry_run: false,
4384 }
4385 }
4386
4387 fn relate_args(from: &crate::EntityId, rel: &str, to: &crate::EntityId) -> RelateEntityArgs {
4388 RelateEntityArgs {
4389 source: from.clone(),
4390 expected_hash: None,
4391 rel_type: rel.to_string(),
4392 target: to.clone(),
4393 remove: false,
4394 description: None,
4395 dry_run: false,
4396 }
4397 }
4398
4399 #[test]
4404 fn derivation_write_edit_report_reassert_clear() {
4405 let tmp = TempDir::new().unwrap();
4406 let mut engine = engine_at(&tmp);
4407 let target = engine
4408 .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4409 .unwrap();
4410 let source = engine
4411 .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4412 .unwrap();
4413
4414 let added = engine
4416 .relate_entity(
4417 relate_args(&source.id, "DERIVED_FROM", &target.id),
4418 Actor::Cli,
4419 None,
4420 None,
4421 )
4422 .unwrap();
4423 assert_eq!(added.action, RelateAction::Added);
4424 assert!(
4425 engine.derivation_report("m").unwrap().is_empty(),
4426 "freshly baselined edge must not report"
4427 );
4428
4429 let t_now = engine.get_entity(&target.id).unwrap().content_hash.clone();
4431 engine
4432 .update_entity(
4433 UpdateEntityArgs {
4434 anchors: Vec::new(),
4435 id: target.id.clone(),
4436 expected_hash: Some(t_now),
4437 sections: IndexMap::from_iter([("body".to_string(), "v2".to_string())]),
4438 append_sections: IndexMap::new(),
4439 patch_sections: IndexMap::new(),
4440 metadata: IndexMap::new(),
4441 metadata_unset: Vec::new(),
4442 declare_relations: Vec::new(),
4443 dry_run: false,
4444 relations_unset: Vec::new(),
4445 anchors_unset: Vec::new(),
4446 },
4447 Actor::Cli,
4448 None,
4449 None,
4450 )
4451 .unwrap();
4452 let report = engine.derivation_report("m").unwrap();
4453 assert_eq!(report.len(), 1, "{report:?}");
4454 assert_eq!(report[0].source, source.id);
4455 assert_eq!(report[0].rel_type, "DERIVED_FROM");
4456 assert_eq!(report[0].target, target.id);
4457 assert_eq!(report[0].state, "stale");
4458 assert!(report[0].baseline.is_some());
4459
4460 let s_now = engine.get_entity(&source.id).unwrap().content_hash.clone();
4465 engine
4466 .update_entity(
4467 UpdateEntityArgs {
4468 anchors: Vec::new(),
4469 id: source.id.clone(),
4470 expected_hash: Some(s_now),
4471 sections: IndexMap::from_iter([(
4472 "body".to_string(),
4473 "conclusion v2".to_string(),
4474 )]),
4475 append_sections: IndexMap::new(),
4476 patch_sections: IndexMap::new(),
4477 metadata: IndexMap::new(),
4478 metadata_unset: Vec::new(),
4479 declare_relations: Vec::new(),
4480 dry_run: false,
4481 relations_unset: Vec::new(),
4482 anchors_unset: Vec::new(),
4483 },
4484 Actor::Cli,
4485 None,
4486 None,
4487 )
4488 .unwrap();
4489 let report = engine.derivation_report("m").unwrap();
4490 assert_eq!(report.len(), 1, "source edit adds nothing: {report:?}");
4491 assert_eq!(report[0].state, "stale");
4492
4493 let md_before = std::fs::read(tmp.path().join("source.md")).unwrap();
4497 let hash_before = engine.get_entity(&source.id).unwrap().content_hash.clone();
4498 let refreshed = engine
4499 .relate_entity(
4500 relate_args(&source.id, "DERIVED_FROM", &target.id),
4501 Actor::Cli,
4502 None,
4503 None,
4504 )
4505 .unwrap();
4506 assert_eq!(refreshed.action, RelateAction::NoOpAlreadyPresent);
4507 assert_eq!(refreshed.content_hash, hash_before, "_hash unchanged");
4508 assert!(
4509 !refreshed.write_id.is_empty(),
4510 "the sidecar refresh persists via a real commit"
4511 );
4512 assert!(
4513 refreshed
4514 .warnings
4515 .iter()
4516 .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4517 "the refresh is STATED, never a bare no-op: {:?}",
4518 refreshed.warnings
4519 );
4520 let md_after = std::fs::read(tmp.path().join("source.md")).unwrap();
4521 assert_eq!(md_before, md_after, "baselines never touch the markdown");
4522 assert!(
4523 engine.derivation_report("m").unwrap().is_empty(),
4524 "re-assert clears the staleness"
4525 );
4526
4527 engine
4530 .relate_entity(
4531 relate_args(&source.id, "SUPPORTS", &target.id),
4532 Actor::Cli,
4533 None,
4534 None,
4535 )
4536 .unwrap();
4537 let noop = engine
4538 .relate_entity(
4539 relate_args(&source.id, "SUPPORTS", &target.id),
4540 Actor::Cli,
4541 None,
4542 None,
4543 )
4544 .unwrap();
4545 assert_eq!(noop.action, RelateAction::NoOpAlreadyPresent);
4546 assert!(noop.write_id.is_empty(), "undeclared no-op stays bare");
4547 assert!(
4548 !noop
4549 .warnings
4550 .iter()
4551 .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4552 );
4553 assert!(engine.derivation_report("m").unwrap().is_empty());
4555 }
4556
4557 #[test]
4562 fn missing_baseline_reports_unbaselined_never_fabricated() {
4563 let tmp = TempDir::new().unwrap();
4564 let mut engine = engine_at(&tmp);
4565 let target = engine
4566 .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4567 .unwrap();
4568 let source = engine
4569 .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4570 .unwrap();
4571 engine
4572 .relate_entity(
4573 relate_args(&source.id, "DERIVED_FROM", &target.id),
4574 Actor::Cli,
4575 None,
4576 None,
4577 )
4578 .unwrap();
4579 let sidecar = tmp.path().join(".memstead").join("derivations.json");
4581 assert!(sidecar.exists(), "baseline persisted to the sidecar");
4582 assert!(
4583 engine
4584 .get_entity(&crate::EntityId::new("m", "derivations"))
4585 .is_none()
4586 );
4587
4588 std::fs::remove_file(&sidecar).unwrap();
4591 let report = engine.derivation_report("m").unwrap();
4592 assert_eq!(report.len(), 1, "{report:?}");
4593 assert_eq!(report[0].state, "unbaselined");
4594 assert!(report[0].baseline.is_none());
4595 }
4596}