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 mut args = args;
93 let (source, source_hint) = self.resolve_entity_id(&args.source)?;
94 args.source = source;
95 let (target, target_hint) = self.resolve_entity_id(&args.target)?;
96 args.target = target;
97 let source_mem = args.source.mem().to_string();
98 let target_mem = args.target.mem().to_string();
99
100 let mut drift_warnings: Vec<WarningHint> =
108 source_hint.into_iter().chain(target_hint).collect();
109 drift_warnings.extend(self.reload_if_stale(Some(&source_mem)));
110 if target_mem != source_mem && !self.mem_is_deferred(&target_mem) {
111 drift_warnings.append(&mut self.reload_if_stale(Some(&target_mem)));
118 }
119 let cycle_guard_needs_full = !args.remove
134 && self.schemas.get(&source_mem).is_some_and(|s| {
135 s.relationship_acyclic(&args.rel_type)
136 || s.acyclic_set_containing(&args.rel_type).is_some()
137 });
138 let signal_diff_needs_full = [source_mem.as_str(), args.target.mem()].iter().any(|m| {
139 self.schemas
140 .get(*m)
141 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
142 });
143 if cycle_guard_needs_full || signal_diff_needs_full {
144 self.ensure_mems_loaded(None);
145 }
146
147 let dry_run = args.dry_run;
148 let prepared = match self.prepare_relate(args, drift_warnings)? {
149 RelatePrepareOutcome::Done(outcome) => {
150 if !dry_run
161 && matches!(
162 outcome.action,
163 super::super::RelateAction::NoOpAlreadyPresent
164 )
165 {
166 return self.refresh_derivation_baseline_on_noop(outcome, actor, client, note);
167 }
168 return Ok(outcome);
169 }
170 RelatePrepareOutcome::Prepared(p) => p,
171 };
172
173 if dry_run {
180 let parse_result = parse_markdown(
181 &prepared.markdown,
182 &prepared.file_path,
183 prepared.type_def.as_ref(),
184 &prepared.source_mem,
185 )
186 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
187 let mut warnings = prepared.warnings;
191 if let Some(w) = self.note_missing_warning("relate_entity", note) {
192 warnings.push(w);
193 }
194 return Ok(RelateEntityOutcome {
195 from: prepared.from,
196 to: prepared.to,
197 rel_type: prepared.rel_type,
198 action: prepared.action,
199 content_hash: parse_result.entity.content_hash,
200 write_id: String::new(),
201 source: "explicit".to_string(),
202 orphan_stubs_removed: Vec::new(),
203 warnings,
204 });
205 }
206
207 let signal_snapshot = crate::ops::signals::snapshot_levels(
211 &self.store,
212 &self.schemas,
213 [&prepared.from, &prepared.to],
214 );
215
216 self.stage_prepared_relate(&prepared)?;
217
218 if let Some(schema) = self.schemas.get(&prepared.source_mem)
224 && super::rel_type_declares_derivation(schema, &prepared.rel_type)
225 {
226 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
227 let (from, rel, to) = (
228 prepared.from.to_string(),
229 prepared.rel_type.clone(),
230 prepared.to.to_string(),
231 );
232 match prepared.action {
233 RelateAction::Added => {
234 let hash = self
235 .store
236 .get(&prepared.to)
237 .map(|e| e.content_hash.clone())
238 .unwrap_or_default();
239 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
240 }
241 RelateAction::Removed => {
242 super::stage_derivation_sidecar(backend, |s| s.remove(&from, &rel, &to))?;
243 }
244 _ => {}
245 }
246 }
247
248 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
249 let commit_subject = format!("memstead: relate {}", prepared.from);
250 let ctx = CommitContext {
251 actor,
252 client: client.cloned(),
253 tool: Some("relate_entity"),
254 note: note.map(String::from),
255 role: self.current_role,
256 identity: self.current_identity.clone(),
257 logical_operation_id: None,
258 entity_ids: None,
259 };
260 let write_id = backend.commit(&commit_subject, &ctx)?;
261
262 backend.append_provenance(
263 &Provenance::new(
264 std::time::SystemTime::now(),
265 ProvenanceKind::Relate,
266 Some(prepared.from.to_string()),
267 actor,
268 client.cloned(),
269 note.map(String::from),
270 )
271 .with_role(self.current_role)
272 .with_identity(self.current_identity.clone()),
273 )?;
274
275 self.record_self_write(prepared.mount_idx, &write_id);
276 let stamp_warnings = self.stamp_mutation_versions(prepared.mount_idx);
277
278 let content_hash = self.apply_prepared_relate_to_store(&prepared)?;
279
280 let orphan_stubs_removed: Vec<EntityId> =
294 if matches!(prepared.action, RelateAction::Removed) {
295 super::gc_orphan_stubs_among(&mut self.store, std::iter::once(&prepared.to))
296 } else {
297 Vec::new()
298 };
299
300 self.invalidate_communities();
301 self.maintain_search_indexes(std::slice::from_ref(&prepared.from));
305
306 let PreparedRelate {
307 from,
308 to,
309 rel_type,
310 action,
311 mut warnings,
312 ..
313 } = prepared;
314
315 warnings.extend(stamp_warnings);
316
317 warnings.extend(crate::ops::signals::crossing_warnings(
321 &self.store,
322 &self.schemas,
323 &signal_snapshot,
324 ));
325
326 if let Some(w) = self.note_missing_warning("relate_entity", note) {
332 warnings.push(w);
333 }
334
335 Ok(RelateEntityOutcome {
336 from,
337 to,
338 rel_type,
339 action,
340 content_hash,
341 write_id,
342 source: "explicit".to_string(),
343 warnings,
344 orphan_stubs_removed,
345 })
346 }
347
348 fn refresh_derivation_baseline_on_noop(
359 &mut self,
360 mut outcome: RelateEntityOutcome,
361 actor: Actor,
362 client: Option<&ClientId>,
363 note: Option<&str>,
364 ) -> Result<RelateEntityOutcome, EngineError> {
365 let source_mem = outcome.from.mem().to_string();
366 let declared = self
367 .schemas
368 .get(&source_mem)
369 .is_some_and(|s| super::rel_type_declares_derivation(s, &outcome.rel_type));
370 if !declared {
371 return Ok(outcome);
372 }
373 let Some(mount_idx) = self.mounts.iter().position(|m| m.mount.mem == source_mem) else {
374 return Ok(outcome);
375 };
376 let hash = self
377 .store
378 .get(&outcome.to)
379 .map(|e| e.content_hash.clone())
380 .unwrap_or_default();
381 let (from, rel, to) = (
382 outcome.from.to_string(),
383 outcome.rel_type.clone(),
384 outcome.to.to_string(),
385 );
386 let backend = self.mounts[mount_idx].backend.as_ref();
387 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
388 let ctx = CommitContext {
389 actor,
390 client: client.cloned(),
391 tool: Some("relate_entity"),
392 note: note.map(String::from),
393 role: self.current_role,
394 identity: self.current_identity.clone(),
395 logical_operation_id: None,
396 entity_ids: None,
397 };
398 let write_id = backend.commit(
399 &format!("memstead: derivation re-baseline {}", outcome.from),
400 &ctx,
401 )?;
402 self.record_self_write(mount_idx, &write_id);
403 outcome
407 .warnings
408 .extend(self.stamp_mutation_versions(mount_idx));
409 outcome.write_id = write_id;
410 outcome
411 .warnings
412 .push(WarningHint::DerivationBaselineRefreshed {
413 from: outcome.from.clone(),
414 rel_type: outcome.rel_type.clone(),
415 to: outcome.to.clone(),
416 });
417 Ok(outcome)
418 }
419
420 fn prepare_relate(
428 &mut self,
429 args: RelateEntityArgs,
430 mut drift_warnings: Vec<WarningHint>,
431 ) -> Result<RelatePrepareOutcome, EngineError> {
432 let mut args = args;
433 let source_mem = args.source.mem().to_string();
434 let target_mem = args.target.mem().to_string();
435
436 validate_relation_target_grammar(&args.target)?;
452
453 let mut target_mem_uncreated = false;
459 if source_mem != target_mem {
460 if !args.remove {
471 super::validate_cross_mem_add_policy(self, &source_mem, &args.target)?;
472 }
473 if let Some(mount) = self.mount(&target_mem)
481 && mount.capability == MountCapability::ReadOnly
482 && !self.store.contains(&args.target)
483 && !matches!(
484 super::probe_deferred_target(self, &args.target)?,
485 super::DeferredTargetProbe::Exists
486 )
487 {
488 return Err(EngineError::CrossMemTargetNotFound {
489 target_id: args.target.to_string(),
490 target_mem: target_mem.clone(),
491 });
492 }
493 if self.mount(&target_mem).is_none() {
501 target_mem_uncreated = true;
502 }
503 }
504
505 if let Ok(canonical) = crate::entity::id::validate_rel_type(&args.rel_type) {
512 args.rel_type = canonical;
513 }
514 args.description = normalise_description(args.description.as_deref());
518
519 let mount_idx = self
520 .mounts
521 .iter()
522 .position(|m| m.mount.mem == source_mem)
523 .ok_or_else(|| self.unknown_mem_error(&source_mem))?;
524 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
525 return Err(EngineError::ReadOnlyMount(source_mem));
526 }
527
528 let schema = self
529 .schemas
530 .get(&source_mem)
531 .expect("schema present for every registered mount");
532
533 let target_schema_ref: Option<SchemaRef> = if source_mem == target_mem {
547 None
548 } else {
549 super::target_schema_ref_for_routing(self, &target_mem)
554 };
555 let cross_mem_different = match (&target_schema_ref, schema.id()) {
556 (Some(target), (src_name, _)) => target.name != src_name,
557 (None, _) => false,
558 };
559
560 let mut warnings: Vec<WarningHint> = Vec::new();
561 warnings.append(&mut drift_warnings);
563 if !cross_mem_different {
568 match validate_rel_type(&args.rel_type, schema.as_ref())? {
569 RelationshipCheck::Ok => {}
570 RelationshipCheck::OpenWarning(message) => {
571 warnings.push(WarningHint::UndeclaredRelationshipOpen {
572 rel_type: args.rel_type.clone(),
573 message,
574 });
575 }
576 }
577 }
578
579 let entity: Entity = self
583 .store
584 .get(&args.source)
585 .ok_or_else(|| EngineError::NotFound {
586 id: args.source.to_string(),
587 })?
588 .clone();
589
590 if entity.stub {
598 return Err(EngineError::StubCannotRelate {
599 id: args.source.to_string(),
600 });
601 }
602
603 let target_type = self
604 .store
605 .get(&args.target)
606 .map(|e| e.entity_type.clone())
607 .filter(|t| !t.is_empty());
608 let target_verified_in_storage = if target_type.is_none() && target_mem != source_mem {
614 matches!(
615 super::probe_deferred_target(self, &args.target)?,
616 super::DeferredTargetProbe::Exists
617 )
618 } else {
619 false
620 };
621 let target_type = match target_type {
622 Some(t) => Some(t),
623 None if target_verified_in_storage && !args.remove => {
624 super::peek_deferred_target_type(self, &args.target)?
625 }
626 None => None,
627 };
628 if target_verified_in_storage {
629 target_mem_uncreated = false;
634 }
635 if !args.remove {
650 validate_description_posture(
651 self,
652 &args.rel_type,
653 args.description.as_deref(),
654 &source_mem,
655 &target_mem,
656 &args.source,
657 &args.target,
658 )?;
659 super::validate_manual_authoring_posture(
667 self,
668 &args.rel_type,
669 &source_mem,
670 &args.source,
671 &args.target,
672 )?;
673 }
674
675 if !args.remove {
676 if cross_mem_different {
677 let target_ref = target_schema_ref
680 .as_ref()
681 .expect("target_schema_ref is Some when cross_mem_different");
682 match validate_cross_mem_edge(
683 &args.rel_type,
684 entity.entity_type.as_str(),
685 target_type.as_deref(),
686 schema.as_ref(),
687 target_ref,
688 ) {
689 CrossMemRelCheck::Ok => {}
690 CrossMemRelCheck::EdgeNotDeclared => {
691 let (src_name, src_version) = schema.id();
692 return Err(EngineError::CrossMemEdgeNotDeclared {
693 source_schema: SchemaRef::new(src_name, src_version).as_display(),
694 target_schema: target_ref.as_display(),
695 rel_type: args.rel_type.clone(),
696 from_id: args.source.to_string(),
697 to_id: args.target.to_string(),
698 });
699 }
700 CrossMemRelCheck::Invalid(v) => {
701 return Err(EngineError::Validation(v));
702 }
703 }
704 } else {
705 validate_rel_shape(
706 &args.rel_type,
707 entity.entity_type.as_str(),
708 target_type.as_deref(),
709 schema.as_ref(),
710 )?;
711 }
712 }
713
714 if let Some(expected) = args.expected_hash.as_deref()
715 && entity.content_hash != expected
716 {
717 return Err(EngineError::HashMismatch {
718 id: args.source.to_string(),
719 current: entity.content_hash.clone(),
720 is_stub: entity.stub,
721 });
722 }
723
724 if !args.remove {
730 super::validate_edge_acyclicity(
731 &self.store,
732 schema,
733 &args.source,
734 entity.entity_type.as_str(),
735 &args.target,
736 &args.rel_type,
737 )?;
738 }
739
740 let type_def = schema
741 .get_type(&entity.entity_type)
742 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
743
744 let mut next = entity.clone();
745 let already = next
746 .relationships
747 .iter()
748 .position(|r| r.rel_type == args.rel_type && r.target == args.target);
749
750 if args.remove && already.is_some() {
757 let other_relation_to_target_exists = entity
758 .relationships
759 .iter()
760 .any(|r| r.target == args.target && r.rel_type != args.rel_type);
761 if !other_relation_to_target_exists {
762 let mut surviving_sections: Vec<String> = Vec::new();
769 for (section_key, body) in entity.sections.iter() {
770 let inline_targets =
771 crate::entity::parser::extract_inline_links_lenient(body, &source_mem);
772 if inline_targets.iter().any(|t| t == &args.target) {
773 surviving_sections.push(section_key.clone());
774 }
775 }
776 if !surviving_sections.is_empty() {
777 return Err(EngineError::RelationHasBodyLinks {
778 from_id: args.source.to_string(),
779 to_id: args.target.to_string(),
780 rel_type: args.rel_type.clone(),
781 body_links: surviving_sections,
782 });
783 }
784 }
785 }
786
787 let action = if args.remove {
788 match already {
789 Some(idx) => {
790 next.relationships.remove(idx);
791 RelateAction::Removed
792 }
793 None => RelateAction::NoOpAbsent,
794 }
795 } else {
796 match already {
797 Some(_) => RelateAction::NoOpAlreadyPresent,
798 None => {
799 next.relationships.push(Relationship {
800 rel_type: args.rel_type.clone(),
801 target: args.target.clone(),
802 description: normalise_description(args.description.as_deref()),
803 });
804 RelateAction::Added
805 }
806 }
807 };
808
809 if matches!(action, RelateAction::Removed) {
817 let blocked: Vec<_> =
818 crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref())
819 .into_iter()
820 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
821 .collect();
822 if !blocked.is_empty() {
823 return Err(EngineError::RequiredOutgoingUnsatisfied {
824 entity_type: next.entity_type.clone(),
825 entity_id: args.source.to_string(),
826 missing: blocked,
827 });
828 }
829 let blocked: Vec<_> = crate::ops::health::unsatisfied_constraints(
839 &self.store,
840 &next,
841 type_def.as_ref(),
842 Some(&args.source),
843 None,
844 )
845 .into_iter()
846 .filter(|v| {
847 matches!(
848 v,
849 crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { .. }
850 ) && v.severity() == memstead_schema::ConstraintSeverity::Block
851 })
852 .collect();
853 if !blocked.is_empty() {
854 return Err(EngineError::ConstraintUnsatisfied {
855 entity_type: next.entity_type.clone(),
856 entity_id: args.source.to_string(),
857 violations: blocked,
858 });
859 }
860 }
861
862 let mut stub_target: Option<EntityId> = None;
875 let mut stub_kind = crate::entity::StubKind::ForwardReference;
876 if matches!(action, RelateAction::Added) && !self.store.contains(&args.target) {
877 stub_target = Some(args.target.clone());
878 if target_verified_in_storage {
879 stub_kind = crate::entity::StubKind::LoadTime;
887 } else {
888 warnings.push(WarningHint::AutoStubCreated {
893 stub_id: args.target.clone(),
894 pending: args.dry_run,
895 });
896 }
897 if target_mem_uncreated {
904 warnings.push(WarningHint::CrossMemTargetMemUncreated {
905 from_mem: source_mem.clone(),
906 to_mem: target_mem.clone(),
907 target_id: args.target.clone(),
908 });
909 }
910 }
911
912 if matches!(
918 action,
919 RelateAction::NoOpAlreadyPresent | RelateAction::NoOpAbsent
920 ) {
921 match action {
922 RelateAction::NoOpAlreadyPresent => {
923 warnings.push(WarningHint::DuplicateRelationship {
924 rel_type: args.rel_type.clone(),
925 from: args.source.clone(),
926 to: args.target.clone(),
927 });
928 }
929 RelateAction::NoOpAbsent => {
930 warnings.push(WarningHint::NoSuchRelationship {
931 rel_type: args.rel_type.clone(),
932 from: args.source.clone(),
933 to: args.target.clone(),
934 });
935 }
936 _ => unreachable!(),
937 }
938 return Ok(RelatePrepareOutcome::Done(RelateEntityOutcome {
939 from: args.source,
940 to: args.target,
941 rel_type: args.rel_type,
942 action,
943 content_hash: entity.content_hash.clone(),
944 write_id: String::new(),
945 source: "explicit".to_string(),
946 warnings,
947 orphan_stubs_removed: Vec::new(),
950 }));
951 }
952
953 let today = self.now_iso();
961 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
962
963 let file_path = next.file_path.clone();
964 let markdown = super::render_for_write(&next, type_def.as_ref())?;
965
966 Ok(RelatePrepareOutcome::Prepared(PreparedRelate {
967 mount_idx,
968 source_mem,
969 from: args.source,
970 to: args.target,
971 rel_type: args.rel_type,
972 action,
973 file_path,
974 markdown,
975 warnings,
976 stub_target,
977 stub_kind,
978 type_def,
979 }))
980 }
981
982 fn stage_prepared_relate(&mut self, p: &PreparedRelate) -> Result<(), EngineError> {
988 if let Some(stub_id) = &p.stub_target {
989 self.store
990 .upsert(stub_id.clone(), make_stub(stub_id, p.stub_kind.clone()));
991 }
992 self.mounts[p.mount_idx]
993 .backend
994 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())?;
995 Ok(())
996 }
997
998 fn apply_prepared_relate_to_store(
1006 &mut self,
1007 p: &PreparedRelate,
1008 ) -> Result<String, EngineError> {
1009 let parse_result = parse_markdown(
1010 &p.markdown,
1011 &p.file_path,
1012 p.type_def.as_ref(),
1013 &p.source_mem,
1014 )
1015 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
1016 let content_hash = parse_result.entity.content_hash.clone();
1017 let fallback = engine_fallback_type();
1018 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
1019 crate::entity::store_builder::remap_alias_target_edge_sources(
1020 &mut self.store,
1021 &self.schemas,
1022 );
1023 Ok(content_hash)
1024 }
1025
1026 pub fn batch_relate(
1063 &mut self,
1064 relates: Vec<(RelateEntityArgs, Option<String>)>,
1065 actor: Actor,
1066 client: Option<&ClientId>,
1067 dry_run: bool,
1068 ) -> Result<crate::ops::BatchResult, EngineError> {
1069 if relates.is_empty() {
1070 return Ok(crate::ops::BatchResult {
1071 warnings: Vec::new(),
1072 orphan_stubs_removed: Vec::new(),
1073 errors_suppressed: 0,
1074 applied: true,
1075 results: Vec::new(),
1076 succeeded: 0,
1077 failed: 0,
1078 write_id: String::new(),
1079 });
1080 }
1081
1082 let mut short_hints: Vec<WarningHint> = Vec::new();
1089 let mut short_errors: Vec<(usize, EngineError)> = Vec::new();
1090 let relates: Vec<(RelateEntityArgs, Option<String>)> = relates
1091 .into_iter()
1092 .enumerate()
1093 .map(|(i, (mut a, n))| {
1094 match self.resolve_entity_id(&a.source) {
1095 Ok((s, hint)) => {
1096 a.source = s;
1097 short_hints.extend(hint);
1098 }
1099 Err(e) => short_errors.push((i, e)),
1100 }
1101 match self.resolve_entity_id(&a.target) {
1102 Ok((t, hint)) => {
1103 a.target = t;
1104 short_hints.extend(hint);
1105 }
1106 Err(e) => short_errors.push((i, e)),
1107 }
1108 (a, n)
1109 })
1110 .collect();
1111 let mut touched_mems: Vec<String> = relates
1112 .iter()
1113 .flat_map(|(a, _)| [a.source.mem().to_string(), a.target.mem().to_string()])
1114 .collect();
1115 touched_mems.sort();
1116 touched_mems.dedup();
1117 let source_mems: std::collections::HashSet<&str> =
1123 relates.iter().map(|(a, _)| a.source.mem()).collect();
1124 touched_mems.retain(|m| source_mems.contains(m.as_str()) || !self.mem_is_deferred(m));
1125 for m in &touched_mems {
1126 self.reload_if_stale(Some(m));
1127 }
1128 if relates.iter().any(|(a, _)| {
1138 (!a.remove
1139 && self.schemas.get(a.source.mem()).is_some_and(|s| {
1140 s.relationship_acyclic(&a.rel_type)
1141 || s.acyclic_set_containing(&a.rel_type).is_some()
1142 }))
1143 || [a.source.mem(), a.target.mem()].iter().any(|m| {
1144 self.schemas
1145 .get(*m)
1146 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1147 })
1148 }) {
1149 self.ensure_mems_loaded(None);
1150 }
1151
1152 let store_snapshot = self.store.clone();
1157
1158 enum ItemState {
1159 Applied(&'static str),
1160 Noop,
1161 Error,
1162 }
1163 let mut items: Vec<(EntityId, ItemState)> = Vec::with_capacity(relates.len());
1164 let mut prepared: Vec<PreparedRelate> = Vec::new();
1165 let mut notes: Vec<Option<String>> = Vec::new();
1166 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1167
1168 for (i, (args, note)) in relates.into_iter().enumerate() {
1169 let source_id = args.source.clone();
1170 if let Some(pos) = short_errors.iter().position(|(j, _)| *j == i) {
1171 let (_, e) = short_errors.remove(pos);
1172 items.push((source_id, ItemState::Error));
1173 errors.push((i, e));
1174 continue;
1175 }
1176 let mut args = args;
1181 args.dry_run = false;
1182 match self.prepare_relate(args, Vec::new()) {
1183 Ok(RelatePrepareOutcome::Done(_)) => {
1184 items.push((source_id, ItemState::Noop));
1185 }
1186 Ok(RelatePrepareOutcome::Prepared(p)) => {
1187 if let Err(e) = self.stage_prepared_relate(&p) {
1190 self.store = store_snapshot;
1191 self.discard_all_pending();
1192 return Err(e);
1193 }
1194 if let Err(e) = self.apply_prepared_relate_to_store(&p) {
1195 self.store = store_snapshot;
1196 self.discard_all_pending();
1197 return Err(e);
1198 }
1199 if let Some(schema) = self.schemas.get(&p.source_mem)
1206 && super::rel_type_declares_derivation(schema, &p.rel_type)
1207 {
1208 let backend = self.mounts[p.mount_idx].backend.as_ref();
1209 let (from, rel, to) =
1210 (p.from.to_string(), p.rel_type.clone(), p.to.to_string());
1211 let staged = match p.action {
1212 RelateAction::Added => {
1213 let hash = self
1214 .store
1215 .get(&p.to)
1216 .map(|e| e.content_hash.clone())
1217 .unwrap_or_default();
1218 super::stage_derivation_sidecar(backend, |s| {
1219 s.set(&from, &rel, &to, &hash)
1220 })
1221 }
1222 RelateAction::Removed => {
1223 super::stage_derivation_sidecar(backend, |s| {
1224 s.remove(&from, &rel, &to)
1225 })
1226 }
1227 _ => Ok(()),
1228 };
1229 if let Err(e) = staged {
1230 self.store = store_snapshot;
1231 self.discard_all_pending();
1232 return Err(e);
1233 }
1234 }
1235 let label = match p.action {
1236 RelateAction::Added => "added",
1237 RelateAction::Removed => "removed",
1238 _ => unreachable!("no-ops resolve to Done"),
1239 };
1240 items.push((source_id, ItemState::Applied(label)));
1241 prepared.push(p);
1242 notes.push(note);
1243 }
1244 Err(e) => {
1245 items.push((source_id, ItemState::Error));
1246 errors.push((i, e));
1247 }
1248 }
1249 }
1250
1251 if !errors.is_empty() {
1252 self.store = store_snapshot;
1254 self.discard_all_pending();
1255 let failed = errors.len();
1256 let mut error_map: std::collections::HashMap<usize, EngineError> =
1257 errors.into_iter().collect();
1258 let mut reported = 0usize;
1259 let mut suppressed = 0usize;
1260 let results: Vec<crate::ops::BatchEntry> = items
1261 .into_iter()
1262 .enumerate()
1263 .map(|(i, (id, _))| match error_map.remove(&i) {
1264 Some(e) => {
1265 if reported < Self::BATCH_ERROR_REPORT_CAP {
1266 reported += 1;
1267 crate::ops::BatchEntry {
1268 id,
1269 action: "error".to_string(),
1270 error: Some(super::update::batch_error_envelope(&e)),
1271 }
1272 } else {
1273 suppressed += 1;
1274 crate::ops::BatchEntry {
1275 id,
1276 action: "error".to_string(),
1277 error: None,
1278 }
1279 }
1280 }
1281 None => crate::ops::BatchEntry {
1282 id,
1283 action: "not_applied".to_string(),
1284 error: None,
1285 },
1286 })
1287 .collect();
1288 return Ok(crate::ops::BatchResult {
1289 warnings: Vec::new(),
1290 orphan_stubs_removed: Vec::new(),
1291 errors_suppressed: suppressed,
1292 applied: false,
1293 results,
1294 succeeded: 0,
1295 failed,
1296 write_id: String::new(),
1297 });
1298 }
1299
1300 if dry_run {
1306 let removed_targets: Vec<EntityId> = prepared
1307 .iter()
1308 .filter(|p| matches!(p.action, RelateAction::Removed))
1309 .map(|p| p.to.clone())
1310 .collect();
1311 let orphan_stubs_removed =
1312 super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1313 self.store = store_snapshot;
1314 self.discard_all_pending();
1315 let succeeded = items.len();
1316 let results: Vec<crate::ops::BatchEntry> = items
1317 .into_iter()
1318 .map(|(id, state)| crate::ops::BatchEntry {
1319 id,
1320 action: match state {
1321 ItemState::Applied(label) => label.to_string(),
1322 ItemState::Noop => "noop".to_string(),
1323 ItemState::Error => unreachable!("refusal path returned above"),
1324 },
1325 error: None,
1326 })
1327 .collect();
1328 return Ok(crate::ops::BatchResult {
1329 warnings: Vec::new(),
1330 orphan_stubs_removed,
1331 errors_suppressed: 0,
1332 applied: true,
1333 results,
1334 succeeded,
1335 failed: 0,
1336 write_id: String::new(),
1337 });
1338 }
1339
1340 let mut distinct_mounts: Vec<usize> = Vec::new();
1342 for p in &prepared {
1343 if !distinct_mounts.contains(&p.mount_idx) {
1344 distinct_mounts.push(p.mount_idx);
1345 }
1346 }
1347 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1348 for &m in &distinct_mounts {
1349 let mut entity_ids: Vec<String> = Vec::new();
1352 let mut edge_count = 0usize;
1353 for p in prepared.iter().filter(|p| p.mount_idx == m) {
1354 edge_count += 1;
1355 let s = p.from.to_string();
1356 if !entity_ids.contains(&s) {
1357 entity_ids.push(s);
1358 }
1359 }
1360 let subject = format!("memstead: batch-relate ({edge_count} edges)");
1361 let note_lines: Vec<String> = prepared
1366 .iter()
1367 .zip(notes.iter())
1368 .filter(|(p, _)| p.mount_idx == m)
1369 .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.from)))
1370 .collect();
1371 let ctx = CommitContext {
1372 actor,
1373 client: client.cloned(),
1374 tool: Some("batch_relate"),
1375 note: if note_lines.is_empty() {
1376 None
1377 } else {
1378 Some(note_lines.join("\n"))
1379 },
1380 role: self.current_role,
1381 identity: self.current_identity.clone(),
1382 logical_operation_id: None,
1383 entity_ids: Some(entity_ids),
1384 };
1385 match self.mounts[m].backend.commit(&subject, &ctx) {
1386 Ok(sha) => mount_commits.push((m, sha)),
1387 Err(e) => {
1388 self.store = store_snapshot;
1393 self.discard_all_pending();
1394 return Err(e.into());
1395 }
1396 }
1397 }
1398
1399 let mut batch_warnings: Vec<WarningHint> = short_hints;
1401 for (p, note) in prepared.iter().zip(notes.iter()) {
1402 let write_id = mount_commits
1403 .iter()
1404 .find(|(m, _)| *m == p.mount_idx)
1405 .map(|(_, s)| s.clone())
1406 .unwrap_or_default();
1407 self.mounts[p.mount_idx].backend.append_provenance(
1408 &Provenance::new(
1409 std::time::SystemTime::now(),
1410 ProvenanceKind::Relate,
1411 Some(p.from.to_string()),
1412 actor,
1413 client.cloned(),
1414 note.clone(),
1415 )
1416 .with_role(self.current_role)
1417 .with_identity(self.current_identity.clone()),
1418 )?;
1419 self.record_self_write(p.mount_idx, &write_id);
1420 batch_warnings.extend(self.stamp_mutation_versions(p.mount_idx));
1421 }
1422
1423 let removed_targets: Vec<EntityId> = prepared
1426 .iter()
1427 .filter(|p| matches!(p.action, RelateAction::Removed))
1428 .map(|p| p.to.clone())
1429 .collect();
1430 let orphan_stubs_removed =
1431 super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1432
1433 self.invalidate_communities();
1434 self.invalidate_search_indexes();
1435
1436 let write_id = mount_commits
1437 .last()
1438 .map(|(_, s)| s.clone())
1439 .unwrap_or_default();
1440 let succeeded = items.len();
1441 let results: Vec<crate::ops::BatchEntry> = items
1442 .into_iter()
1443 .map(|(id, state)| crate::ops::BatchEntry {
1444 id,
1445 action: match state {
1446 ItemState::Applied(label) => label.to_string(),
1447 ItemState::Noop => "noop".to_string(),
1448 ItemState::Error => unreachable!("refusal path returned above"),
1449 },
1450 error: None,
1451 })
1452 .collect();
1453
1454 Ok(crate::ops::BatchResult {
1455 warnings: batch_warnings,
1456 orphan_stubs_removed,
1457 errors_suppressed: 0,
1458 applied: true,
1459 results,
1460 succeeded,
1461 failed: 0,
1462 write_id,
1463 })
1464 }
1465
1466 pub fn relate(
1473 &mut self,
1474 from: &EntityId,
1475 to: &EntityId,
1476 rel_type: &str,
1477 remove: bool,
1478 ctx: &CommitContext<'_>,
1479 ) -> Result<RelateEntityOutcome, EngineError> {
1480 let args = RelateEntityArgs {
1481 source: from.clone(),
1482 expected_hash: None,
1483 rel_type: rel_type.to_string(),
1484 target: to.clone(),
1485 remove,
1486 description: None,
1487 dry_run: false,
1488 };
1489 self.relate_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1490 }
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495
1496 use indexmap::IndexMap;
1497 use tempfile::TempDir;
1498
1499 use crate::backend::MemBackend;
1500 use crate::engine::test_helpers::*;
1501 use crate::engine::{CreateEntityArgs, Engine, EngineError, RelateAction, RelateEntityArgs};
1502 use crate::ops::WarningHint;
1503 use crate::storage::FilesystemMemWriter;
1504 use crate::vcs::{Actor, CommitContext};
1505
1506 #[test]
1507 fn relate_alias_delegates_to_relate_entity() {
1508 let tmp = TempDir::new().unwrap();
1513 let mem_dir = tmp.path().to_path_buf();
1514 let writer = FilesystemMemWriter::new(mem_dir.clone());
1515 let mut engine = Engine::from_mounts(vec![(
1516 folder_mount("specs", mem_dir),
1517 Box::new(writer) as Box<dyn MemBackend>,
1518 )])
1519 .unwrap();
1520
1521 let a = engine
1523 .create_entity(
1524 CreateEntityArgs {
1525 anchors: Vec::new(),
1526 mem: "specs".to_string(),
1527 title: "A".to_string(),
1528 entity_type: "spec".to_string(),
1529 sections: IndexMap::from_iter([
1530 ("identity".to_string(), "seed identity".to_string()),
1531 ("purpose".to_string(), "seed purpose".to_string()),
1532 ]),
1533 metadata: IndexMap::new(),
1534 relations: Vec::new(),
1535 dry_run: false,
1536 },
1537 Actor::Cli,
1538 None,
1539 None,
1540 )
1541 .unwrap();
1542 let b = engine
1543 .create_entity(
1544 CreateEntityArgs {
1545 anchors: Vec::new(),
1546 mem: "specs".to_string(),
1547 title: "B".to_string(),
1548 entity_type: "spec".to_string(),
1549 sections: IndexMap::from_iter([
1550 ("identity".to_string(), "seed identity".to_string()),
1551 ("purpose".to_string(), "seed purpose".to_string()),
1552 ]),
1553 metadata: IndexMap::new(),
1554 relations: Vec::new(),
1555 dry_run: false,
1556 },
1557 Actor::Cli,
1558 None,
1559 None,
1560 )
1561 .unwrap();
1562
1563 let ctx = CommitContext::internal();
1565 let result = engine.relate(&a.id, &b.id, "PART_OF", false, &ctx).unwrap();
1566 assert_eq!(result.from, a.id);
1567 assert_eq!(result.to, b.id);
1568 assert_eq!(result.rel_type, "PART_OF");
1569 let outgoing: Vec<_> = engine.store().outgoing(&a.id).to_vec();
1571 assert!(
1572 outgoing
1573 .iter()
1574 .any(|e| e.target == b.id && e.rel_type == "PART_OF")
1575 );
1576 }
1577
1578 #[test]
1579 fn relate_entity_appends_relationship_and_logs_provenance() {
1580 let tmp = TempDir::new().unwrap();
1581 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1582 let (actor, client) = cli_actor();
1583 let target = engine
1584 .create_entity(
1585 empty_create_args("specs", "Target"),
1586 actor,
1587 Some(&client),
1588 None,
1589 )
1590 .unwrap();
1591
1592 let outcome = engine
1593 .relate_entity(
1594 RelateEntityArgs {
1595 source: source.id.clone(),
1596 expected_hash: Some(source.content_hash.clone()),
1597 rel_type: "USES".to_string(),
1598 target: target.id.clone(),
1599 remove: false,
1600 description: None,
1601 dry_run: false,
1602 },
1603 actor,
1604 Some(&client),
1605 None,
1606 )
1607 .unwrap();
1608 assert_eq!(outcome.action, RelateAction::Added);
1609 assert_ne!(outcome.content_hash, source.content_hash);
1610 let edges = engine.store().outgoing(&source.id);
1612 assert!(
1613 edges
1614 .iter()
1615 .any(|e| e.rel_type == "USES" && e.target == target.id),
1616 "expected USES edge in store"
1617 );
1618 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
1620 assert!(log.contains("\"kind\":\"relate\""));
1621 }
1622
1623 #[test]
1624 fn relate_entity_no_op_when_already_present() {
1625 let tmp = TempDir::new().unwrap();
1626 let (mut engine, source) = engine_with_seed(&tmp, "Already");
1627 let (actor, client) = cli_actor();
1628 let target = engine
1629 .create_entity(empty_create_args("specs", "T2"), actor, Some(&client), None)
1630 .unwrap();
1631 let first = engine
1632 .relate_entity(
1633 RelateEntityArgs {
1634 source: source.id.clone(),
1635 expected_hash: Some(source.content_hash.clone()),
1636 rel_type: "USES".to_string(),
1637 target: target.id.clone(),
1638 remove: false,
1639 description: None,
1640 dry_run: false,
1641 },
1642 actor,
1643 Some(&client),
1644 None,
1645 )
1646 .unwrap();
1647 let second = engine
1648 .relate_entity(
1649 RelateEntityArgs {
1650 source: source.id.clone(),
1651 expected_hash: Some(first.content_hash.clone()),
1652 rel_type: "USES".to_string(),
1653 target: target.id.clone(),
1654 remove: false,
1655 description: None,
1656 dry_run: false,
1657 },
1658 actor,
1659 Some(&client),
1660 None,
1661 )
1662 .unwrap();
1663 assert_eq!(second.action, RelateAction::NoOpAlreadyPresent);
1664 assert_eq!(second.content_hash, first.content_hash);
1666 }
1667
1668 #[test]
1669 fn relate_entity_returns_write_id_on_real_write() {
1670 let tmp = TempDir::new().unwrap();
1671 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1672 let (actor, client) = cli_actor();
1673 let target = engine
1674 .create_entity(
1675 empty_create_args("specs", "Target"),
1676 actor,
1677 Some(&client),
1678 None,
1679 )
1680 .unwrap();
1681
1682 let outcome = engine
1683 .relate_entity(
1684 RelateEntityArgs {
1685 source: source.id.clone(),
1686 expected_hash: Some(source.content_hash.clone()),
1687 rel_type: "USES".to_string(),
1688 target: target.id.clone(),
1689 remove: false,
1690 description: None,
1691 dry_run: false,
1692 },
1693 actor,
1694 Some(&client),
1695 None,
1696 )
1697 .unwrap();
1698 assert!(
1702 !outcome.write_id.is_empty(),
1703 "write_id must be populated on a real write"
1704 );
1705 }
1706
1707 #[test]
1708 fn relate_entity_no_op_paths_carry_typed_warnings_and_empty_write_id() {
1709 let tmp = TempDir::new().unwrap();
1710 let (mut engine, source) = engine_with_seed(&tmp, "S");
1711 let (actor, client) = cli_actor();
1712 let target = engine
1713 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1714 .unwrap();
1715
1716 let first = engine
1718 .relate_entity(
1719 RelateEntityArgs {
1720 source: source.id.clone(),
1721 expected_hash: Some(source.content_hash.clone()),
1722 rel_type: "USES".to_string(),
1723 target: target.id.clone(),
1724 remove: false,
1725 description: None,
1726 dry_run: false,
1727 },
1728 actor,
1729 Some(&client),
1730 None,
1731 )
1732 .unwrap();
1733
1734 let dup = engine
1737 .relate_entity(
1738 RelateEntityArgs {
1739 source: source.id.clone(),
1740 expected_hash: Some(first.content_hash.clone()),
1741 rel_type: "USES".to_string(),
1742 target: target.id.clone(),
1743 remove: false,
1744 description: None,
1745 dry_run: false,
1746 },
1747 actor,
1748 Some(&client),
1749 None,
1750 )
1751 .unwrap();
1752 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1753 assert!(dup.write_id.is_empty());
1754 assert_eq!(dup.warnings.len(), 1);
1755 assert!(matches!(
1756 dup.warnings[0],
1757 WarningHint::DuplicateRelationship { .. }
1758 ));
1759
1760 let no_such = engine
1763 .relate_entity(
1764 RelateEntityArgs {
1765 source: source.id.clone(),
1766 expected_hash: Some(first.content_hash.clone()),
1767 rel_type: "DEPENDS_ON".to_string(),
1768 target: target.id.clone(),
1769 remove: true,
1770 description: None,
1771 dry_run: false,
1772 },
1773 actor,
1774 Some(&client),
1775 None,
1776 )
1777 .unwrap();
1778 assert_eq!(no_such.action, RelateAction::NoOpAbsent);
1779 assert!(no_such.write_id.is_empty());
1780 assert_eq!(no_such.warnings.len(), 1);
1781 assert!(matches!(
1782 no_such.warnings[0],
1783 WarningHint::NoSuchRelationship { .. }
1784 ));
1785 }
1786
1787 #[test]
1788 fn relate_entity_creates_stub_for_absent_target_on_add_path() {
1789 let tmp = TempDir::new().unwrap();
1790 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1791 let (actor, client) = cli_actor();
1792 let absent_target = crate::EntityId::new("specs", "ghost-target");
1793 assert!(!engine.store().contains(&absent_target));
1795
1796 let outcome = engine
1797 .relate_entity(
1798 RelateEntityArgs {
1799 source: source.id.clone(),
1800 expected_hash: Some(source.content_hash.clone()),
1801 rel_type: "USES".to_string(),
1802 target: absent_target.clone(),
1803 remove: false,
1804 description: None,
1805 dry_run: false,
1806 },
1807 actor,
1808 Some(&client),
1809 None,
1810 )
1811 .unwrap();
1812
1813 assert_eq!(outcome.action, RelateAction::Added);
1814 assert_eq!(outcome.source, "explicit");
1815 let stub_warning = outcome
1821 .warnings
1822 .iter()
1823 .find_map(|w| match w {
1824 crate::ops::WarningHint::AutoStubCreated { stub_id, .. } => Some(stub_id.clone()),
1825 _ => None,
1826 })
1827 .expect("AutoStubCreated warning must surface when target was absent");
1828 assert_eq!(stub_warning, absent_target);
1829 let msg = outcome
1832 .warnings
1833 .iter()
1834 .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
1835 .unwrap()
1836 .message();
1837 assert!(
1838 msg.contains("stub auto-created"),
1839 "real relate keeps the performed-effect wording: {msg}"
1840 );
1841
1842 let stub = engine.store().get(&absent_target).expect("stub upserted");
1844 assert!(stub.stub);
1845 assert!(stub.entity_type.is_empty());
1846 assert!(stub.file_path.is_empty());
1847 }
1848
1849 #[test]
1850 fn relate_entity_skips_stub_creation_when_target_already_exists() {
1851 let tmp = TempDir::new().unwrap();
1852 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1853 let (actor, client) = cli_actor();
1854 let target = engine
1855 .create_entity(
1856 empty_create_args("specs", "Real"),
1857 actor,
1858 Some(&client),
1859 None,
1860 )
1861 .unwrap();
1862
1863 let outcome = engine
1864 .relate_entity(
1865 RelateEntityArgs {
1866 source: source.id.clone(),
1867 expected_hash: Some(source.content_hash.clone()),
1868 rel_type: "USES".to_string(),
1869 target: target.id.clone(),
1870 remove: false,
1871 description: None,
1872 dry_run: false,
1873 },
1874 actor,
1875 Some(&client),
1876 None,
1877 )
1878 .unwrap();
1879
1880 assert!(
1881 !outcome
1882 .warnings
1883 .iter()
1884 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1885 "AutoStubCreated must not surface when target was already in store"
1886 );
1887 assert_eq!(outcome.source, "explicit");
1888 let target_after = engine.store().get(&target.id).unwrap();
1890 assert!(!target_after.stub);
1891 }
1892
1893 #[test]
1894 fn relate_entity_does_not_create_stub_on_remove_path() {
1895 let tmp = TempDir::new().unwrap();
1896 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1897 let (actor, client) = cli_actor();
1898 let absent_target = crate::EntityId::new("specs", "never-existed");
1899
1900 let outcome = engine
1901 .relate_entity(
1902 RelateEntityArgs {
1903 source: source.id.clone(),
1904 expected_hash: Some(source.content_hash.clone()),
1905 rel_type: "USES".to_string(),
1906 target: absent_target.clone(),
1907 remove: true,
1908 description: None,
1909 dry_run: false,
1910 },
1911 actor,
1912 Some(&client),
1913 None,
1914 )
1915 .unwrap();
1916
1917 assert_eq!(outcome.action, RelateAction::NoOpAbsent);
1920 assert!(
1921 !outcome
1922 .warnings
1923 .iter()
1924 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1925 "remove path must never auto-stub the target",
1926 );
1927 assert!(!engine.store().contains(&absent_target));
1928 }
1929
1930 #[test]
1931 fn relate_entity_remove_refuses_when_source_body_still_references_target() {
1932 use crate::engine::CreateEntityArgs;
1933 use indexmap::IndexMap;
1934
1935 let tmp = TempDir::new().unwrap();
1936 let mem_dir = tmp.path().to_path_buf();
1937 let writer = FilesystemMemWriter::new(mem_dir.clone());
1938 let mut engine = Engine::from_mounts(vec![(
1939 folder_mount("specs", mem_dir.clone()),
1940 Box::new(writer) as Box<dyn MemBackend>,
1941 )])
1942 .unwrap();
1943 let (actor, client) = cli_actor();
1944
1945 let target = engine
1946 .create_entity(
1947 empty_create_args("specs", "Target"),
1948 actor,
1949 Some(&client),
1950 None,
1951 )
1952 .unwrap();
1953
1954 let mut sections: IndexMap<String, String> = IndexMap::new();
1960 sections.insert("identity".to_string(), "source identity".to_string());
1961 sections.insert(
1962 "purpose".to_string(),
1963 "discussion stems from [[target]]".to_string(),
1964 );
1965 let source = engine
1966 .create_entity(
1967 CreateEntityArgs {
1968 anchors: Vec::new(),
1969 mem: "specs".to_string(),
1970 title: "Source".to_string(),
1971 entity_type: "spec".to_string(),
1972 sections,
1973 metadata: IndexMap::new(),
1974 relations: Vec::new(),
1975 dry_run: false,
1976 },
1977 actor,
1978 Some(&client),
1979 None,
1980 )
1981 .unwrap();
1982 let related = source.clone();
1983
1984 let err = engine
1988 .relate_entity(
1989 RelateEntityArgs {
1990 source: source.id.clone(),
1991 expected_hash: Some(related.content_hash.clone()),
1992 rel_type: "REFERENCES".to_string(),
1993 target: target.id.clone(),
1994 remove: true,
1995 description: None,
1996 dry_run: false,
1997 },
1998 actor,
1999 Some(&client),
2000 None,
2001 )
2002 .unwrap_err();
2003 match err {
2004 EngineError::RelationHasBodyLinks {
2005 from_id,
2006 to_id,
2007 rel_type,
2008 body_links,
2009 } => {
2010 assert_eq!(from_id, source.id.to_string());
2011 assert_eq!(to_id, target.id.to_string());
2012 assert_eq!(rel_type, "REFERENCES");
2013 assert_eq!(body_links, vec!["purpose".to_string()]);
2014 }
2015 other => panic!("expected RelationHasBodyLinks, got {other:?}"),
2016 }
2017 let in_mem = engine.get_entity(&source.id).unwrap();
2020 assert!(
2021 in_mem
2022 .relationships
2023 .iter()
2024 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2025 "relation must survive the refused remove; got {:?}",
2026 in_mem.relationships
2027 );
2028 }
2029
2030 #[test]
2031 fn relate_entity_remove_succeeds_when_body_no_longer_references_target() {
2032 let tmp = TempDir::new().unwrap();
2033 let (mut engine, source) = engine_with_seed(&tmp, "Src");
2034 let (actor, client) = cli_actor();
2035 let target = engine
2036 .create_entity(
2037 empty_create_args("specs", "Other"),
2038 actor,
2039 Some(&client),
2040 None,
2041 )
2042 .unwrap();
2043 let related = engine
2049 .relate_entity(
2050 RelateEntityArgs {
2051 source: source.id.clone(),
2052 expected_hash: Some(source.content_hash.clone()),
2053 rel_type: "USES".to_string(),
2054 target: target.id.clone(),
2055 remove: false,
2056 description: None,
2057 dry_run: false,
2058 },
2059 actor,
2060 Some(&client),
2061 None,
2062 )
2063 .unwrap();
2064 let removed = engine
2065 .relate_entity(
2066 RelateEntityArgs {
2067 source: source.id.clone(),
2068 expected_hash: Some(related.content_hash.clone()),
2069 rel_type: "USES".to_string(),
2070 target: target.id.clone(),
2071 remove: true,
2072 description: None,
2073 dry_run: false,
2074 },
2075 actor,
2076 Some(&client),
2077 None,
2078 )
2079 .unwrap();
2080 assert_eq!(removed.action, RelateAction::Removed);
2081 }
2082
2083 #[test]
2084 fn relate_entity_auto_stub_is_tagged_forward_reference() {
2085 use crate::entity::StubKind;
2090
2091 let tmp = TempDir::new().unwrap();
2092 let (mut engine, source) = engine_with_seed(&tmp, "Src");
2093 let (actor, client) = cli_actor();
2094 let absent_target = crate::EntityId::new("specs", "absent-target");
2095
2096 let _ = engine
2097 .relate_entity(
2098 RelateEntityArgs {
2099 source: source.id.clone(),
2100 expected_hash: Some(source.content_hash.clone()),
2101 rel_type: "USES".to_string(),
2102 target: absent_target.clone(),
2103 remove: false,
2104 description: None,
2105 dry_run: false,
2106 },
2107 actor,
2108 Some(&client),
2109 None,
2110 )
2111 .unwrap();
2112
2113 let stub = engine
2114 .get_entity(&absent_target)
2115 .expect("relate auto-stubbed target must be in the store");
2116 assert!(stub.stub, "auto-stubbed target must carry stub: true");
2117 assert_eq!(
2118 stub.stub_kind,
2119 Some(StubKind::ForwardReference),
2120 "auto-stub from relate must be tagged ForwardReference; got {:?}",
2121 stub.stub_kind
2122 );
2123 }
2124
2125 #[test]
2126 fn relate_entity_case_insensitive_rel_type_input_canonicalises_to_upper_snake_case() {
2127 let tmp = TempDir::new().unwrap();
2132 let (mut engine, source) = engine_with_seed(&tmp, "Source");
2133 let (actor, client) = cli_actor();
2134 let target = engine
2135 .create_entity(
2136 empty_create_args("specs", "Target"),
2137 actor,
2138 Some(&client),
2139 None,
2140 )
2141 .unwrap();
2142
2143 let lower = engine
2145 .relate_entity(
2146 RelateEntityArgs {
2147 source: source.id.clone(),
2148 expected_hash: Some(source.content_hash.clone()),
2149 rel_type: "uses".to_string(),
2150 target: target.id.clone(),
2151 remove: false,
2152 description: None,
2153 dry_run: false,
2154 },
2155 actor,
2156 Some(&client),
2157 None,
2158 )
2159 .unwrap();
2160 assert_eq!(lower.rel_type, "USES", "response must echo canonical form");
2161 assert_eq!(lower.action, RelateAction::Added);
2162 let edges = engine.store().outgoing(&source.id);
2163 assert!(
2164 edges
2165 .iter()
2166 .any(|e| e.rel_type == "USES" && e.target == target.id),
2167 "store must hold UPPER_SNAKE_CASE rel_type after lowercase input"
2168 );
2169
2170 let dup = engine
2174 .relate_entity(
2175 RelateEntityArgs {
2176 source: source.id.clone(),
2177 expected_hash: Some(lower.content_hash.clone()),
2178 rel_type: "Uses".to_string(),
2179 target: target.id.clone(),
2180 remove: false,
2181 description: None,
2182 dry_run: false,
2183 },
2184 actor,
2185 Some(&client),
2186 None,
2187 )
2188 .unwrap();
2189 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
2190 assert_eq!(dup.rel_type, "USES");
2191 assert!(matches!(
2192 dup.warnings[0],
2193 WarningHint::DuplicateRelationship { .. }
2194 ));
2195 }
2196
2197 #[test]
2198 fn relate_entity_rejects_cross_mem_when_policy_denies() {
2199 let tmp = TempDir::new().unwrap();
2206 let (mut engine, source) = engine_with_seed(&tmp, "S");
2207 let (actor, client) = cli_actor();
2208 let err = engine
2209 .relate_entity(
2210 RelateEntityArgs {
2211 source: source.id.clone(),
2212 expected_hash: Some(source.content_hash.clone()),
2213 rel_type: "USES".to_string(),
2214 target: crate::EntityId::new("other-mem", "thing"),
2215 remove: false,
2216 description: None,
2217 dry_run: false,
2218 },
2219 actor,
2220 Some(&client),
2221 None,
2222 )
2223 .unwrap_err();
2224 match err {
2225 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2226 assert_eq!(from_mem, "specs");
2227 assert_eq!(to_mem, "other-mem");
2228 }
2229 other => panic!("expected CrossMemLinkNotAllowed, got {other:?}"),
2230 }
2231 }
2232
2233 #[test]
2242 fn relate_entity_malformed_bare_target_surfaces_invalid_entity_id_not_cross_mem() {
2243 let tmp = TempDir::new().unwrap();
2244 let (mut engine, source) = engine_with_seed(&tmp, "S");
2245 let (actor, client) = cli_actor();
2246 let err = engine
2247 .relate_entity(
2248 RelateEntityArgs {
2249 source: source.id.clone(),
2250 expected_hash: Some(source.content_hash.clone()),
2251 rel_type: "USES".to_string(),
2252 target: crate::EntityId("bad target with spaces!!".to_string()),
2255 remove: false,
2256 description: None,
2257 dry_run: false,
2258 },
2259 actor,
2260 Some(&client),
2261 None,
2262 )
2263 .unwrap_err();
2264 assert!(
2265 matches!(err, EngineError::InvalidEntityId { .. }),
2266 "malformed bare-string target must surface INVALID_ENTITY_ID, got: {err:?}"
2267 );
2268 }
2269
2270 #[test]
2275 fn relate_entity_malformed_prefixed_target_surfaces_invalid_entity_id() {
2276 let tmp = TempDir::new().unwrap();
2277 let (mut engine, source) = engine_with_seed(&tmp, "S");
2278 let (actor, client) = cli_actor();
2279 let source_mem = source.id.mem().to_string();
2280 let err = engine
2281 .relate_entity(
2282 RelateEntityArgs {
2283 source: source.id.clone(),
2284 expected_hash: Some(source.content_hash.clone()),
2285 rel_type: "USES".to_string(),
2286 target: crate::EntityId(format!("{source_mem}--bad target with spaces!!")),
2287 remove: false,
2288 description: None,
2289 dry_run: false,
2290 },
2291 actor,
2292 Some(&client),
2293 None,
2294 )
2295 .unwrap_err();
2296 assert!(
2297 matches!(err, EngineError::InvalidEntityId { .. }),
2298 "prefixed malformed target must still surface INVALID_ENTITY_ID, got: {err:?}"
2299 );
2300 }
2301
2302 #[test]
2308 fn relate_add_bumps_last_modified_on_source_entity() {
2309 let tmp = TempDir::new().unwrap();
2310 let (mut engine, source) = engine_with_seed(&tmp, "S");
2311 let (actor, client) = cli_actor();
2312 let target = engine
2313 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2314 .unwrap();
2315
2316 let outcome = engine
2317 .relate_entity(
2318 RelateEntityArgs {
2319 source: source.id.clone(),
2320 expected_hash: Some(source.content_hash.clone()),
2321 rel_type: "USES".to_string(),
2322 target: target.id.clone(),
2323 remove: false,
2324 description: None,
2325 dry_run: false,
2326 },
2327 actor,
2328 Some(&client),
2329 None,
2330 )
2331 .unwrap();
2332 assert_eq!(outcome.action, RelateAction::Added);
2333
2334 let post = engine.get_entity(&source.id).unwrap();
2339 let last_modified = post
2340 .metadata
2341 .get("last_modified")
2342 .map(|v| v.to_frontmatter_string())
2343 .unwrap_or_default();
2344 assert!(
2345 last_modified.starts_with("20"),
2346 "last_modified must carry an ISO timestamp post-relate; got: {last_modified:?}"
2347 );
2348 }
2349
2350 #[test]
2355 fn relate_add_noop_does_not_bump_last_modified() {
2356 let tmp = TempDir::new().unwrap();
2357 let (mut engine, source) = engine_with_seed(&tmp, "S");
2358 let (actor, client) = cli_actor();
2359 let target = engine
2360 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2361 .unwrap();
2362 let first = engine
2363 .relate_entity(
2364 RelateEntityArgs {
2365 source: source.id.clone(),
2366 expected_hash: Some(source.content_hash.clone()),
2367 rel_type: "USES".to_string(),
2368 target: target.id.clone(),
2369 remove: false,
2370 description: None,
2371 dry_run: false,
2372 },
2373 actor,
2374 Some(&client),
2375 None,
2376 )
2377 .unwrap();
2378 let pre = engine.get_entity(&source.id).unwrap();
2379 let pre_stamp = pre
2380 .metadata
2381 .get("last_modified")
2382 .map(|v| v.to_frontmatter_string())
2383 .unwrap_or_default();
2384
2385 let dup = engine
2387 .relate_entity(
2388 RelateEntityArgs {
2389 source: source.id.clone(),
2390 expected_hash: Some(first.content_hash.clone()),
2391 rel_type: "USES".to_string(),
2392 target: target.id.clone(),
2393 remove: false,
2394 description: None,
2395 dry_run: false,
2396 },
2397 actor,
2398 Some(&client),
2399 None,
2400 )
2401 .unwrap();
2402 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
2403
2404 let post = engine.get_entity(&source.id).unwrap();
2405 let post_stamp = post
2406 .metadata
2407 .get("last_modified")
2408 .map(|v| v.to_frontmatter_string())
2409 .unwrap_or_default();
2410 assert_eq!(
2411 pre_stamp, post_stamp,
2412 "last_modified must not advance on a duplicate-add no-op (no disk write happened)"
2413 );
2414 }
2415
2416 #[test]
2421 fn cross_mem_relate_to_uncreated_mem_emits_typed_warning() {
2422 use memstead_schema::workspace_config::CrossLinkValue;
2423 let tmp = TempDir::new().unwrap();
2424 let (mut engine, source) = engine_with_seed(&tmp, "S");
2425 let (actor, client) = cli_actor();
2426 let mut settings = crate::workspace::WorkspaceSettings::default();
2430 settings.cross_mem_links.insert(
2431 "specs".to_string(),
2432 CrossLinkValue::List(vec!["uncreated-mem".to_string()]),
2433 );
2434 engine.set_settings(settings);
2435
2436 let absent = crate::EntityId::new("uncreated-mem", "ghost");
2437 let outcome = engine
2438 .relate_entity(
2439 RelateEntityArgs {
2440 source: source.id.clone(),
2441 expected_hash: Some(source.content_hash.clone()),
2442 rel_type: "USES".to_string(),
2443 target: absent.clone(),
2444 remove: false,
2445 description: None,
2446 dry_run: false,
2447 },
2448 actor,
2449 Some(&client),
2450 None,
2451 )
2452 .unwrap();
2453 assert_eq!(outcome.action, RelateAction::Added);
2454
2455 let saw_uncreated = outcome.warnings.iter().any(|w| {
2457 matches!(
2458 w,
2459 WarningHint::CrossMemTargetMemUncreated {
2460 from_mem,
2461 to_mem,
2462 target_id,
2463 } if from_mem == "specs"
2464 && to_mem == "uncreated-mem"
2465 && target_id == &absent
2466 )
2467 });
2468 assert!(
2469 saw_uncreated,
2470 "CrossMemTargetMemUncreated warning must surface; got: {:?}",
2471 outcome.warnings
2472 );
2473 assert!(engine.store().contains(&absent));
2475 }
2476
2477 #[test]
2483 fn cross_mem_relate_policy_refusal_preempts_uncreated_mem_warning() {
2484 let tmp = TempDir::new().unwrap();
2485 let (mut engine, source) = engine_with_seed(&tmp, "S");
2486 let (actor, client) = cli_actor();
2487 let absent = crate::EntityId::new("uncreated-mem", "ghost");
2489 let err = engine
2490 .relate_entity(
2491 RelateEntityArgs {
2492 source: source.id.clone(),
2493 expected_hash: Some(source.content_hash.clone()),
2494 rel_type: "USES".to_string(),
2495 target: absent.clone(),
2496 remove: false,
2497 description: None,
2498 dry_run: false,
2499 },
2500 actor,
2501 Some(&client),
2502 None,
2503 )
2504 .unwrap_err();
2505 assert!(matches!(err, EngineError::CrossMemLinkNotAllowed { .. }));
2506 assert!(!engine.store().contains(&absent));
2508 }
2509
2510 #[test]
2516 fn relate_remove_garbage_collects_orphan_stub() {
2517 let tmp = TempDir::new().unwrap();
2518 let (mut engine, source) = engine_with_seed(&tmp, "Src");
2519 let (actor, client) = cli_actor();
2520 let stub_id = crate::EntityId::new("specs", "ghost-target");
2521
2522 let added = engine
2524 .relate_entity(
2525 RelateEntityArgs {
2526 source: source.id.clone(),
2527 expected_hash: Some(source.content_hash.clone()),
2528 rel_type: "USES".to_string(),
2529 target: stub_id.clone(),
2530 remove: false,
2531 description: None,
2532 dry_run: false,
2533 },
2534 actor,
2535 Some(&client),
2536 None,
2537 )
2538 .unwrap();
2539 assert!(engine.store().contains(&stub_id));
2540
2541 let removed = engine
2542 .relate_entity(
2543 RelateEntityArgs {
2544 source: source.id.clone(),
2545 expected_hash: Some(added.content_hash.clone()),
2546 rel_type: "USES".to_string(),
2547 target: stub_id.clone(),
2548 remove: true,
2549 description: None,
2550 dry_run: false,
2551 },
2552 actor,
2553 Some(&client),
2554 None,
2555 )
2556 .unwrap();
2557 assert_eq!(removed.action, RelateAction::Removed);
2558 assert_eq!(
2559 removed.orphan_stubs_removed,
2560 vec![stub_id.clone()],
2561 "orphan stub must be GC'd in the same call"
2562 );
2563 assert!(
2564 !engine.store().contains(&stub_id),
2565 "stub must be gone from the store after GC"
2566 );
2567 }
2568
2569 #[test]
2575 fn relate_remove_does_not_gc_stub_with_surviving_incoming_edge() {
2576 let tmp = TempDir::new().unwrap();
2577 let (mut engine, source_a) = engine_with_seed(&tmp, "SrcA");
2578 let (actor, client) = cli_actor();
2579 let source_b = engine
2580 .create_entity(
2581 empty_create_args("specs", "SrcB"),
2582 actor,
2583 Some(&client),
2584 None,
2585 )
2586 .unwrap();
2587 let stub_id = crate::EntityId::new("specs", "ghost-target");
2588
2589 let a_added = engine
2591 .relate_entity(
2592 RelateEntityArgs {
2593 source: source_a.id.clone(),
2594 expected_hash: Some(source_a.content_hash.clone()),
2595 rel_type: "USES".to_string(),
2596 target: stub_id.clone(),
2597 remove: false,
2598 description: None,
2599 dry_run: false,
2600 },
2601 actor,
2602 Some(&client),
2603 None,
2604 )
2605 .unwrap();
2606 let _b_added = engine
2607 .relate_entity(
2608 RelateEntityArgs {
2609 source: source_b.id.clone(),
2610 expected_hash: Some(source_b.content_hash.clone()),
2611 rel_type: "USES".to_string(),
2612 target: stub_id.clone(),
2613 remove: false,
2614 description: None,
2615 dry_run: false,
2616 },
2617 actor,
2618 Some(&client),
2619 None,
2620 )
2621 .unwrap();
2622
2623 let removed = engine
2626 .relate_entity(
2627 RelateEntityArgs {
2628 source: source_a.id.clone(),
2629 expected_hash: Some(a_added.content_hash.clone()),
2630 rel_type: "USES".to_string(),
2631 target: stub_id.clone(),
2632 remove: true,
2633 description: None,
2634 dry_run: false,
2635 },
2636 actor,
2637 Some(&client),
2638 None,
2639 )
2640 .unwrap();
2641 assert_eq!(removed.action, RelateAction::Removed);
2642 assert!(
2643 removed.orphan_stubs_removed.is_empty(),
2644 "stub with surviving referrer must not be GC'd; got: {:?}",
2645 removed.orphan_stubs_removed
2646 );
2647 assert!(
2648 engine.store().contains(&stub_id),
2649 "stub must remain in store while another referrer holds it"
2650 );
2651 }
2652
2653 mod cross_mem {
2663 use std::collections::BTreeMap;
2664 use std::path::Path;
2665
2666 use indexmap::IndexMap;
2667 use memstead_schema::SchemaRef;
2668 use memstead_schema::workspace_config::CrossLinkValue;
2669 use tempfile::TempDir;
2670
2671 use crate::backend::MemBackend;
2672 use crate::engine::test_helpers::*;
2673 use crate::engine::{
2674 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateAction,
2675 RelateEntityArgs,
2676 };
2677 use crate::storage::FilesystemMemWriter;
2678
2679 use crate::workspace::{
2680 Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
2681 };
2682
2683 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
2684 let dir = root.join(name);
2685 std::fs::create_dir_all(dir.join("types")).unwrap();
2686 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
2687 for (type_name, body) in types {
2688 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
2689 }
2690 }
2691
2692 const TYPE_BODY: &str = r#"description: t
2693when_to_use: Here
2694sections:
2695 - key: body
2696 heading: Body
2697 required: true
2698 search_weight: 10.0
2699 catch_all: true
2700 write_rules: []
2701metadata_fields: []
2702title_weight: 100.0
2703text_fields:
2704 - body
2705hierarchy_relationship: _default
2706no_self_loop_relationships: []
2707updatable_fields:
2708 - title
2709 - body
2710health_required_fields:
2711 - body
2712staleness_threshold_days: 90
2713write_rules: []
2714"#;
2715
2716 fn make_type_yaml(name: &str) -> String {
2717 format!("name: {name}\n{TYPE_BODY}")
2718 }
2719
2720 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
2721 Mount {
2722 mem: mem.to_string(),
2723 schema: Some(pin),
2724 storage: MountStorage::Folder { path },
2725 capability: MountCapability::Write,
2726 lifecycle: MountLifecycle::Eager,
2727 cross_linkable: true,
2728 migration_target: None,
2729 }
2730 }
2731
2732 fn two_mem_engine() -> (TempDir, Engine, CreateEntityOutcome, CreateEntityOutcome) {
2735 let tmp = TempDir::new().unwrap();
2736
2737 let src_manifest = r#"name: src-cv
2740version: 0.1.0
2741description: source schema
2742when_to_use: tests
2743types:
2744 - doc
2745relationships:
2746 mode: strict
2747 definitions:
2748 - name: IMPLEMENTS
2749 description: intra-mem only
2750 default_weight: 1.0
2751 - name: _default
2752 description: fallback
2753 default_weight: 1.0
2754cross_mem_relationships:
2755 - to_schema: tgt-cv
2756 definitions:
2757 - name: ADDRESSES
2758 description: outbound shape-pinned
2759 default_weight: 1.0
2760 source_types: [doc]
2761 target_types: [req]
2762community:
2763 resolution: 1.0
2764 seed: 42
2765"#;
2766 let tgt_manifest = r#"name: tgt-cv
2769version: 0.1.0
2770description: target schema
2771when_to_use: tests
2772types:
2773 - req
2774relationships:
2775 mode: strict
2776 definitions:
2777 - name: PART_OF
2778 description: hierarchy
2779 default_weight: 3.0
2780 acyclic: true
2781 - name: _default
2782 description: fallback
2783 default_weight: 1.0
2784community:
2785 resolution: 1.0
2786 seed: 42
2787"#;
2788 let schemas_dir = tmp.path().join("schemas");
2789 std::fs::create_dir_all(&schemas_dir).unwrap();
2790 write_schema_files(
2791 &schemas_dir,
2792 "src-cv",
2793 src_manifest,
2794 &[("doc", &make_type_yaml("doc"))],
2795 );
2796 write_schema_files(
2797 &schemas_dir,
2798 "tgt-cv",
2799 tgt_manifest,
2800 &[("req", &make_type_yaml("req"))],
2801 );
2802
2803 let src_dir = tmp.path().join("mem-src");
2804 let tgt_dir = tmp.path().join("mem-tgt");
2805 std::fs::create_dir_all(&src_dir).unwrap();
2806 std::fs::create_dir_all(&tgt_dir).unwrap();
2807
2808 let src_writer = FilesystemMemWriter::new(src_dir.clone());
2809 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
2810 let src_pin = SchemaRef::new("src-cv", semver::Version::new(0, 1, 0));
2811 let tgt_pin = SchemaRef::new("tgt-cv", semver::Version::new(0, 1, 0));
2812
2813 let mut engine = Engine::from_mounts_with_schemas_dir(
2814 vec![
2815 (
2816 folder_mount_with_pin("src", src_dir, src_pin),
2817 Box::new(src_writer) as Box<dyn MemBackend>,
2818 ),
2819 (
2820 folder_mount_with_pin("tgt", tgt_dir, tgt_pin),
2821 Box::new(tgt_writer) as Box<dyn MemBackend>,
2822 ),
2823 ],
2824 Some(&schemas_dir),
2825 )
2826 .expect("two-mem engine constructs");
2827
2828 let mut settings = WorkspaceSettings::default();
2832 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2833 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2834 settings.cross_mem_links = links;
2835 engine.set_settings(settings);
2836
2837 let (actor, client) = cli_actor();
2838 let src_entity = engine
2839 .create_entity(
2840 CreateEntityArgs {
2841 anchors: Vec::new(),
2842 mem: "src".to_string(),
2843 title: "Doc One".to_string(),
2844 entity_type: "doc".to_string(),
2845 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2846 metadata: IndexMap::new(),
2847 relations: Vec::new(),
2848 dry_run: false,
2849 },
2850 actor,
2851 Some(&client),
2852 None,
2853 )
2854 .expect("source entity creates");
2855 let tgt_entity = engine
2856 .create_entity(
2857 CreateEntityArgs {
2858 anchors: Vec::new(),
2859 mem: "tgt".to_string(),
2860 title: "Req One".to_string(),
2861 entity_type: "req".to_string(),
2862 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2863 metadata: IndexMap::new(),
2864 relations: Vec::new(),
2865 dry_run: false,
2866 },
2867 actor,
2868 Some(&client),
2869 None,
2870 )
2871 .expect("target entity creates");
2872
2873 (tmp, engine, src_entity, tgt_entity)
2874 }
2875
2876 #[test]
2877 fn cross_different_schema_admits_declared_edge() {
2878 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2879 let (actor, client) = cli_actor();
2880 let outcome = engine
2881 .relate_entity(
2882 RelateEntityArgs {
2883 source: src.id.clone(),
2884 expected_hash: Some(src.content_hash.clone()),
2885 rel_type: "ADDRESSES".to_string(),
2886 target: tgt.id.clone(),
2887 remove: false,
2888 description: None,
2889 dry_run: false,
2890 },
2891 actor,
2892 Some(&client),
2893 None,
2894 )
2895 .expect("declared cross-mem edge admits");
2896 assert_eq!(outcome.rel_type, "ADDRESSES");
2897 }
2898
2899 #[test]
2905 fn same_name_different_version_uses_intra_mem_vocabulary() {
2906 let tmp = TempDir::new().unwrap();
2907
2908 let manifest_for = |version: &str| {
2909 format!(
2910 r#"name: same-dom
2911version: {version}
2912description: same-domain schema
2913when_to_use: tests
2914types:
2915 - doc
2916relationships:
2917 mode: strict
2918 definitions:
2919 - name: IMPLEMENTS
2920 description: intra-mem vocabulary
2921 default_weight: 1.0
2922 - name: _default
2923 description: fallback
2924 default_weight: 1.0
2925community:
2926 resolution: 1.0
2927 seed: 42
2928"#
2929 )
2930 };
2931 let schemas_dir = tmp.path().join("schemas");
2932 std::fs::create_dir_all(&schemas_dir).unwrap();
2933 write_schema_files(
2936 &schemas_dir,
2937 "same-dom-0.1.0",
2938 &manifest_for("0.1.0"),
2939 &[("doc", &make_type_yaml("doc"))],
2940 );
2941 write_schema_files(
2942 &schemas_dir,
2943 "same-dom-0.2.0",
2944 &manifest_for("0.2.0"),
2945 &[("doc", &make_type_yaml("doc"))],
2946 );
2947
2948 let src_dir = tmp.path().join("mem-src");
2949 let tgt_dir = tmp.path().join("mem-tgt");
2950 std::fs::create_dir_all(&src_dir).unwrap();
2951 std::fs::create_dir_all(&tgt_dir).unwrap();
2952 let src_pin = SchemaRef::new("same-dom", semver::Version::new(0, 1, 0));
2953 let tgt_pin = SchemaRef::new("same-dom", semver::Version::new(0, 2, 0));
2954 let mut engine = Engine::from_mounts_with_schemas_dir(
2955 vec![
2956 (
2957 folder_mount_with_pin("src", src_dir.clone(), src_pin),
2958 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2959 ),
2960 (
2961 folder_mount_with_pin("tgt", tgt_dir.clone(), tgt_pin),
2962 Box::new(FilesystemMemWriter::new(tgt_dir)) as Box<dyn MemBackend>,
2963 ),
2964 ],
2965 Some(&schemas_dir),
2966 )
2967 .expect("same-domain two-version engine constructs");
2968
2969 let mut settings = WorkspaceSettings::default();
2970 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2971 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2972 settings.cross_mem_links = links;
2973 engine.set_settings(settings);
2974
2975 let (actor, client) = cli_actor();
2976 let mk_entity = |engine: &mut Engine, mem: &str, title: &str| {
2977 engine
2978 .create_entity(
2979 CreateEntityArgs {
2980 anchors: Vec::new(),
2981 mem: mem.to_string(),
2982 title: title.to_string(),
2983 entity_type: "doc".to_string(),
2984 sections: IndexMap::from_iter([(
2985 "body".to_string(),
2986 "seed".to_string(),
2987 )]),
2988 metadata: IndexMap::new(),
2989 relations: Vec::new(),
2990 dry_run: false,
2991 },
2992 actor,
2993 Some(&client),
2994 None,
2995 )
2996 .expect("entity creates")
2997 };
2998 let src_entity = mk_entity(&mut engine, "src", "Doc A");
2999 let tgt_entity = mk_entity(&mut engine, "tgt", "Doc B");
3000
3001 let outcome = engine
3002 .relate_entity(
3003 RelateEntityArgs {
3004 source: src_entity.id.clone(),
3005 expected_hash: Some(src_entity.content_hash.clone()),
3006 rel_type: "IMPLEMENTS".to_string(),
3007 target: tgt_entity.id.clone(),
3008 remove: false,
3009 description: None,
3010 dry_run: false,
3011 },
3012 actor,
3013 Some(&client),
3014 None,
3015 )
3016 .expect("same-domain edge uses the intra-schema vocabulary across versions");
3017 assert_eq!(outcome.rel_type, "IMPLEMENTS");
3018 }
3019
3020 #[test]
3021 fn cross_different_schema_unknown_rel_type_returns_invalid_rel_type() {
3022 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3026 let (actor, client) = cli_actor();
3027 let err = engine
3028 .relate_entity(
3029 RelateEntityArgs {
3030 source: src.id.clone(),
3031 expected_hash: Some(src.content_hash.clone()),
3032 rel_type: "IMPLEMENTS".to_string(),
3033 target: tgt.id.clone(),
3034 remove: false,
3035 description: None,
3036 dry_run: false,
3037 },
3038 actor,
3039 Some(&client),
3040 None,
3041 )
3042 .unwrap_err();
3043 match err {
3044 EngineError::Validation(
3045 crate::runtime_validator::ValidationError::InvalidRelationshipType {
3046 input,
3047 allowed,
3048 ..
3049 },
3050 ) => {
3051 assert_eq!(input, "IMPLEMENTS");
3052 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
3053 assert!(names.iter().any(|n| n == "ADDRESSES"));
3054 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
3055 }
3056 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
3057 }
3058 }
3059
3060 #[test]
3061 fn cross_different_schema_shape_violation_returns_invalid_rel_shape() {
3062 }
3079
3080 #[test]
3081 fn cross_different_schema_no_matching_entry_returns_edge_not_declared() {
3082 let tmp = TempDir::new().unwrap();
3085 let src_manifest = r#"name: src-cv
3086version: 0.1.0
3087description: source schema
3088when_to_use: tests
3089types:
3090 - doc
3091relationships:
3092 mode: strict
3093 definitions:
3094 - name: IMPLEMENTS
3095 description: intra-mem
3096 default_weight: 1.0
3097 - name: _default
3098 description: fallback
3099 default_weight: 1.0
3100cross_mem_relationships:
3101 - to_schema: tgt-cv
3102 definitions:
3103 - name: ADDRESSES
3104 description: outbound
3105 default_weight: 1.0
3106 source_types: [doc]
3107 target_types: [req]
3108community:
3109 resolution: 1.0
3110 seed: 42
3111"#;
3112 let other_manifest = r#"name: other-cv
3114version: 0.1.0
3115description: foreign schema
3116when_to_use: tests
3117types:
3118 - thing
3119relationships:
3120 mode: strict
3121 definitions:
3122 - name: _default
3123 description: fallback
3124 default_weight: 1.0
3125community:
3126 resolution: 1.0
3127 seed: 42
3128"#;
3129 let schemas_dir = tmp.path().join("schemas");
3130 std::fs::create_dir_all(&schemas_dir).unwrap();
3131 write_schema_files(
3132 &schemas_dir,
3133 "src-cv",
3134 src_manifest,
3135 &[("doc", &make_type_yaml("doc"))],
3136 );
3137 write_schema_files(
3138 &schemas_dir,
3139 "other-cv",
3140 other_manifest,
3141 &[("thing", &make_type_yaml("thing"))],
3142 );
3143 let src_dir = tmp.path().join("mem-src");
3144 let other_dir = tmp.path().join("mem-other");
3145 std::fs::create_dir_all(&src_dir).unwrap();
3146 std::fs::create_dir_all(&other_dir).unwrap();
3147
3148 let mut engine = Engine::from_mounts_with_schemas_dir(
3149 vec![
3150 (
3151 folder_mount_with_pin(
3152 "src",
3153 src_dir.clone(),
3154 SchemaRef::new("src-cv", semver::Version::new(0, 1, 0)),
3155 ),
3156 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
3157 ),
3158 (
3159 folder_mount_with_pin(
3160 "other",
3161 other_dir.clone(),
3162 SchemaRef::new("other-cv", semver::Version::new(0, 1, 0)),
3163 ),
3164 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
3165 ),
3166 ],
3167 Some(&schemas_dir),
3168 )
3169 .expect("engine constructs");
3170
3171 let mut settings = WorkspaceSettings::default();
3172 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
3173 links.insert("src".to_string(), CrossLinkValue::Wildcard);
3174 settings.cross_mem_links = links;
3175 engine.set_settings(settings);
3176
3177 let (actor, client) = cli_actor();
3178 let src_entity = engine
3179 .create_entity(
3180 CreateEntityArgs {
3181 anchors: Vec::new(),
3182 mem: "src".to_string(),
3183 title: "D".to_string(),
3184 entity_type: "doc".to_string(),
3185 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3186 metadata: IndexMap::new(),
3187 relations: Vec::new(),
3188 dry_run: false,
3189 },
3190 actor,
3191 Some(&client),
3192 None,
3193 )
3194 .unwrap();
3195 let other_entity = engine
3196 .create_entity(
3197 CreateEntityArgs {
3198 anchors: Vec::new(),
3199 mem: "other".to_string(),
3200 title: "T".to_string(),
3201 entity_type: "thing".to_string(),
3202 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3203 metadata: IndexMap::new(),
3204 relations: Vec::new(),
3205 dry_run: false,
3206 },
3207 actor,
3208 Some(&client),
3209 None,
3210 )
3211 .unwrap();
3212
3213 let err = engine
3214 .relate_entity(
3215 RelateEntityArgs {
3216 source: src_entity.id.clone(),
3217 expected_hash: Some(src_entity.content_hash.clone()),
3218 rel_type: "ADDRESSES".to_string(),
3219 target: other_entity.id.clone(),
3220 remove: false,
3221 description: None,
3222 dry_run: false,
3223 },
3224 actor,
3225 Some(&client),
3226 None,
3227 )
3228 .unwrap_err();
3229 match err {
3230 EngineError::CrossMemEdgeNotDeclared {
3231 source_schema,
3232 target_schema,
3233 rel_type,
3234 from_id,
3235 to_id,
3236 } => {
3237 assert_eq!(source_schema, "src-cv@0.1.0");
3238 assert_eq!(target_schema, "other-cv@0.1.0");
3239 assert_eq!(rel_type, "ADDRESSES");
3240 assert_eq!(from_id, src_entity.id.to_string());
3241 assert_eq!(to_id, other_entity.id.to_string());
3242 }
3243 other => panic!("expected CrossMemEdgeNotDeclared, got {other:?}"),
3244 }
3245 }
3246
3247 #[test]
3248 fn intra_mem_with_cross_mem_only_rel_type_returns_invalid_rel_type() {
3249 let (_tmp, mut engine, src, _tgt) = two_mem_engine();
3254 let (actor, client) = cli_actor();
3255 let intra_target = engine
3257 .create_entity(
3258 CreateEntityArgs {
3259 anchors: Vec::new(),
3260 mem: "src".to_string(),
3261 title: "Doc Two".to_string(),
3262 entity_type: "doc".to_string(),
3263 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3264 metadata: IndexMap::new(),
3265 relations: Vec::new(),
3266 dry_run: false,
3267 },
3268 actor,
3269 Some(&client),
3270 None,
3271 )
3272 .unwrap();
3273 let src_fresh = engine.get_entity(&src.id).unwrap();
3276 let err = engine
3277 .relate_entity(
3278 RelateEntityArgs {
3279 source: src.id.clone(),
3280 expected_hash: Some(src_fresh.content_hash.clone()),
3281 rel_type: "ADDRESSES".to_string(),
3282 target: intra_target.id.clone(),
3283 remove: false,
3284 description: None,
3285 dry_run: false,
3286 },
3287 actor,
3288 Some(&client),
3289 None,
3290 )
3291 .unwrap_err();
3292 match err {
3293 EngineError::Validation(
3294 crate::runtime_validator::ValidationError::InvalidRelationshipType {
3295 input,
3296 ..
3297 },
3298 ) => {
3299 assert_eq!(input, "ADDRESSES");
3300 }
3301 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
3302 }
3303 }
3304
3305 #[test]
3306 fn vocabulary_admissible_edge_blocked_by_policy_returns_cross_mem_link_not_allowed() {
3307 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3311 engine.set_settings(WorkspaceSettings::default());
3313 let (actor, client) = cli_actor();
3314 let err = engine
3315 .relate_entity(
3316 RelateEntityArgs {
3317 source: src.id.clone(),
3318 expected_hash: Some(src.content_hash.clone()),
3319 rel_type: "ADDRESSES".to_string(),
3320 target: tgt.id.clone(),
3321 remove: false,
3322 description: None,
3323 dry_run: false,
3324 },
3325 actor,
3326 Some(&client),
3327 None,
3328 )
3329 .unwrap_err();
3330 assert!(
3331 matches!(err, EngineError::CrossMemLinkNotAllowed { .. }),
3332 "expected CrossMemLinkNotAllowed, got {err:?}"
3333 );
3334 }
3335
3336 #[test]
3343 fn cross_mem_remove_bypasses_policy_after_revoke() {
3344 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3345 let (actor, client) = cli_actor();
3346
3347 let added = engine
3349 .relate_entity(
3350 RelateEntityArgs {
3351 source: src.id.clone(),
3352 expected_hash: Some(src.content_hash.clone()),
3353 rel_type: "ADDRESSES".to_string(),
3354 target: tgt.id.clone(),
3355 remove: false,
3356 description: None,
3357 dry_run: false,
3358 },
3359 actor,
3360 Some(&client),
3361 None,
3362 )
3363 .expect("declared cross-mem edge admits under grant");
3364 assert_eq!(added.action, RelateAction::Added);
3365
3366 engine.set_settings(WorkspaceSettings::default());
3368
3369 let add_err = engine
3372 .relate_entity(
3373 RelateEntityArgs {
3374 source: src.id.clone(),
3375 expected_hash: Some(added.content_hash.clone()),
3376 rel_type: "ADDRESSES".to_string(),
3377 target: tgt.id.clone(),
3378 remove: false,
3379 description: None,
3380 dry_run: false,
3381 },
3382 actor,
3383 Some(&client),
3384 None,
3385 )
3386 .unwrap_err();
3387 assert!(
3388 matches!(add_err, EngineError::CrossMemLinkNotAllowed { .. }),
3389 "add path must still refuse under denial, got {add_err:?}"
3390 );
3391
3392 let removed = engine
3395 .relate_entity(
3396 RelateEntityArgs {
3397 source: src.id.clone(),
3398 expected_hash: Some(added.content_hash.clone()),
3399 rel_type: "ADDRESSES".to_string(),
3400 target: tgt.id.clone(),
3401 remove: true,
3402 description: None,
3403 dry_run: false,
3404 },
3405 actor,
3406 Some(&client),
3407 None,
3408 )
3409 .expect("remove must bypass the policy gate post-revoke");
3410 assert_eq!(removed.action, RelateAction::Removed);
3411
3412 let outgoing = engine.store().outgoing(&src.id);
3414 assert!(
3415 !outgoing
3416 .iter()
3417 .any(|e| e.target == tgt.id && e.rel_type == "ADDRESSES"),
3418 "ADDRESSES edge must be gone after remove"
3419 );
3420 }
3421
3422 #[test]
3426 fn cross_mem_remove_of_absent_edge_under_denial_is_no_op() {
3427 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3428 engine.set_settings(WorkspaceSettings::default());
3430 let (actor, client) = cli_actor();
3431 let outcome = engine
3432 .relate_entity(
3433 RelateEntityArgs {
3434 source: src.id.clone(),
3435 expected_hash: Some(src.content_hash.clone()),
3436 rel_type: "ADDRESSES".to_string(),
3437 target: tgt.id.clone(),
3438 remove: true,
3439 description: None,
3440 dry_run: false,
3441 },
3442 actor,
3443 Some(&client),
3444 None,
3445 )
3446 .expect("absent-edge remove must not refuse on policy");
3447 assert!(
3448 matches!(outcome.action, RelateAction::NoOpAbsent),
3449 "expected NoOpAbsent, got {:?}",
3450 outcome.action
3451 );
3452 }
3453
3454 fn engine_with_tgt_capability(
3463 capability: MountCapability,
3464 ) -> (TempDir, Engine, CreateEntityOutcome) {
3465 let tmp = TempDir::new().unwrap();
3466
3467 let src_manifest = r#"name: src-al
3468version: 0.1.0
3469description: source schema with alias pointer
3470when_to_use: tests
3471types:
3472 - doc
3473relationships:
3474 mode: strict
3475 definitions:
3476 - name: ADDRESSES
3477 description: explicit cross-mem
3478 default_weight: 1.0
3479 - name: REFERENCES
3480 description: alias pointer
3481 default_weight: 1.0
3482 - name: _default
3483 description: fallback
3484 default_weight: 1.0
3485cross_mem_relationships:
3486 - to_schema: tgt-al
3487 definitions:
3488 - name: ADDRESSES
3489 description: explicit cross-mem
3490 default_weight: 1.0
3491 - name: REFERENCES
3492 description: alias-emitted cross-mem
3493 default_weight: 1.0
3494alias_target_rel_type: REFERENCES
3495community:
3496 resolution: 1.0
3497 seed: 42
3498"#;
3499 let tgt_manifest = r#"name: tgt-al
3500version: 0.1.0
3501description: target schema
3502when_to_use: tests
3503types:
3504 - req
3505relationships:
3506 mode: strict
3507 definitions:
3508 - name: _default
3509 description: fallback
3510 default_weight: 1.0
3511community:
3512 resolution: 1.0
3513 seed: 42
3514"#;
3515 let schemas_dir = tmp.path().join("schemas");
3516 std::fs::create_dir_all(&schemas_dir).unwrap();
3517 write_schema_files(
3518 &schemas_dir,
3519 "src-al",
3520 src_manifest,
3521 &[("doc", &make_type_yaml("doc"))],
3522 );
3523 write_schema_files(
3524 &schemas_dir,
3525 "tgt-al",
3526 tgt_manifest,
3527 &[("req", &make_type_yaml("req"))],
3528 );
3529
3530 let src_dir = tmp.path().join("mem-src");
3531 let tgt_dir = tmp.path().join("mem-tgt");
3532 std::fs::create_dir_all(&src_dir).unwrap();
3533 std::fs::create_dir_all(&tgt_dir).unwrap();
3534 std::fs::write(
3537 tgt_dir.join("req-one.md"),
3538 "---\ntype: req\n---\n# Req One\n\n## Body\n\nseed.\n",
3539 )
3540 .unwrap();
3541
3542 let src_writer = FilesystemMemWriter::new(src_dir.clone());
3543 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
3544 let src_pin = SchemaRef::new("src-al", semver::Version::new(0, 1, 0));
3545 let tgt_pin = SchemaRef::new("tgt-al", semver::Version::new(0, 1, 0));
3546
3547 let tgt_mount = Mount {
3548 mem: "tgt".to_string(),
3549 schema: Some(tgt_pin),
3550 storage: MountStorage::Folder {
3551 path: tgt_dir.clone(),
3552 },
3553 capability,
3554 lifecycle: MountLifecycle::Eager,
3555 cross_linkable: true,
3556 migration_target: None,
3557 };
3558 let mut engine = Engine::from_mounts_with_schemas_dir(
3559 vec![
3560 (
3561 folder_mount_with_pin("src", src_dir, src_pin),
3562 Box::new(src_writer) as Box<dyn MemBackend>,
3563 ),
3564 (tgt_mount, Box::new(tgt_writer) as Box<dyn MemBackend>),
3565 ],
3566 Some(&schemas_dir),
3567 )
3568 .expect("two-mem engine constructs");
3569
3570 let mut settings = WorkspaceSettings::default();
3571 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
3572 links.insert("src".to_string(), CrossLinkValue::Wildcard);
3573 settings.cross_mem_links = links;
3574 engine.set_settings(settings);
3575
3576 let (actor, client) = cli_actor();
3577 let src_entity = engine
3578 .create_entity(
3579 CreateEntityArgs {
3580 anchors: Vec::new(),
3581 mem: "src".to_string(),
3582 title: "Doc One".to_string(),
3583 entity_type: "doc".to_string(),
3584 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
3585 metadata: IndexMap::new(),
3586 relations: Vec::new(),
3587 dry_run: false,
3588 },
3589 actor,
3590 Some(&client),
3591 None,
3592 )
3593 .expect("source entity creates");
3594
3595 (tmp, engine, src_entity)
3596 }
3597
3598 fn assert_cross_mem_target_not_found(err: EngineError, expected_target: &str) {
3599 match err {
3600 EngineError::CrossMemTargetNotFound {
3601 target_id,
3602 target_mem,
3603 } => {
3604 assert_eq!(target_id, expected_target);
3605 assert_eq!(target_mem, "tgt");
3606 }
3607 other => panic!("expected CrossMemTargetNotFound, got {other:?}"),
3608 }
3609 }
3610
3611 #[test]
3616 fn relate_dry_run_to_missing_target_in_readonly_mem_refuses_identically() {
3617 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3618 let (actor, client) = cli_actor();
3619 let args = |dry_run: bool| RelateEntityArgs {
3620 source: src.id.clone(),
3621 expected_hash: Some(src.content_hash.clone()),
3622 rel_type: "ADDRESSES".to_string(),
3623 target: crate::EntityId::new("tgt", "missing"),
3624 remove: false,
3625 description: None,
3626 dry_run,
3627 };
3628 let rehearsed = engine
3629 .relate_entity(args(true), actor, Some(&client), None)
3630 .unwrap_err();
3631 let real = engine
3632 .relate_entity(args(false), actor, Some(&client), None)
3633 .unwrap_err();
3634 assert_eq!(format!("{rehearsed:?}"), format!("{real:?}"));
3635 assert_cross_mem_target_not_found(rehearsed, "tgt--missing");
3636 }
3637
3638 #[test]
3639 fn relate_to_missing_target_in_readonly_mem_refuses() {
3640 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3641 let (actor, client) = cli_actor();
3642 let err = engine
3643 .relate_entity(
3644 RelateEntityArgs {
3645 source: src.id.clone(),
3646 expected_hash: Some(src.content_hash.clone()),
3647 rel_type: "ADDRESSES".to_string(),
3648 target: crate::EntityId::new("tgt", "missing"),
3649 remove: false,
3650 description: None,
3651 dry_run: false,
3652 },
3653 actor,
3654 Some(&client),
3655 None,
3656 )
3657 .unwrap_err();
3658 assert_cross_mem_target_not_found(err, "tgt--missing");
3659 }
3660
3661 #[test]
3666 fn create_inline_relation_to_missing_target_in_readonly_mem_refuses() {
3667 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3668 let (actor, client) = cli_actor();
3669 let err = engine
3670 .create_entity(
3671 CreateEntityArgs {
3672 anchors: Vec::new(),
3673 mem: "src".to_string(),
3674 title: "Doc Two".to_string(),
3675 entity_type: "doc".to_string(),
3676 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3677 metadata: IndexMap::new(),
3678 relations: vec![crate::ops::RelateArg {
3679 target: crate::EntityId::new("tgt", "missing"),
3680 rel_type: "ADDRESSES".to_string(),
3681 description: None,
3682 }],
3683 dry_run: false,
3684 },
3685 actor,
3686 Some(&client),
3687 None,
3688 )
3689 .unwrap_err();
3690 assert_cross_mem_target_not_found(err, "tgt--missing");
3691 }
3692
3693 #[test]
3697 fn create_body_link_to_missing_target_in_readonly_mem_refuses() {
3698 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3699 let (actor, client) = cli_actor();
3700 let err = engine
3701 .create_entity(
3702 CreateEntityArgs {
3703 anchors: Vec::new(),
3704 mem: "src".to_string(),
3705 title: "Doc Three".to_string(),
3706 entity_type: "doc".to_string(),
3707 sections: IndexMap::from_iter([(
3708 "body".to_string(),
3709 "see [[tgt--missing]].".to_string(),
3710 )]),
3711 metadata: IndexMap::new(),
3712 relations: Vec::new(),
3713 dry_run: false,
3714 },
3715 actor,
3716 Some(&client),
3717 None,
3718 )
3719 .unwrap_err();
3720 assert_cross_mem_target_not_found(err, "tgt--missing");
3721 }
3722
3723 #[test]
3724 fn update_body_link_to_missing_target_in_readonly_mem_refuses() {
3725 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3726 let (actor, client) = cli_actor();
3727 let err = engine
3728 .update_entity(
3729 crate::engine::UpdateEntityArgs {
3730 anchors: Vec::new(),
3731 id: src.id.clone(),
3732 expected_hash: Some(src.content_hash.clone()),
3733 sections: IndexMap::from_iter([(
3734 "body".to_string(),
3735 "now see [[tgt--missing]].".to_string(),
3736 )]),
3737 append_sections: IndexMap::new(),
3738 patch_sections: IndexMap::new(),
3739 sections_unset: Vec::new(),
3740 metadata: IndexMap::new(),
3741 metadata_unset: Vec::new(),
3742 declare_relations: Vec::new(),
3743 dry_run: false,
3744 relations_unset: Vec::new(),
3745 anchors_unset: Vec::new(),
3746 },
3747 actor,
3748 Some(&client),
3749 None,
3750 )
3751 .unwrap_err();
3752 assert_cross_mem_target_not_found(err, "tgt--missing");
3753 }
3754
3755 #[test]
3759 fn body_link_to_existing_target_in_readonly_mem_admits_and_emits_edge() {
3760 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3761 let (actor, client) = cli_actor();
3762 let created = engine
3763 .create_entity(
3764 CreateEntityArgs {
3765 anchors: Vec::new(),
3766 mem: "src".to_string(),
3767 title: "Doc Four".to_string(),
3768 entity_type: "doc".to_string(),
3769 sections: IndexMap::from_iter([(
3770 "body".to_string(),
3771 "see [[tgt--req-one]].".to_string(),
3772 )]),
3773 metadata: IndexMap::new(),
3774 relations: Vec::new(),
3775 dry_run: false,
3776 },
3777 actor,
3778 Some(&client),
3779 None,
3780 )
3781 .expect("body link to existing read-only target admits");
3782 let outgoing = engine.store().outgoing(&created.id);
3783 assert!(
3784 outgoing.iter().any(|e| e.rel_type == "REFERENCES"
3785 && e.target == crate::EntityId::new("tgt", "req-one")),
3786 "alias REFERENCES edge to the read-only target must materialise; got {outgoing:?}"
3787 );
3788 }
3789
3790 #[test]
3794 fn body_link_to_missing_target_in_write_mem_still_stubs() {
3795 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::Write);
3796 let (actor, client) = cli_actor();
3797 let created = engine
3798 .create_entity(
3799 CreateEntityArgs {
3800 anchors: Vec::new(),
3801 mem: "src".to_string(),
3802 title: "Doc Five".to_string(),
3803 entity_type: "doc".to_string(),
3804 sections: IndexMap::from_iter([(
3805 "body".to_string(),
3806 "see [[tgt--missing]].".to_string(),
3807 )]),
3808 metadata: IndexMap::new(),
3809 relations: Vec::new(),
3810 dry_run: false,
3811 },
3812 actor,
3813 Some(&client),
3814 None,
3815 )
3816 .expect("forward reference into a Write sibling mem keeps stubbing");
3817 assert!(
3818 engine
3819 .store()
3820 .contains(&crate::EntityId::new("tgt", "missing")),
3821 "auto-stub must land for the Write-mem forward reference"
3822 );
3823 let outgoing = engine.store().outgoing(&created.id);
3824 assert!(
3825 outgoing.iter().any(|e| e.rel_type == "REFERENCES"),
3826 "alias edge must still emit for the stubbed target"
3827 );
3828 }
3829 }
3830
3831 #[test]
3840 fn batch_relate_applies_adds_and_removes_in_order_one_commit() {
3841 let tmp = TempDir::new().unwrap();
3842 let mem_dir = tmp.path().to_path_buf();
3843 let writer = FilesystemMemWriter::new(mem_dir.clone());
3844 let mut engine = Engine::from_mounts(vec![(
3845 folder_mount("specs", mem_dir),
3846 Box::new(writer) as Box<dyn MemBackend>,
3847 )])
3848 .unwrap();
3849 let (actor, client) = cli_actor();
3850
3851 for title in ["A", "B", "C"] {
3852 engine
3853 .create_entity(
3854 empty_create_args("specs", title),
3855 actor,
3856 Some(&client),
3857 None,
3858 )
3859 .unwrap();
3860 }
3861 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
3862 let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
3863 source: id(from),
3864 expected_hash: None,
3865 rel_type: "USES".to_string(),
3866 target: id(to),
3867 remove,
3868 description: None,
3869 dry_run: false,
3870 };
3871
3872 let result = engine
3873 .batch_relate(
3874 vec![
3875 (edge("a", "b", false), Some("add a-b".to_string())),
3876 (edge("a", "c", false), Some("add a-c".to_string())),
3877 (edge("a", "c", true), Some("undo a-c".to_string())),
3878 ],
3879 actor,
3880 Some(&client),
3881 false,
3882 )
3883 .unwrap();
3884 assert!(result.applied, "{result:?}");
3885 assert_eq!(result.succeeded, 3);
3886 assert_eq!(result.failed, 0);
3887 assert!(!result.write_id.is_empty(), "one real commit");
3888 let actions: Vec<&str> = result.results.iter().map(|r| r.action.as_str()).collect();
3889 assert_eq!(
3890 actions,
3891 vec!["added", "added", "removed"],
3892 "in-order application: the remove sees the same batch's add"
3893 );
3894
3895 let a = engine.get_entity(&id("a")).unwrap();
3897 assert_eq!(a.relationships.len(), 1, "{:?}", a.relationships);
3898 assert_eq!(a.relationships[0].rel_type, "USES");
3899 assert_eq!(a.relationships[0].target, id("b"));
3900 }
3901
3902 #[test]
3910 fn relate_dry_run_reports_would_be_stub_and_writes_nothing() {
3911 let tmp = TempDir::new().unwrap();
3912 let (mut engine, source) = engine_with_seed(&tmp, "Src");
3913 engine.set_mutation_clock(std::sync::Arc::new(|| {
3919 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_000_000)
3920 }));
3921 let (actor, client) = cli_actor();
3922 let absent = crate::EntityId::new("specs", "ghost-target");
3923 let args = |dry_run: bool| RelateEntityArgs {
3924 source: source.id.clone(),
3925 expected_hash: Some(source.content_hash.clone()),
3926 rel_type: "USES".to_string(),
3927 target: absent.clone(),
3928 remove: false,
3929 description: None,
3930 dry_run,
3931 };
3932
3933 let rehearsed = engine
3934 .relate_entity(args(true), actor, Some(&client), None)
3935 .unwrap();
3936 assert_eq!(rehearsed.action, RelateAction::Added);
3937 assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
3938 assert!(
3939 rehearsed.warnings.iter().any(
3940 |w| matches!(w, crate::ops::WarningHint::AutoStubCreated { stub_id, pending: true } if *stub_id == absent)
3941 ),
3942 "would-be stub must be reported as pending: {:?}",
3943 rehearsed.warnings
3944 );
3945 let rehearsed_stub = rehearsed
3948 .warnings
3949 .iter()
3950 .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
3951 .unwrap();
3952 assert_eq!(rehearsed_stub.code(), "AUTO_STUB_CREATED");
3953 let msg = rehearsed_stub.message();
3954 assert!(
3955 msg.contains("would be auto-created") && !msg.contains("stub auto-created."),
3956 "dry-run wording must be conditional: {msg}"
3957 );
3958 assert!(
3959 !engine.store().contains(&absent),
3960 "would-be stub reported, never created"
3961 );
3962 let stored = engine.store().get(&source.id).unwrap();
3965 assert_eq!(stored.content_hash, source.content_hash);
3966 assert_ne!(rehearsed.content_hash, source.content_hash);
3967 assert!(stored.relationships.is_empty(), "no edge landed");
3968
3969 let real = engine
3972 .relate_entity(args(false), actor, Some(&client), None)
3973 .unwrap();
3974 assert!(!real.write_id.is_empty(), "the real relate commits");
3975 assert_eq!(
3976 real.content_hash, rehearsed.content_hash,
3977 "prospective hash must equal the real post-write hash"
3978 );
3979 assert!(engine.store().get(&absent).expect("real call stubs").stub);
3980 let real_msg = real
3982 .warnings
3983 .iter()
3984 .find(|w| {
3985 matches!(
3986 w,
3987 crate::ops::WarningHint::AutoStubCreated { pending: false, .. }
3988 )
3989 })
3990 .expect("real relate carries the non-pending stub warning")
3991 .message();
3992 assert!(
3993 real_msg.contains("did not exist — stub auto-created."),
3994 "real wording unchanged: {real_msg}"
3995 );
3996 }
3997
3998 #[test]
4002 fn relate_dry_run_refuses_identically_to_real() {
4003 let tmp = TempDir::new().unwrap();
4004 let (mut engine, source) = engine_with_seed(&tmp, "Src");
4005 let (actor, client) = cli_actor();
4006 let args = |dry_run: bool| RelateEntityArgs {
4008 source: source.id.clone(),
4009 expected_hash: None,
4010 rel_type: "USES".to_string(),
4011 target: crate::EntityId("bad target with spaces".to_string()),
4012 remove: false,
4013 description: None,
4014 dry_run,
4015 };
4016 let rehearsed = engine
4017 .relate_entity(args(true), actor, Some(&client), None)
4018 .unwrap_err();
4019 let real = engine
4020 .relate_entity(args(false), actor, Some(&client), None)
4021 .unwrap_err();
4022 assert_eq!(
4023 format!("{rehearsed:?}"),
4024 format!("{real:?}"),
4025 "identical typed refusal"
4026 );
4027 assert_eq!(rehearsed.code(), real.code());
4028 }
4029
4030 #[test]
4037 fn batch_relate_dry_run_reports_receipt_and_commits_nothing() {
4038 let tmp = TempDir::new().unwrap();
4039 let mem_dir = tmp.path().to_path_buf();
4040 let writer = FilesystemMemWriter::new(mem_dir.clone());
4041 let mut engine = Engine::from_mounts(vec![(
4042 folder_mount("specs", mem_dir),
4043 Box::new(writer) as Box<dyn MemBackend>,
4044 )])
4045 .unwrap();
4046 let (actor, client) = cli_actor();
4047 for title in ["A", "B"] {
4048 engine
4049 .create_entity(
4050 empty_create_args("specs", title),
4051 actor,
4052 Some(&client),
4053 None,
4054 )
4055 .unwrap();
4056 }
4057 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4058 let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
4059 source: id(from),
4060 expected_hash: None,
4061 rel_type: "USES".to_string(),
4062 target: id(to),
4063 remove,
4064 description: None,
4065 dry_run: false,
4066 };
4067 let batch = || {
4068 vec![
4069 (edge("a", "b", false), None),
4070 (edge("a", "ghost", false), None), (edge("a", "b", true), None), ]
4073 };
4074 let head_before = engine
4075 .mem_head_sha("specs")
4076 .ok()
4077 .flatten()
4078 .unwrap_or_default();
4079
4080 let rehearsed = engine
4081 .batch_relate(batch(), actor, Some(&client), true)
4082 .unwrap();
4083 assert!(rehearsed.applied, "{rehearsed:?}");
4084 assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
4085 let actions: Vec<&str> = rehearsed
4086 .results
4087 .iter()
4088 .map(|r| r.action.as_str())
4089 .collect();
4090 assert_eq!(
4091 actions,
4092 vec!["added", "added", "removed"],
4093 "in-order rehearsal semantics"
4094 );
4095 assert!(!engine.store().contains(&id("ghost")), "no stub created");
4097 assert!(
4098 engine
4099 .get_entity(&id("a"))
4100 .unwrap()
4101 .relationships
4102 .is_empty(),
4103 "no edge landed"
4104 );
4105 let head_after = engine
4106 .mem_head_sha("specs")
4107 .ok()
4108 .flatten()
4109 .unwrap_or_default();
4110 assert_eq!(head_before, head_after, "no commit landed");
4111
4112 let real = engine
4114 .batch_relate(batch(), actor, Some(&client), false)
4115 .unwrap();
4116 assert!(real.applied, "{real:?}");
4117 assert!(!real.write_id.is_empty());
4118 }
4119
4120 #[test]
4124 fn batch_relate_dry_run_refuses_identically_to_real() {
4125 let tmp = TempDir::new().unwrap();
4126 let mem_dir = tmp.path().to_path_buf();
4127 let writer = FilesystemMemWriter::new(mem_dir.clone());
4128 let mut engine = Engine::from_mounts(vec![(
4129 folder_mount("specs", mem_dir),
4130 Box::new(writer) as Box<dyn MemBackend>,
4131 )])
4132 .unwrap();
4133 let (actor, client) = cli_actor();
4134 for title in ["A", "B"] {
4135 engine
4136 .create_entity(
4137 empty_create_args("specs", title),
4138 actor,
4139 Some(&client),
4140 None,
4141 )
4142 .unwrap();
4143 }
4144 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4145 let batch = || {
4146 vec![
4147 (
4148 RelateEntityArgs {
4149 source: id("a"),
4150 expected_hash: None,
4151 rel_type: "USES".to_string(),
4152 target: id("b"),
4153 remove: false,
4154 description: None,
4155 dry_run: false,
4156 },
4157 None,
4158 ),
4159 (
4160 RelateEntityArgs {
4161 source: id("a"),
4162 expected_hash: None,
4163 rel_type: "USES".to_string(),
4164 target: crate::EntityId("bad target".to_string()),
4165 remove: false,
4166 description: None,
4167 dry_run: false,
4168 },
4169 None,
4170 ),
4171 ]
4172 };
4173 let rehearsed = engine
4174 .batch_relate(batch(), actor, Some(&client), true)
4175 .unwrap();
4176 let real = engine
4177 .batch_relate(batch(), actor, Some(&client), false)
4178 .unwrap();
4179 assert!(!rehearsed.applied && !real.applied);
4180 let envelope = |r: &crate::ops::BatchResult| {
4181 r.results
4182 .iter()
4183 .map(|e| {
4184 (
4185 e.id.to_string(),
4186 e.action.clone(),
4187 e.error.as_ref().map(|err| {
4188 (err.code.clone(), err.message.clone(), err.details.clone())
4189 }),
4190 )
4191 })
4192 .collect::<Vec<_>>()
4193 };
4194 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
4195 assert!(
4196 engine
4197 .get_entity(&id("a"))
4198 .unwrap()
4199 .relationships
4200 .is_empty(),
4201 "neither run landed the valid entry"
4202 );
4203 }
4204
4205 #[test]
4210 fn batch_relate_refuses_whole_batch_reporting_every_failure() {
4211 let tmp = TempDir::new().unwrap();
4212 let mem_dir = tmp.path().to_path_buf();
4213 let writer = FilesystemMemWriter::new(mem_dir.clone());
4214 let mut engine = Engine::from_mounts(vec![(
4215 folder_mount("specs", mem_dir),
4216 Box::new(writer) as Box<dyn MemBackend>,
4217 )])
4218 .unwrap();
4219 let (actor, client) = cli_actor();
4220
4221 let a = engine
4222 .create_entity(empty_create_args("specs", "A"), actor, Some(&client), None)
4223 .unwrap();
4224 engine
4225 .create_entity(empty_create_args("specs", "B"), actor, Some(&client), None)
4226 .unwrap();
4227 let head_before = engine
4228 .mem_head_sha("specs")
4229 .ok()
4230 .flatten()
4231 .unwrap_or_default();
4232 let count_before = engine.store().all_entities().count();
4233
4234 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4235 let result = engine
4236 .batch_relate(
4237 vec![
4238 (
4240 RelateEntityArgs {
4241 source: id("a"),
4242 expected_hash: None,
4243 rel_type: "USES".to_string(),
4244 target: id("b"),
4245 remove: false,
4246 description: None,
4247 dry_run: false,
4248 },
4249 None,
4250 ),
4251 (
4253 RelateEntityArgs {
4254 source: id("ghost"),
4255 expected_hash: None,
4256 rel_type: "USES".to_string(),
4257 target: id("b"),
4258 remove: false,
4259 description: None,
4260 dry_run: false,
4261 },
4262 None,
4263 ),
4264 (
4266 RelateEntityArgs {
4267 source: id("b"),
4268 expected_hash: Some("definitely-wrong".to_string()),
4269 rel_type: "USES".to_string(),
4270 target: id("a"),
4271 remove: false,
4272 description: None,
4273 dry_run: false,
4274 },
4275 None,
4276 ),
4277 ],
4278 actor,
4279 Some(&client),
4280 false,
4281 )
4282 .unwrap();
4283 assert!(!result.applied);
4284 assert_eq!(result.failed, 2, "{result:?}");
4285 assert!(result.write_id.is_empty());
4286 let codes: Vec<(usize, &str)> = result
4287 .results
4288 .iter()
4289 .enumerate()
4290 .filter(|(_, r)| r.action == "error")
4291 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
4292 .collect();
4293 assert_eq!(
4294 codes,
4295 vec![(1, "ENTITY_NOT_FOUND"), (2, "HASH_MISMATCH")],
4296 "every failing entry named with index + typed code: {result:?}"
4297 );
4298 assert_eq!(result.results[0].action, "not_applied");
4299
4300 let a_after = engine.get_entity(&a.id).unwrap();
4303 assert!(
4304 a_after.relationships.is_empty(),
4305 "staged edge must roll back: {:?}",
4306 a_after.relationships
4307 );
4308 assert_eq!(a_after.content_hash, a.content_hash);
4309 let head_after = engine
4310 .mem_head_sha("specs")
4311 .ok()
4312 .flatten()
4313 .unwrap_or_default();
4314 assert_eq!(head_before, head_after, "mem head unmoved");
4315 assert_eq!(engine.store().all_entities().count(), count_before);
4316 }
4317}
4318
4319#[cfg(test)]
4320mod derivation_tests {
4321 use indexmap::IndexMap;
4322 use tempfile::TempDir;
4323
4324 use crate::backend::MemBackend;
4325 use crate::engine::{
4326 CreateEntityArgs, Engine, RelateAction, RelateEntityArgs, UpdateEntityArgs,
4327 };
4328 use crate::ops::WarningHint;
4329 use crate::storage::FilesystemMemWriter;
4330 use crate::vcs::Actor;
4331 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4332
4333 fn deriv_schema() -> std::sync::Arc<memstead_schema::Schema> {
4337 let manifest = r#"name: deriv
4338version: 0.1.0
4339description: derivation fixture
4340when_to_use: tests
4341types:
4342 - note
4343relationships:
4344 mode: strict
4345 definitions:
4346 - name: PART_OF
4347 description: hier
4348 default_weight: 3.0
4349 - name: DERIVED_FROM
4350 description: source derives from target
4351 default_weight: 2.0
4352 derivation: true
4353 - name: SUPPORTS
4354 description: plain edge
4355 default_weight: 1.0
4356 - name: _default
4357 description: fallback
4358 default_weight: 1.0
4359community:
4360 resolution: 1.0
4361 seed: 42
4362"#;
4363 let type_yaml = r#"name: note
4364description: t
4365when_to_use: tests
4366sections:
4367 - key: body
4368 heading: Body
4369 required: true
4370 search_weight: 10.0
4371 catch_all: true
4372 write_rules: []
4373metadata_fields: []
4374title_weight: 100.0
4375text_fields:
4376 - body
4377hierarchy_relationship: PART_OF
4378no_self_loop_relationships: []
4379updatable_fields:
4380 - title
4381 - body
4382health_required_fields:
4383 - body
4384staleness_threshold_days: 90
4385write_rules: []
4386"#;
4387 std::sync::Arc::new(
4388 memstead_schema::loader::load_schema_from_memory(
4389 manifest,
4390 &[("note".to_string(), type_yaml.to_string())],
4391 )
4392 .expect("fixture schema loads"),
4393 )
4394 }
4395
4396 fn engine_at(tmp: &TempDir) -> Engine {
4397 let mem_dir = tmp.path().to_path_buf();
4398 let writer = FilesystemMemWriter::new(mem_dir.clone());
4399 let mount = Mount {
4400 mem: "m".to_string(),
4401 schema: Some("deriv@0.1.0".parse().unwrap()),
4402 storage: MountStorage::Folder { path: mem_dir },
4403 capability: MountCapability::Write,
4404 lifecycle: MountLifecycle::Eager,
4405 cross_linkable: true,
4406 migration_target: None,
4407 };
4408 Engine::from_mounts_with_schemas_dir_and_extra(
4409 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
4410 None,
4411 vec![deriv_schema()],
4412 )
4413 .unwrap()
4414 }
4415
4416 fn note(title: &str, body: &str) -> CreateEntityArgs {
4417 CreateEntityArgs {
4418 anchors: Vec::new(),
4419 mem: "m".to_string(),
4420 title: title.to_string(),
4421 entity_type: "note".to_string(),
4422 sections: IndexMap::from_iter([("body".to_string(), body.to_string())]),
4423 metadata: IndexMap::new(),
4424 relations: Vec::new(),
4425 dry_run: false,
4426 }
4427 }
4428
4429 fn relate_args(from: &crate::EntityId, rel: &str, to: &crate::EntityId) -> RelateEntityArgs {
4430 RelateEntityArgs {
4431 source: from.clone(),
4432 expected_hash: None,
4433 rel_type: rel.to_string(),
4434 target: to.clone(),
4435 remove: false,
4436 description: None,
4437 dry_run: false,
4438 }
4439 }
4440
4441 #[test]
4446 fn derivation_write_edit_report_reassert_clear() {
4447 let tmp = TempDir::new().unwrap();
4448 let mut engine = engine_at(&tmp);
4449 let target = engine
4450 .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4451 .unwrap();
4452 let source = engine
4453 .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4454 .unwrap();
4455
4456 let added = engine
4458 .relate_entity(
4459 relate_args(&source.id, "DERIVED_FROM", &target.id),
4460 Actor::Cli,
4461 None,
4462 None,
4463 )
4464 .unwrap();
4465 assert_eq!(added.action, RelateAction::Added);
4466 assert!(
4467 engine.derivation_report("m").unwrap().is_empty(),
4468 "freshly baselined edge must not report"
4469 );
4470
4471 let t_now = engine.get_entity(&target.id).unwrap().content_hash.clone();
4473 engine
4474 .update_entity(
4475 UpdateEntityArgs {
4476 anchors: Vec::new(),
4477 id: target.id.clone(),
4478 expected_hash: Some(t_now),
4479 sections: IndexMap::from_iter([("body".to_string(), "v2".to_string())]),
4480 append_sections: IndexMap::new(),
4481 patch_sections: IndexMap::new(),
4482 sections_unset: Vec::new(),
4483 metadata: IndexMap::new(),
4484 metadata_unset: Vec::new(),
4485 declare_relations: Vec::new(),
4486 dry_run: false,
4487 relations_unset: Vec::new(),
4488 anchors_unset: Vec::new(),
4489 },
4490 Actor::Cli,
4491 None,
4492 None,
4493 )
4494 .unwrap();
4495 let report = engine.derivation_report("m").unwrap();
4496 assert_eq!(report.len(), 1, "{report:?}");
4497 assert_eq!(report[0].source, source.id);
4498 assert_eq!(report[0].rel_type, "DERIVED_FROM");
4499 assert_eq!(report[0].target, target.id);
4500 assert_eq!(report[0].state, "stale");
4501 assert!(report[0].baseline.is_some());
4502
4503 let s_now = engine.get_entity(&source.id).unwrap().content_hash.clone();
4508 engine
4509 .update_entity(
4510 UpdateEntityArgs {
4511 anchors: Vec::new(),
4512 id: source.id.clone(),
4513 expected_hash: Some(s_now),
4514 sections: IndexMap::from_iter([(
4515 "body".to_string(),
4516 "conclusion v2".to_string(),
4517 )]),
4518 append_sections: IndexMap::new(),
4519 patch_sections: IndexMap::new(),
4520 sections_unset: Vec::new(),
4521 metadata: IndexMap::new(),
4522 metadata_unset: Vec::new(),
4523 declare_relations: Vec::new(),
4524 dry_run: false,
4525 relations_unset: Vec::new(),
4526 anchors_unset: Vec::new(),
4527 },
4528 Actor::Cli,
4529 None,
4530 None,
4531 )
4532 .unwrap();
4533 let report = engine.derivation_report("m").unwrap();
4534 assert_eq!(report.len(), 1, "source edit adds nothing: {report:?}");
4535 assert_eq!(report[0].state, "stale");
4536
4537 let md_before = std::fs::read(tmp.path().join("source.md")).unwrap();
4541 let hash_before = engine.get_entity(&source.id).unwrap().content_hash.clone();
4542 let refreshed = engine
4543 .relate_entity(
4544 relate_args(&source.id, "DERIVED_FROM", &target.id),
4545 Actor::Cli,
4546 None,
4547 None,
4548 )
4549 .unwrap();
4550 assert_eq!(refreshed.action, RelateAction::NoOpAlreadyPresent);
4551 assert_eq!(refreshed.content_hash, hash_before, "_hash unchanged");
4552 assert!(
4553 !refreshed.write_id.is_empty(),
4554 "the sidecar refresh persists via a real commit"
4555 );
4556 assert!(
4557 refreshed
4558 .warnings
4559 .iter()
4560 .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4561 "the refresh is STATED, never a bare no-op: {:?}",
4562 refreshed.warnings
4563 );
4564 let md_after = std::fs::read(tmp.path().join("source.md")).unwrap();
4565 assert_eq!(md_before, md_after, "baselines never touch the markdown");
4566 assert!(
4567 engine.derivation_report("m").unwrap().is_empty(),
4568 "re-assert clears the staleness"
4569 );
4570
4571 engine
4574 .relate_entity(
4575 relate_args(&source.id, "SUPPORTS", &target.id),
4576 Actor::Cli,
4577 None,
4578 None,
4579 )
4580 .unwrap();
4581 let noop = engine
4582 .relate_entity(
4583 relate_args(&source.id, "SUPPORTS", &target.id),
4584 Actor::Cli,
4585 None,
4586 None,
4587 )
4588 .unwrap();
4589 assert_eq!(noop.action, RelateAction::NoOpAlreadyPresent);
4590 assert!(noop.write_id.is_empty(), "undeclared no-op stays bare");
4591 assert!(
4592 !noop
4593 .warnings
4594 .iter()
4595 .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4596 );
4597 assert!(engine.derivation_report("m").unwrap().is_empty());
4599 }
4600
4601 #[test]
4606 fn missing_baseline_reports_unbaselined_never_fabricated() {
4607 let tmp = TempDir::new().unwrap();
4608 let mut engine = engine_at(&tmp);
4609 let target = engine
4610 .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4611 .unwrap();
4612 let source = engine
4613 .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4614 .unwrap();
4615 engine
4616 .relate_entity(
4617 relate_args(&source.id, "DERIVED_FROM", &target.id),
4618 Actor::Cli,
4619 None,
4620 None,
4621 )
4622 .unwrap();
4623 let sidecar = tmp.path().join(".memstead").join("derivations.json");
4625 assert!(sidecar.exists(), "baseline persisted to the sidecar");
4626 assert!(
4627 engine
4628 .get_entity(&crate::EntityId::new("m", "derivations"))
4629 .is_none()
4630 );
4631
4632 std::fs::remove_file(&sidecar).unwrap();
4635 let report = engine.derivation_report("m").unwrap();
4636 assert_eq!(report.len(), 1, "{report:?}");
4637 assert_eq!(report[0].state, "unbaselined");
4638 assert!(report[0].baseline.is_none());
4639 }
4640}