1use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::generator::generate_markdown;
8use crate::entity::parser::parse_markdown;
9use crate::entity::store_builder::push_entities_into_store;
10use crate::entity::{Entity, EntityId, Relationship, normalise_description};
11use crate::ops::WarningHint;
12use crate::provenance::{Provenance, ProvenanceKind};
13use crate::runtime_validator::{
14 CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
15 validate_rel_type,
16};
17use crate::vcs::{Actor, ClientId, CommitContext};
18use crate::workspace::MountCapability;
19use memstead_schema::SchemaRef;
20
21use super::super::{Engine, EngineError, RelateAction, RelateEntityArgs, RelateEntityOutcome};
22use super::{
23 make_stub, unknown_type_error, validate_description_posture, validate_relation_target_grammar,
24};
25
26pub(super) struct PreparedRelate {
33 pub(super) mount_idx: usize,
34 pub(super) source_mem: String,
35 pub(super) from: EntityId,
36 pub(super) to: EntityId,
37 pub(super) rel_type: String,
38 pub(super) action: RelateAction,
39 pub(super) file_path: String,
40 pub(super) markdown: String,
41 pub(super) warnings: Vec<WarningHint>,
42 pub(super) stub_target: Option<EntityId>,
47 pub(super) stub_kind: crate::entity::StubKind,
52 pub(super) type_def: std::sync::Arc<memstead_schema::TypeDefinition>,
53}
54
55pub(super) enum RelatePrepareOutcome {
57 Done(RelateEntityOutcome),
61 Prepared(PreparedRelate),
63}
64
65impl Engine {
66 pub fn relate_entity(
85 &mut self,
86 args: RelateEntityArgs,
87 actor: Actor,
88 client: Option<&ClientId>,
89 note: Option<&str>,
90 ) -> Result<RelateEntityOutcome, EngineError> {
91 let source_mem = args.source.mem().to_string();
92 let target_mem = args.target.mem().to_string();
93
94 let mut drift_warnings = self.reload_if_stale(Some(&source_mem));
102 if target_mem != source_mem && !self.mem_is_deferred(&target_mem) {
103 drift_warnings.append(&mut self.reload_if_stale(Some(&target_mem)));
110 }
111 let cycle_guard_needs_full = !args.remove
126 && self.schemas.get(&source_mem).is_some_and(|s| {
127 s.relationship_acyclic(&args.rel_type)
128 || s.acyclic_set_containing(&args.rel_type).is_some()
129 });
130 let signal_diff_needs_full = [source_mem.as_str(), args.target.mem()].iter().any(|m| {
131 self.schemas
132 .get(*m)
133 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
134 });
135 if cycle_guard_needs_full || signal_diff_needs_full {
136 self.ensure_mems_loaded(None);
137 }
138
139 let dry_run = args.dry_run;
140 let prepared = match self.prepare_relate(args, drift_warnings)? {
141 RelatePrepareOutcome::Done(outcome) => {
142 if !dry_run
153 && matches!(
154 outcome.action,
155 super::super::RelateAction::NoOpAlreadyPresent
156 )
157 {
158 return self.refresh_derivation_baseline_on_noop(outcome, actor, client, note);
159 }
160 return Ok(outcome);
161 }
162 RelatePrepareOutcome::Prepared(p) => p,
163 };
164
165 if dry_run {
172 let parse_result = parse_markdown(
173 &prepared.markdown,
174 &prepared.file_path,
175 prepared.type_def.as_ref(),
176 &prepared.source_mem,
177 )
178 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
179 let mut warnings = prepared.warnings;
183 if let Some(w) = self.note_missing_warning("relate_entity", note) {
184 warnings.push(w);
185 }
186 return Ok(RelateEntityOutcome {
187 from: prepared.from,
188 to: prepared.to,
189 rel_type: prepared.rel_type,
190 action: prepared.action,
191 content_hash: parse_result.entity.content_hash,
192 commit_sha: String::new(),
193 source: "explicit".to_string(),
194 orphan_stubs_removed: Vec::new(),
195 warnings,
196 });
197 }
198
199 let signal_snapshot = crate::ops::signals::snapshot_levels(
203 &self.store,
204 &self.schemas,
205 [&prepared.from, &prepared.to],
206 );
207
208 self.stage_prepared_relate(&prepared)?;
209
210 if let Some(schema) = self.schemas.get(&prepared.source_mem)
216 && super::rel_type_declares_derivation(schema, &prepared.rel_type)
217 {
218 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
219 let (from, rel, to) = (
220 prepared.from.to_string(),
221 prepared.rel_type.clone(),
222 prepared.to.to_string(),
223 );
224 match prepared.action {
225 RelateAction::Added => {
226 let hash = self
227 .store
228 .get(&prepared.to)
229 .map(|e| e.content_hash.clone())
230 .unwrap_or_default();
231 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
232 }
233 RelateAction::Removed => {
234 super::stage_derivation_sidecar(backend, |s| s.remove(&from, &rel, &to))?;
235 }
236 _ => {}
237 }
238 }
239
240 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
241 let commit_subject = format!("memstead: relate {}", prepared.from);
242 let ctx = CommitContext {
243 actor,
244 client: client.cloned(),
245 tool: Some("relate_entity"),
246 note: note.map(String::from),
247 role: self.current_role,
248 logical_operation_id: None,
249 entity_ids: None,
250 };
251 let commit_sha = backend.commit(&commit_subject, &ctx)?;
252
253 backend.append_provenance(
254 &Provenance::new(
255 std::time::SystemTime::now(),
256 ProvenanceKind::Relate,
257 Some(prepared.from.to_string()),
258 actor,
259 client.cloned(),
260 note.map(String::from),
261 )
262 .with_role(self.current_role),
263 )?;
264
265 self.record_self_write(prepared.mount_idx, &commit_sha);
266 self.stamp_mutation_versions(prepared.mount_idx);
267
268 let content_hash = self.apply_prepared_relate_to_store(&prepared)?;
269
270 let orphan_stubs_removed: Vec<EntityId> =
284 if matches!(prepared.action, RelateAction::Removed) {
285 super::gc_orphan_stubs_among(&mut self.store, std::iter::once(&prepared.to))
286 } else {
287 Vec::new()
288 };
289
290 self.invalidate_communities();
291 self.maintain_search_indexes(std::slice::from_ref(&prepared.from));
295
296 let PreparedRelate {
297 from,
298 to,
299 rel_type,
300 action,
301 mut warnings,
302 ..
303 } = prepared;
304
305 warnings.extend(crate::ops::signals::crossing_warnings(
309 &self.store,
310 &self.schemas,
311 &signal_snapshot,
312 ));
313
314 if let Some(w) = self.note_missing_warning("relate_entity", note) {
320 warnings.push(w);
321 }
322
323 Ok(RelateEntityOutcome {
324 from,
325 to,
326 rel_type,
327 action,
328 content_hash,
329 commit_sha,
330 source: "explicit".to_string(),
331 warnings,
332 orphan_stubs_removed,
333 })
334 }
335
336 fn refresh_derivation_baseline_on_noop(
347 &mut self,
348 mut outcome: RelateEntityOutcome,
349 actor: Actor,
350 client: Option<&ClientId>,
351 note: Option<&str>,
352 ) -> Result<RelateEntityOutcome, EngineError> {
353 let source_mem = outcome.from.mem().to_string();
354 let declared = self
355 .schemas
356 .get(&source_mem)
357 .is_some_and(|s| super::rel_type_declares_derivation(s, &outcome.rel_type));
358 if !declared {
359 return Ok(outcome);
360 }
361 let Some(mount_idx) = self.mounts.iter().position(|m| m.mount.mem == source_mem) else {
362 return Ok(outcome);
363 };
364 let hash = self
365 .store
366 .get(&outcome.to)
367 .map(|e| e.content_hash.clone())
368 .unwrap_or_default();
369 let (from, rel, to) = (
370 outcome.from.to_string(),
371 outcome.rel_type.clone(),
372 outcome.to.to_string(),
373 );
374 let backend = self.mounts[mount_idx].backend.as_ref();
375 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
376 let ctx = CommitContext {
377 actor,
378 client: client.cloned(),
379 tool: Some("relate_entity"),
380 note: note.map(String::from),
381 role: self.current_role,
382 logical_operation_id: None,
383 entity_ids: None,
384 };
385 let commit_sha = backend.commit(
386 &format!("memstead: derivation re-baseline {}", outcome.from),
387 &ctx,
388 )?;
389 self.record_self_write(mount_idx, &commit_sha);
390 self.stamp_mutation_versions(mount_idx);
391 outcome.commit_sha = commit_sha;
392 outcome
393 .warnings
394 .push(WarningHint::DerivationBaselineRefreshed {
395 from: outcome.from.clone(),
396 rel_type: outcome.rel_type.clone(),
397 to: outcome.to.clone(),
398 });
399 Ok(outcome)
400 }
401
402 fn prepare_relate(
410 &mut self,
411 args: RelateEntityArgs,
412 mut drift_warnings: Vec<WarningHint>,
413 ) -> Result<RelatePrepareOutcome, EngineError> {
414 let mut args = args;
415 let source_mem = args.source.mem().to_string();
416 let target_mem = args.target.mem().to_string();
417
418 validate_relation_target_grammar(&args.target)?;
434
435 let mut target_mem_uncreated = false;
441 if source_mem != target_mem {
442 if !args.remove {
453 super::validate_cross_mem_add_policy(self, &source_mem, &args.target)?;
454 }
455 if let Some(mount) = self.mount(&target_mem)
463 && mount.capability == MountCapability::ReadOnly
464 && !self.store.contains(&args.target)
465 && !matches!(
466 super::probe_deferred_target(self, &args.target)?,
467 super::DeferredTargetProbe::Exists
468 )
469 {
470 return Err(EngineError::CrossMemTargetNotFound {
471 target_id: args.target.to_string(),
472 target_mem: target_mem.clone(),
473 });
474 }
475 if self.mount(&target_mem).is_none() {
483 target_mem_uncreated = true;
484 }
485 }
486
487 if let Ok(canonical) = crate::entity::id::validate_rel_type(&args.rel_type) {
494 args.rel_type = canonical;
495 }
496 args.description = normalise_description(args.description.as_deref());
500
501 let mount_idx = self
502 .mounts
503 .iter()
504 .position(|m| m.mount.mem == source_mem)
505 .ok_or_else(|| self.unknown_mem_error(&source_mem))?;
506 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
507 return Err(EngineError::ReadOnlyMount(source_mem));
508 }
509
510 let schema = self
511 .schemas
512 .get(&source_mem)
513 .expect("schema present for every registered mount");
514
515 let target_schema_ref: Option<SchemaRef> = if source_mem == target_mem {
529 None
530 } else {
531 super::target_schema_ref_for_routing(self, &target_mem)
536 };
537 let cross_mem_different = match (&target_schema_ref, schema.id()) {
538 (Some(target), (src_name, _)) => target.name != src_name,
539 (None, _) => false,
540 };
541
542 let mut warnings: Vec<WarningHint> = Vec::new();
543 warnings.append(&mut drift_warnings);
545 if !cross_mem_different {
550 match validate_rel_type(&args.rel_type, schema.as_ref())? {
551 RelationshipCheck::Ok => {}
552 RelationshipCheck::OpenWarning(message) => {
553 warnings.push(WarningHint::UndeclaredRelationshipOpen {
554 rel_type: args.rel_type.clone(),
555 message,
556 });
557 }
558 }
559 }
560
561 let entity: Entity = self
565 .store
566 .get(&args.source)
567 .ok_or_else(|| EngineError::NotFound {
568 id: args.source.to_string(),
569 })?
570 .clone();
571
572 if entity.stub {
580 return Err(EngineError::StubCannotRelate {
581 id: args.source.to_string(),
582 });
583 }
584
585 let target_type = self
586 .store
587 .get(&args.target)
588 .map(|e| e.entity_type.clone())
589 .filter(|t| !t.is_empty());
590 let target_verified_in_storage = if target_type.is_none() && target_mem != source_mem {
596 matches!(
597 super::probe_deferred_target(self, &args.target)?,
598 super::DeferredTargetProbe::Exists
599 )
600 } else {
601 false
602 };
603 let target_type = match target_type {
604 Some(t) => Some(t),
605 None if target_verified_in_storage && !args.remove => {
606 super::peek_deferred_target_type(self, &args.target)?
607 }
608 None => None,
609 };
610 if target_verified_in_storage {
611 target_mem_uncreated = false;
616 }
617 if !args.remove {
632 validate_description_posture(
633 self,
634 &args.rel_type,
635 args.description.as_deref(),
636 &source_mem,
637 &target_mem,
638 &args.source,
639 &args.target,
640 )?;
641 super::validate_manual_authoring_posture(
649 self,
650 &args.rel_type,
651 &source_mem,
652 &args.source,
653 &args.target,
654 )?;
655 }
656
657 if !args.remove {
658 if cross_mem_different {
659 let target_ref = target_schema_ref
662 .as_ref()
663 .expect("target_schema_ref is Some when cross_mem_different");
664 match validate_cross_mem_edge(
665 &args.rel_type,
666 entity.entity_type.as_str(),
667 target_type.as_deref(),
668 schema.as_ref(),
669 target_ref,
670 ) {
671 CrossMemRelCheck::Ok => {}
672 CrossMemRelCheck::EdgeNotDeclared => {
673 let (src_name, src_version) = schema.id();
674 return Err(EngineError::CrossMemEdgeNotDeclared {
675 source_schema: SchemaRef::new(src_name, src_version).as_display(),
676 target_schema: target_ref.as_display(),
677 rel_type: args.rel_type.clone(),
678 from_id: args.source.to_string(),
679 to_id: args.target.to_string(),
680 });
681 }
682 CrossMemRelCheck::Invalid(v) => {
683 return Err(EngineError::Validation(v));
684 }
685 }
686 } else {
687 validate_rel_shape(
688 &args.rel_type,
689 entity.entity_type.as_str(),
690 target_type.as_deref(),
691 schema.as_ref(),
692 )?;
693 }
694 }
695
696 if let Some(expected) = args.expected_hash.as_deref()
697 && entity.content_hash != expected
698 {
699 return Err(EngineError::HashMismatch {
700 id: args.source.to_string(),
701 current: entity.content_hash.clone(),
702 is_stub: entity.stub,
703 });
704 }
705
706 if !args.remove {
712 super::validate_edge_acyclicity(
713 &self.store,
714 schema,
715 &args.source,
716 entity.entity_type.as_str(),
717 &args.target,
718 &args.rel_type,
719 )?;
720 }
721
722 let type_def = schema
723 .get_type(&entity.entity_type)
724 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
725
726 let mut next = entity.clone();
727 let already = next
728 .relationships
729 .iter()
730 .position(|r| r.rel_type == args.rel_type && r.target == args.target);
731
732 if args.remove && already.is_some() {
739 let other_relation_to_target_exists = entity
740 .relationships
741 .iter()
742 .any(|r| r.target == args.target && r.rel_type != args.rel_type);
743 if !other_relation_to_target_exists {
744 let mut surviving_sections: Vec<String> = Vec::new();
751 for (section_key, body) in entity.sections.iter() {
752 let inline_targets =
753 crate::entity::parser::extract_inline_links_lenient(body, &source_mem);
754 if inline_targets.iter().any(|t| t == &args.target) {
755 surviving_sections.push(section_key.clone());
756 }
757 }
758 if !surviving_sections.is_empty() {
759 return Err(EngineError::RelationHasBodyLinks {
760 from_id: args.source.to_string(),
761 to_id: args.target.to_string(),
762 rel_type: args.rel_type.clone(),
763 body_links: surviving_sections,
764 });
765 }
766 }
767 }
768
769 let action = if args.remove {
770 match already {
771 Some(idx) => {
772 next.relationships.remove(idx);
773 RelateAction::Removed
774 }
775 None => RelateAction::NoOpAbsent,
776 }
777 } else {
778 match already {
779 Some(_) => RelateAction::NoOpAlreadyPresent,
780 None => {
781 next.relationships.push(Relationship {
782 rel_type: args.rel_type.clone(),
783 target: args.target.clone(),
784 description: normalise_description(args.description.as_deref()),
785 });
786 RelateAction::Added
787 }
788 }
789 };
790
791 if matches!(action, RelateAction::Removed) {
799 let blocked: Vec<_> =
800 crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref())
801 .into_iter()
802 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
803 .collect();
804 if !blocked.is_empty() {
805 return Err(EngineError::RequiredOutgoingUnsatisfied {
806 entity_type: next.entity_type.clone(),
807 entity_id: args.source.to_string(),
808 missing: blocked,
809 });
810 }
811 let blocked: Vec<_> = crate::ops::health::unsatisfied_constraints(
818 &self.store,
819 &next,
820 type_def.as_ref(),
821 Some(&args.source),
822 )
823 .into_iter()
824 .filter(|v| {
825 matches!(
826 v,
827 crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { .. }
828 ) && v.severity() == memstead_schema::ConstraintSeverity::Block
829 })
830 .collect();
831 if !blocked.is_empty() {
832 return Err(EngineError::ConstraintUnsatisfied {
833 entity_type: next.entity_type.clone(),
834 entity_id: args.source.to_string(),
835 violations: blocked,
836 });
837 }
838 }
839
840 let mut stub_target: Option<EntityId> = None;
853 let mut stub_kind = crate::entity::StubKind::ForwardReference;
854 if matches!(action, RelateAction::Added) && !self.store.contains(&args.target) {
855 stub_target = Some(args.target.clone());
856 if target_verified_in_storage {
857 stub_kind = crate::entity::StubKind::LoadTime;
865 } else {
866 warnings.push(WarningHint::AutoStubCreated {
871 stub_id: args.target.clone(),
872 pending: args.dry_run,
873 });
874 }
875 if target_mem_uncreated {
882 warnings.push(WarningHint::CrossMemTargetMemUncreated {
883 from_mem: source_mem.clone(),
884 to_mem: target_mem.clone(),
885 target_id: args.target.clone(),
886 });
887 }
888 }
889
890 if matches!(
896 action,
897 RelateAction::NoOpAlreadyPresent | RelateAction::NoOpAbsent
898 ) {
899 match action {
900 RelateAction::NoOpAlreadyPresent => {
901 warnings.push(WarningHint::DuplicateRelationship {
902 rel_type: args.rel_type.clone(),
903 from: args.source.clone(),
904 to: args.target.clone(),
905 });
906 }
907 RelateAction::NoOpAbsent => {
908 warnings.push(WarningHint::NoSuchRelationship {
909 rel_type: args.rel_type.clone(),
910 from: args.source.clone(),
911 to: args.target.clone(),
912 });
913 }
914 _ => unreachable!(),
915 }
916 return Ok(RelatePrepareOutcome::Done(RelateEntityOutcome {
917 from: args.source,
918 to: args.target,
919 rel_type: args.rel_type,
920 action,
921 content_hash: entity.content_hash.clone(),
922 commit_sha: String::new(),
923 source: "explicit".to_string(),
924 warnings,
925 orphan_stubs_removed: Vec::new(),
928 }));
929 }
930
931 let today = self.now_iso();
939 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
940
941 let file_path = next.file_path.clone();
942 let markdown = generate_markdown(&next, type_def.as_ref());
943
944 Ok(RelatePrepareOutcome::Prepared(PreparedRelate {
945 mount_idx,
946 source_mem,
947 from: args.source,
948 to: args.target,
949 rel_type: args.rel_type,
950 action,
951 file_path,
952 markdown,
953 warnings,
954 stub_target,
955 stub_kind,
956 type_def,
957 }))
958 }
959
960 fn stage_prepared_relate(&mut self, p: &PreparedRelate) -> Result<(), EngineError> {
966 if let Some(stub_id) = &p.stub_target {
967 self.store
968 .upsert(stub_id.clone(), make_stub(stub_id, p.stub_kind.clone()));
969 }
970 self.mounts[p.mount_idx]
971 .backend
972 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())?;
973 Ok(())
974 }
975
976 fn apply_prepared_relate_to_store(
984 &mut self,
985 p: &PreparedRelate,
986 ) -> Result<String, EngineError> {
987 let parse_result = parse_markdown(
988 &p.markdown,
989 &p.file_path,
990 p.type_def.as_ref(),
991 &p.source_mem,
992 )
993 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
994 let content_hash = parse_result.entity.content_hash.clone();
995 let fallback = engine_fallback_type();
996 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
997 crate::entity::store_builder::remap_alias_target_edge_sources(
998 &mut self.store,
999 &self.schemas,
1000 );
1001 Ok(content_hash)
1002 }
1003
1004 pub fn batch_relate(
1041 &mut self,
1042 relates: Vec<(RelateEntityArgs, Option<String>)>,
1043 actor: Actor,
1044 client: Option<&ClientId>,
1045 dry_run: bool,
1046 ) -> Result<crate::ops::BatchResult, EngineError> {
1047 if relates.is_empty() {
1048 return Ok(crate::ops::BatchResult {
1049 orphan_stubs_removed: Vec::new(),
1050 errors_suppressed: 0,
1051 applied: true,
1052 results: Vec::new(),
1053 succeeded: 0,
1054 failed: 0,
1055 commit_sha: String::new(),
1056 });
1057 }
1058
1059 let mut touched_mems: Vec<String> = relates
1063 .iter()
1064 .flat_map(|(a, _)| [a.source.mem().to_string(), a.target.mem().to_string()])
1065 .collect();
1066 touched_mems.sort();
1067 touched_mems.dedup();
1068 let source_mems: std::collections::HashSet<&str> =
1074 relates.iter().map(|(a, _)| a.source.mem()).collect();
1075 touched_mems.retain(|m| source_mems.contains(m.as_str()) || !self.mem_is_deferred(m));
1076 for m in &touched_mems {
1077 self.reload_if_stale(Some(m));
1078 }
1079 if relates.iter().any(|(a, _)| {
1089 (!a.remove
1090 && self.schemas.get(a.source.mem()).is_some_and(|s| {
1091 s.relationship_acyclic(&a.rel_type)
1092 || s.acyclic_set_containing(&a.rel_type).is_some()
1093 }))
1094 || [a.source.mem(), a.target.mem()].iter().any(|m| {
1095 self.schemas
1096 .get(*m)
1097 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1098 })
1099 }) {
1100 self.ensure_mems_loaded(None);
1101 }
1102
1103 let store_snapshot = self.store.clone();
1108
1109 enum ItemState {
1110 Applied(&'static str),
1111 Noop,
1112 Error,
1113 }
1114 let mut items: Vec<(EntityId, ItemState)> = Vec::with_capacity(relates.len());
1115 let mut prepared: Vec<PreparedRelate> = Vec::new();
1116 let mut notes: Vec<Option<String>> = Vec::new();
1117 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1118
1119 for (i, (args, note)) in relates.into_iter().enumerate() {
1120 let source_id = args.source.clone();
1121 let mut args = args;
1126 args.dry_run = false;
1127 match self.prepare_relate(args, Vec::new()) {
1128 Ok(RelatePrepareOutcome::Done(_)) => {
1129 items.push((source_id, ItemState::Noop));
1130 }
1131 Ok(RelatePrepareOutcome::Prepared(p)) => {
1132 if let Err(e) = self.stage_prepared_relate(&p) {
1135 self.store = store_snapshot;
1136 self.discard_all_pending();
1137 return Err(e);
1138 }
1139 if let Err(e) = self.apply_prepared_relate_to_store(&p) {
1140 self.store = store_snapshot;
1141 self.discard_all_pending();
1142 return Err(e);
1143 }
1144 if let Some(schema) = self.schemas.get(&p.source_mem)
1151 && super::rel_type_declares_derivation(schema, &p.rel_type)
1152 {
1153 let backend = self.mounts[p.mount_idx].backend.as_ref();
1154 let (from, rel, to) =
1155 (p.from.to_string(), p.rel_type.clone(), p.to.to_string());
1156 let staged = match p.action {
1157 RelateAction::Added => {
1158 let hash = self
1159 .store
1160 .get(&p.to)
1161 .map(|e| e.content_hash.clone())
1162 .unwrap_or_default();
1163 super::stage_derivation_sidecar(backend, |s| {
1164 s.set(&from, &rel, &to, &hash)
1165 })
1166 }
1167 RelateAction::Removed => {
1168 super::stage_derivation_sidecar(backend, |s| {
1169 s.remove(&from, &rel, &to)
1170 })
1171 }
1172 _ => Ok(()),
1173 };
1174 if let Err(e) = staged {
1175 self.store = store_snapshot;
1176 self.discard_all_pending();
1177 return Err(e);
1178 }
1179 }
1180 let label = match p.action {
1181 RelateAction::Added => "added",
1182 RelateAction::Removed => "removed",
1183 _ => unreachable!("no-ops resolve to Done"),
1184 };
1185 items.push((source_id, ItemState::Applied(label)));
1186 prepared.push(p);
1187 notes.push(note);
1188 }
1189 Err(e) => {
1190 items.push((source_id, ItemState::Error));
1191 errors.push((i, e));
1192 }
1193 }
1194 }
1195
1196 if !errors.is_empty() {
1197 self.store = store_snapshot;
1199 self.discard_all_pending();
1200 let failed = errors.len();
1201 let mut error_map: std::collections::HashMap<usize, EngineError> =
1202 errors.into_iter().collect();
1203 let mut reported = 0usize;
1204 let mut suppressed = 0usize;
1205 let results: Vec<crate::ops::BatchEntry> = items
1206 .into_iter()
1207 .enumerate()
1208 .map(|(i, (id, _))| match error_map.remove(&i) {
1209 Some(e) => {
1210 if reported < Self::BATCH_ERROR_REPORT_CAP {
1211 reported += 1;
1212 crate::ops::BatchEntry {
1213 id,
1214 action: "error".to_string(),
1215 error: Some(super::update::batch_error_envelope(&e)),
1216 }
1217 } else {
1218 suppressed += 1;
1219 crate::ops::BatchEntry {
1220 id,
1221 action: "error".to_string(),
1222 error: None,
1223 }
1224 }
1225 }
1226 None => crate::ops::BatchEntry {
1227 id,
1228 action: "not_applied".to_string(),
1229 error: None,
1230 },
1231 })
1232 .collect();
1233 return Ok(crate::ops::BatchResult {
1234 orphan_stubs_removed: Vec::new(),
1235 errors_suppressed: suppressed,
1236 applied: false,
1237 results,
1238 succeeded: 0,
1239 failed,
1240 commit_sha: String::new(),
1241 });
1242 }
1243
1244 if dry_run {
1250 let removed_targets: Vec<EntityId> = prepared
1251 .iter()
1252 .filter(|p| matches!(p.action, RelateAction::Removed))
1253 .map(|p| p.to.clone())
1254 .collect();
1255 let orphan_stubs_removed =
1256 super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1257 self.store = store_snapshot;
1258 self.discard_all_pending();
1259 let succeeded = items.len();
1260 let results: Vec<crate::ops::BatchEntry> = items
1261 .into_iter()
1262 .map(|(id, state)| crate::ops::BatchEntry {
1263 id,
1264 action: match state {
1265 ItemState::Applied(label) => label.to_string(),
1266 ItemState::Noop => "noop".to_string(),
1267 ItemState::Error => unreachable!("refusal path returned above"),
1268 },
1269 error: None,
1270 })
1271 .collect();
1272 return Ok(crate::ops::BatchResult {
1273 orphan_stubs_removed,
1274 errors_suppressed: 0,
1275 applied: true,
1276 results,
1277 succeeded,
1278 failed: 0,
1279 commit_sha: String::new(),
1280 });
1281 }
1282
1283 let mut distinct_mounts: Vec<usize> = Vec::new();
1285 for p in &prepared {
1286 if !distinct_mounts.contains(&p.mount_idx) {
1287 distinct_mounts.push(p.mount_idx);
1288 }
1289 }
1290 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1291 for &m in &distinct_mounts {
1292 let mut entity_ids: Vec<String> = Vec::new();
1295 let mut edge_count = 0usize;
1296 for p in prepared.iter().filter(|p| p.mount_idx == m) {
1297 edge_count += 1;
1298 let s = p.from.to_string();
1299 if !entity_ids.contains(&s) {
1300 entity_ids.push(s);
1301 }
1302 }
1303 let subject = format!("memstead: batch-relate ({edge_count} edges)");
1304 let note_lines: Vec<String> = prepared
1309 .iter()
1310 .zip(notes.iter())
1311 .filter(|(p, _)| p.mount_idx == m)
1312 .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.from)))
1313 .collect();
1314 let ctx = CommitContext {
1315 actor,
1316 client: client.cloned(),
1317 tool: Some("batch_relate"),
1318 note: if note_lines.is_empty() {
1319 None
1320 } else {
1321 Some(note_lines.join("\n"))
1322 },
1323 role: self.current_role,
1324 logical_operation_id: None,
1325 entity_ids: Some(entity_ids),
1326 };
1327 match self.mounts[m].backend.commit(&subject, &ctx) {
1328 Ok(sha) => mount_commits.push((m, sha)),
1329 Err(e) => {
1330 self.store = store_snapshot;
1335 self.discard_all_pending();
1336 return Err(e.into());
1337 }
1338 }
1339 }
1340
1341 for (p, note) in prepared.iter().zip(notes.iter()) {
1343 let commit_sha = mount_commits
1344 .iter()
1345 .find(|(m, _)| *m == p.mount_idx)
1346 .map(|(_, s)| s.clone())
1347 .unwrap_or_default();
1348 self.mounts[p.mount_idx].backend.append_provenance(
1349 &Provenance::new(
1350 std::time::SystemTime::now(),
1351 ProvenanceKind::Relate,
1352 Some(p.from.to_string()),
1353 actor,
1354 client.cloned(),
1355 note.clone(),
1356 )
1357 .with_role(self.current_role),
1358 )?;
1359 self.record_self_write(p.mount_idx, &commit_sha);
1360 self.stamp_mutation_versions(p.mount_idx);
1361 }
1362
1363 let removed_targets: Vec<EntityId> = prepared
1366 .iter()
1367 .filter(|p| matches!(p.action, RelateAction::Removed))
1368 .map(|p| p.to.clone())
1369 .collect();
1370 let orphan_stubs_removed =
1371 super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1372
1373 self.invalidate_communities();
1374 self.invalidate_search_indexes();
1375
1376 let commit_sha = mount_commits
1377 .last()
1378 .map(|(_, s)| s.clone())
1379 .unwrap_or_default();
1380 let succeeded = items.len();
1381 let results: Vec<crate::ops::BatchEntry> = items
1382 .into_iter()
1383 .map(|(id, state)| crate::ops::BatchEntry {
1384 id,
1385 action: match state {
1386 ItemState::Applied(label) => label.to_string(),
1387 ItemState::Noop => "noop".to_string(),
1388 ItemState::Error => unreachable!("refusal path returned above"),
1389 },
1390 error: None,
1391 })
1392 .collect();
1393
1394 Ok(crate::ops::BatchResult {
1395 orphan_stubs_removed,
1396 errors_suppressed: 0,
1397 applied: true,
1398 results,
1399 succeeded,
1400 failed: 0,
1401 commit_sha,
1402 })
1403 }
1404
1405 pub fn relate(
1412 &mut self,
1413 from: &EntityId,
1414 to: &EntityId,
1415 rel_type: &str,
1416 remove: bool,
1417 ctx: &CommitContext<'_>,
1418 ) -> Result<RelateEntityOutcome, EngineError> {
1419 let args = RelateEntityArgs {
1420 source: from.clone(),
1421 expected_hash: None,
1422 rel_type: rel_type.to_string(),
1423 target: to.clone(),
1424 remove,
1425 description: None,
1426 dry_run: false,
1427 };
1428 self.relate_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1429 }
1430}
1431
1432#[cfg(test)]
1433mod tests {
1434
1435 use indexmap::IndexMap;
1436 use tempfile::TempDir;
1437
1438 use crate::backend::MemBackend;
1439 use crate::engine::test_helpers::*;
1440 use crate::engine::{CreateEntityArgs, Engine, EngineError, RelateAction, RelateEntityArgs};
1441 use crate::ops::WarningHint;
1442 use crate::storage::FilesystemMemWriter;
1443 use crate::vcs::{Actor, CommitContext};
1444
1445 #[test]
1446 fn relate_alias_delegates_to_relate_entity() {
1447 let tmp = TempDir::new().unwrap();
1452 let mem_dir = tmp.path().to_path_buf();
1453 let writer = FilesystemMemWriter::new(mem_dir.clone());
1454 let mut engine = Engine::from_mounts(vec![(
1455 folder_mount("specs", mem_dir),
1456 Box::new(writer) as Box<dyn MemBackend>,
1457 )])
1458 .unwrap();
1459
1460 let a = engine
1462 .create_entity(
1463 CreateEntityArgs {
1464 anchors: Vec::new(),
1465 mem: "specs".to_string(),
1466 title: "A".to_string(),
1467 entity_type: "spec".to_string(),
1468 sections: IndexMap::from_iter([
1469 ("identity".to_string(), "seed identity".to_string()),
1470 ("purpose".to_string(), "seed purpose".to_string()),
1471 ]),
1472 metadata: IndexMap::new(),
1473 relations: Vec::new(),
1474 dry_run: false,
1475 },
1476 Actor::Cli,
1477 None,
1478 None,
1479 )
1480 .unwrap();
1481 let b = engine
1482 .create_entity(
1483 CreateEntityArgs {
1484 anchors: Vec::new(),
1485 mem: "specs".to_string(),
1486 title: "B".to_string(),
1487 entity_type: "spec".to_string(),
1488 sections: IndexMap::from_iter([
1489 ("identity".to_string(), "seed identity".to_string()),
1490 ("purpose".to_string(), "seed purpose".to_string()),
1491 ]),
1492 metadata: IndexMap::new(),
1493 relations: Vec::new(),
1494 dry_run: false,
1495 },
1496 Actor::Cli,
1497 None,
1498 None,
1499 )
1500 .unwrap();
1501
1502 let ctx = CommitContext::internal();
1504 let result = engine.relate(&a.id, &b.id, "PART_OF", false, &ctx).unwrap();
1505 assert_eq!(result.from, a.id);
1506 assert_eq!(result.to, b.id);
1507 assert_eq!(result.rel_type, "PART_OF");
1508 let outgoing: Vec<_> = engine.store().outgoing(&a.id).to_vec();
1510 assert!(
1511 outgoing
1512 .iter()
1513 .any(|e| e.target == b.id && e.rel_type == "PART_OF")
1514 );
1515 }
1516
1517 #[test]
1518 fn relate_entity_appends_relationship_and_logs_provenance() {
1519 let tmp = TempDir::new().unwrap();
1520 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1521 let (actor, client) = cli_actor();
1522 let target = engine
1523 .create_entity(
1524 empty_create_args("specs", "Target"),
1525 actor,
1526 Some(&client),
1527 None,
1528 )
1529 .unwrap();
1530
1531 let outcome = engine
1532 .relate_entity(
1533 RelateEntityArgs {
1534 source: source.id.clone(),
1535 expected_hash: Some(source.content_hash.clone()),
1536 rel_type: "USES".to_string(),
1537 target: target.id.clone(),
1538 remove: false,
1539 description: None,
1540 dry_run: false,
1541 },
1542 actor,
1543 Some(&client),
1544 None,
1545 )
1546 .unwrap();
1547 assert_eq!(outcome.action, RelateAction::Added);
1548 assert_ne!(outcome.content_hash, source.content_hash);
1549 let edges = engine.store().outgoing(&source.id);
1551 assert!(
1552 edges
1553 .iter()
1554 .any(|e| e.rel_type == "USES" && e.target == target.id),
1555 "expected USES edge in store"
1556 );
1557 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
1559 assert!(log.contains("\"kind\":\"relate\""));
1560 }
1561
1562 #[test]
1563 fn relate_entity_no_op_when_already_present() {
1564 let tmp = TempDir::new().unwrap();
1565 let (mut engine, source) = engine_with_seed(&tmp, "Already");
1566 let (actor, client) = cli_actor();
1567 let target = engine
1568 .create_entity(empty_create_args("specs", "T2"), actor, Some(&client), None)
1569 .unwrap();
1570 let first = engine
1571 .relate_entity(
1572 RelateEntityArgs {
1573 source: source.id.clone(),
1574 expected_hash: Some(source.content_hash.clone()),
1575 rel_type: "USES".to_string(),
1576 target: target.id.clone(),
1577 remove: false,
1578 description: None,
1579 dry_run: false,
1580 },
1581 actor,
1582 Some(&client),
1583 None,
1584 )
1585 .unwrap();
1586 let second = engine
1587 .relate_entity(
1588 RelateEntityArgs {
1589 source: source.id.clone(),
1590 expected_hash: Some(first.content_hash.clone()),
1591 rel_type: "USES".to_string(),
1592 target: target.id.clone(),
1593 remove: false,
1594 description: None,
1595 dry_run: false,
1596 },
1597 actor,
1598 Some(&client),
1599 None,
1600 )
1601 .unwrap();
1602 assert_eq!(second.action, RelateAction::NoOpAlreadyPresent);
1603 assert_eq!(second.content_hash, first.content_hash);
1605 }
1606
1607 #[test]
1608 fn relate_entity_returns_commit_sha_on_real_write() {
1609 let tmp = TempDir::new().unwrap();
1610 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1611 let (actor, client) = cli_actor();
1612 let target = engine
1613 .create_entity(
1614 empty_create_args("specs", "Target"),
1615 actor,
1616 Some(&client),
1617 None,
1618 )
1619 .unwrap();
1620
1621 let outcome = engine
1622 .relate_entity(
1623 RelateEntityArgs {
1624 source: source.id.clone(),
1625 expected_hash: Some(source.content_hash.clone()),
1626 rel_type: "USES".to_string(),
1627 target: target.id.clone(),
1628 remove: false,
1629 description: None,
1630 dry_run: false,
1631 },
1632 actor,
1633 Some(&client),
1634 None,
1635 )
1636 .unwrap();
1637 assert!(
1641 !outcome.commit_sha.is_empty(),
1642 "commit_sha must be populated on a real write"
1643 );
1644 }
1645
1646 #[test]
1647 fn relate_entity_no_op_paths_carry_typed_warnings_and_empty_commit_sha() {
1648 let tmp = TempDir::new().unwrap();
1649 let (mut engine, source) = engine_with_seed(&tmp, "S");
1650 let (actor, client) = cli_actor();
1651 let target = engine
1652 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1653 .unwrap();
1654
1655 let first = engine
1657 .relate_entity(
1658 RelateEntityArgs {
1659 source: source.id.clone(),
1660 expected_hash: Some(source.content_hash.clone()),
1661 rel_type: "USES".to_string(),
1662 target: target.id.clone(),
1663 remove: false,
1664 description: None,
1665 dry_run: false,
1666 },
1667 actor,
1668 Some(&client),
1669 None,
1670 )
1671 .unwrap();
1672
1673 let dup = engine
1676 .relate_entity(
1677 RelateEntityArgs {
1678 source: source.id.clone(),
1679 expected_hash: Some(first.content_hash.clone()),
1680 rel_type: "USES".to_string(),
1681 target: target.id.clone(),
1682 remove: false,
1683 description: None,
1684 dry_run: false,
1685 },
1686 actor,
1687 Some(&client),
1688 None,
1689 )
1690 .unwrap();
1691 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1692 assert!(dup.commit_sha.is_empty());
1693 assert_eq!(dup.warnings.len(), 1);
1694 assert!(matches!(
1695 dup.warnings[0],
1696 WarningHint::DuplicateRelationship { .. }
1697 ));
1698
1699 let no_such = engine
1702 .relate_entity(
1703 RelateEntityArgs {
1704 source: source.id.clone(),
1705 expected_hash: Some(first.content_hash.clone()),
1706 rel_type: "DEPENDS_ON".to_string(),
1707 target: target.id.clone(),
1708 remove: true,
1709 description: None,
1710 dry_run: false,
1711 },
1712 actor,
1713 Some(&client),
1714 None,
1715 )
1716 .unwrap();
1717 assert_eq!(no_such.action, RelateAction::NoOpAbsent);
1718 assert!(no_such.commit_sha.is_empty());
1719 assert_eq!(no_such.warnings.len(), 1);
1720 assert!(matches!(
1721 no_such.warnings[0],
1722 WarningHint::NoSuchRelationship { .. }
1723 ));
1724 }
1725
1726 #[test]
1727 fn relate_entity_creates_stub_for_absent_target_on_add_path() {
1728 let tmp = TempDir::new().unwrap();
1729 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1730 let (actor, client) = cli_actor();
1731 let absent_target = crate::EntityId::new("specs", "ghost-target");
1732 assert!(!engine.store().contains(&absent_target));
1734
1735 let outcome = engine
1736 .relate_entity(
1737 RelateEntityArgs {
1738 source: source.id.clone(),
1739 expected_hash: Some(source.content_hash.clone()),
1740 rel_type: "USES".to_string(),
1741 target: absent_target.clone(),
1742 remove: false,
1743 description: None,
1744 dry_run: false,
1745 },
1746 actor,
1747 Some(&client),
1748 None,
1749 )
1750 .unwrap();
1751
1752 assert_eq!(outcome.action, RelateAction::Added);
1753 assert_eq!(outcome.source, "explicit");
1754 let stub_warning = outcome
1760 .warnings
1761 .iter()
1762 .find_map(|w| match w {
1763 crate::ops::WarningHint::AutoStubCreated { stub_id, .. } => Some(stub_id.clone()),
1764 _ => None,
1765 })
1766 .expect("AutoStubCreated warning must surface when target was absent");
1767 assert_eq!(stub_warning, absent_target);
1768 let msg = outcome
1771 .warnings
1772 .iter()
1773 .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
1774 .unwrap()
1775 .message();
1776 assert!(
1777 msg.contains("stub auto-created"),
1778 "real relate keeps the performed-effect wording: {msg}"
1779 );
1780
1781 let stub = engine.store().get(&absent_target).expect("stub upserted");
1783 assert!(stub.stub);
1784 assert!(stub.entity_type.is_empty());
1785 assert!(stub.file_path.is_empty());
1786 }
1787
1788 #[test]
1789 fn relate_entity_skips_stub_creation_when_target_already_exists() {
1790 let tmp = TempDir::new().unwrap();
1791 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1792 let (actor, client) = cli_actor();
1793 let target = engine
1794 .create_entity(
1795 empty_create_args("specs", "Real"),
1796 actor,
1797 Some(&client),
1798 None,
1799 )
1800 .unwrap();
1801
1802 let outcome = engine
1803 .relate_entity(
1804 RelateEntityArgs {
1805 source: source.id.clone(),
1806 expected_hash: Some(source.content_hash.clone()),
1807 rel_type: "USES".to_string(),
1808 target: target.id.clone(),
1809 remove: false,
1810 description: None,
1811 dry_run: false,
1812 },
1813 actor,
1814 Some(&client),
1815 None,
1816 )
1817 .unwrap();
1818
1819 assert!(
1820 !outcome
1821 .warnings
1822 .iter()
1823 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1824 "AutoStubCreated must not surface when target was already in store"
1825 );
1826 assert_eq!(outcome.source, "explicit");
1827 let target_after = engine.store().get(&target.id).unwrap();
1829 assert!(!target_after.stub);
1830 }
1831
1832 #[test]
1833 fn relate_entity_does_not_create_stub_on_remove_path() {
1834 let tmp = TempDir::new().unwrap();
1835 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1836 let (actor, client) = cli_actor();
1837 let absent_target = crate::EntityId::new("specs", "never-existed");
1838
1839 let outcome = engine
1840 .relate_entity(
1841 RelateEntityArgs {
1842 source: source.id.clone(),
1843 expected_hash: Some(source.content_hash.clone()),
1844 rel_type: "USES".to_string(),
1845 target: absent_target.clone(),
1846 remove: true,
1847 description: None,
1848 dry_run: false,
1849 },
1850 actor,
1851 Some(&client),
1852 None,
1853 )
1854 .unwrap();
1855
1856 assert_eq!(outcome.action, RelateAction::NoOpAbsent);
1859 assert!(
1860 !outcome
1861 .warnings
1862 .iter()
1863 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1864 "remove path must never auto-stub the target",
1865 );
1866 assert!(!engine.store().contains(&absent_target));
1867 }
1868
1869 #[test]
1870 fn relate_entity_remove_refuses_when_source_body_still_references_target() {
1871 use crate::engine::CreateEntityArgs;
1872 use indexmap::IndexMap;
1873
1874 let tmp = TempDir::new().unwrap();
1875 let mem_dir = tmp.path().to_path_buf();
1876 let writer = FilesystemMemWriter::new(mem_dir.clone());
1877 let mut engine = Engine::from_mounts(vec![(
1878 folder_mount("specs", mem_dir.clone()),
1879 Box::new(writer) as Box<dyn MemBackend>,
1880 )])
1881 .unwrap();
1882 let (actor, client) = cli_actor();
1883
1884 let target = engine
1885 .create_entity(
1886 empty_create_args("specs", "Target"),
1887 actor,
1888 Some(&client),
1889 None,
1890 )
1891 .unwrap();
1892
1893 let mut sections: IndexMap<String, String> = IndexMap::new();
1899 sections.insert("identity".to_string(), "source identity".to_string());
1900 sections.insert(
1901 "purpose".to_string(),
1902 "discussion stems from [[target]]".to_string(),
1903 );
1904 let source = engine
1905 .create_entity(
1906 CreateEntityArgs {
1907 anchors: Vec::new(),
1908 mem: "specs".to_string(),
1909 title: "Source".to_string(),
1910 entity_type: "spec".to_string(),
1911 sections,
1912 metadata: IndexMap::new(),
1913 relations: Vec::new(),
1914 dry_run: false,
1915 },
1916 actor,
1917 Some(&client),
1918 None,
1919 )
1920 .unwrap();
1921 let related = source.clone();
1922
1923 let err = engine
1927 .relate_entity(
1928 RelateEntityArgs {
1929 source: source.id.clone(),
1930 expected_hash: Some(related.content_hash.clone()),
1931 rel_type: "REFERENCES".to_string(),
1932 target: target.id.clone(),
1933 remove: true,
1934 description: None,
1935 dry_run: false,
1936 },
1937 actor,
1938 Some(&client),
1939 None,
1940 )
1941 .unwrap_err();
1942 match err {
1943 EngineError::RelationHasBodyLinks {
1944 from_id,
1945 to_id,
1946 rel_type,
1947 body_links,
1948 } => {
1949 assert_eq!(from_id, source.id.to_string());
1950 assert_eq!(to_id, target.id.to_string());
1951 assert_eq!(rel_type, "REFERENCES");
1952 assert_eq!(body_links, vec!["purpose".to_string()]);
1953 }
1954 other => panic!("expected RelationHasBodyLinks, got {other:?}"),
1955 }
1956 let in_mem = engine.get_entity(&source.id).unwrap();
1959 assert!(
1960 in_mem
1961 .relationships
1962 .iter()
1963 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
1964 "relation must survive the refused remove; got {:?}",
1965 in_mem.relationships
1966 );
1967 }
1968
1969 #[test]
1970 fn relate_entity_remove_succeeds_when_body_no_longer_references_target() {
1971 let tmp = TempDir::new().unwrap();
1972 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1973 let (actor, client) = cli_actor();
1974 let target = engine
1975 .create_entity(
1976 empty_create_args("specs", "Other"),
1977 actor,
1978 Some(&client),
1979 None,
1980 )
1981 .unwrap();
1982 let related = engine
1988 .relate_entity(
1989 RelateEntityArgs {
1990 source: source.id.clone(),
1991 expected_hash: Some(source.content_hash.clone()),
1992 rel_type: "USES".to_string(),
1993 target: target.id.clone(),
1994 remove: false,
1995 description: None,
1996 dry_run: false,
1997 },
1998 actor,
1999 Some(&client),
2000 None,
2001 )
2002 .unwrap();
2003 let removed = engine
2004 .relate_entity(
2005 RelateEntityArgs {
2006 source: source.id.clone(),
2007 expected_hash: Some(related.content_hash.clone()),
2008 rel_type: "USES".to_string(),
2009 target: target.id.clone(),
2010 remove: true,
2011 description: None,
2012 dry_run: false,
2013 },
2014 actor,
2015 Some(&client),
2016 None,
2017 )
2018 .unwrap();
2019 assert_eq!(removed.action, RelateAction::Removed);
2020 }
2021
2022 #[test]
2023 fn relate_entity_auto_stub_is_tagged_forward_reference() {
2024 use crate::entity::StubKind;
2029
2030 let tmp = TempDir::new().unwrap();
2031 let (mut engine, source) = engine_with_seed(&tmp, "Src");
2032 let (actor, client) = cli_actor();
2033 let absent_target = crate::EntityId::new("specs", "absent-target");
2034
2035 let _ = engine
2036 .relate_entity(
2037 RelateEntityArgs {
2038 source: source.id.clone(),
2039 expected_hash: Some(source.content_hash.clone()),
2040 rel_type: "USES".to_string(),
2041 target: absent_target.clone(),
2042 remove: false,
2043 description: None,
2044 dry_run: false,
2045 },
2046 actor,
2047 Some(&client),
2048 None,
2049 )
2050 .unwrap();
2051
2052 let stub = engine
2053 .get_entity(&absent_target)
2054 .expect("relate auto-stubbed target must be in the store");
2055 assert!(stub.stub, "auto-stubbed target must carry stub: true");
2056 assert_eq!(
2057 stub.stub_kind,
2058 Some(StubKind::ForwardReference),
2059 "auto-stub from relate must be tagged ForwardReference; got {:?}",
2060 stub.stub_kind
2061 );
2062 }
2063
2064 #[test]
2065 fn relate_entity_case_insensitive_rel_type_input_canonicalises_to_upper_snake_case() {
2066 let tmp = TempDir::new().unwrap();
2071 let (mut engine, source) = engine_with_seed(&tmp, "Source");
2072 let (actor, client) = cli_actor();
2073 let target = engine
2074 .create_entity(
2075 empty_create_args("specs", "Target"),
2076 actor,
2077 Some(&client),
2078 None,
2079 )
2080 .unwrap();
2081
2082 let lower = engine
2084 .relate_entity(
2085 RelateEntityArgs {
2086 source: source.id.clone(),
2087 expected_hash: Some(source.content_hash.clone()),
2088 rel_type: "uses".to_string(),
2089 target: target.id.clone(),
2090 remove: false,
2091 description: None,
2092 dry_run: false,
2093 },
2094 actor,
2095 Some(&client),
2096 None,
2097 )
2098 .unwrap();
2099 assert_eq!(lower.rel_type, "USES", "response must echo canonical form");
2100 assert_eq!(lower.action, RelateAction::Added);
2101 let edges = engine.store().outgoing(&source.id);
2102 assert!(
2103 edges
2104 .iter()
2105 .any(|e| e.rel_type == "USES" && e.target == target.id),
2106 "store must hold UPPER_SNAKE_CASE rel_type after lowercase input"
2107 );
2108
2109 let dup = engine
2113 .relate_entity(
2114 RelateEntityArgs {
2115 source: source.id.clone(),
2116 expected_hash: Some(lower.content_hash.clone()),
2117 rel_type: "Uses".to_string(),
2118 target: target.id.clone(),
2119 remove: false,
2120 description: None,
2121 dry_run: false,
2122 },
2123 actor,
2124 Some(&client),
2125 None,
2126 )
2127 .unwrap();
2128 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
2129 assert_eq!(dup.rel_type, "USES");
2130 assert!(matches!(
2131 dup.warnings[0],
2132 WarningHint::DuplicateRelationship { .. }
2133 ));
2134 }
2135
2136 #[test]
2137 fn relate_entity_rejects_cross_mem_when_policy_denies() {
2138 let tmp = TempDir::new().unwrap();
2145 let (mut engine, source) = engine_with_seed(&tmp, "S");
2146 let (actor, client) = cli_actor();
2147 let err = engine
2148 .relate_entity(
2149 RelateEntityArgs {
2150 source: source.id.clone(),
2151 expected_hash: Some(source.content_hash.clone()),
2152 rel_type: "USES".to_string(),
2153 target: crate::EntityId::new("other-mem", "thing"),
2154 remove: false,
2155 description: None,
2156 dry_run: false,
2157 },
2158 actor,
2159 Some(&client),
2160 None,
2161 )
2162 .unwrap_err();
2163 match err {
2164 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2165 assert_eq!(from_mem, "specs");
2166 assert_eq!(to_mem, "other-mem");
2167 }
2168 other => panic!("expected CrossMemLinkNotAllowed, got {other:?}"),
2169 }
2170 }
2171
2172 #[test]
2181 fn relate_entity_malformed_bare_target_surfaces_invalid_entity_id_not_cross_mem() {
2182 let tmp = TempDir::new().unwrap();
2183 let (mut engine, source) = engine_with_seed(&tmp, "S");
2184 let (actor, client) = cli_actor();
2185 let err = engine
2186 .relate_entity(
2187 RelateEntityArgs {
2188 source: source.id.clone(),
2189 expected_hash: Some(source.content_hash.clone()),
2190 rel_type: "USES".to_string(),
2191 target: crate::EntityId("bad target with spaces!!".to_string()),
2194 remove: false,
2195 description: None,
2196 dry_run: false,
2197 },
2198 actor,
2199 Some(&client),
2200 None,
2201 )
2202 .unwrap_err();
2203 assert!(
2204 matches!(err, EngineError::InvalidEntityId { .. }),
2205 "malformed bare-string target must surface INVALID_ENTITY_ID, got: {err:?}"
2206 );
2207 }
2208
2209 #[test]
2214 fn relate_entity_malformed_prefixed_target_surfaces_invalid_entity_id() {
2215 let tmp = TempDir::new().unwrap();
2216 let (mut engine, source) = engine_with_seed(&tmp, "S");
2217 let (actor, client) = cli_actor();
2218 let source_mem = source.id.mem().to_string();
2219 let err = engine
2220 .relate_entity(
2221 RelateEntityArgs {
2222 source: source.id.clone(),
2223 expected_hash: Some(source.content_hash.clone()),
2224 rel_type: "USES".to_string(),
2225 target: crate::EntityId(format!("{source_mem}--bad target with spaces!!")),
2226 remove: false,
2227 description: None,
2228 dry_run: false,
2229 },
2230 actor,
2231 Some(&client),
2232 None,
2233 )
2234 .unwrap_err();
2235 assert!(
2236 matches!(err, EngineError::InvalidEntityId { .. }),
2237 "prefixed malformed target must still surface INVALID_ENTITY_ID, got: {err:?}"
2238 );
2239 }
2240
2241 #[test]
2247 fn relate_add_bumps_last_modified_on_source_entity() {
2248 let tmp = TempDir::new().unwrap();
2249 let (mut engine, source) = engine_with_seed(&tmp, "S");
2250 let (actor, client) = cli_actor();
2251 let target = engine
2252 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2253 .unwrap();
2254
2255 let outcome = engine
2256 .relate_entity(
2257 RelateEntityArgs {
2258 source: source.id.clone(),
2259 expected_hash: Some(source.content_hash.clone()),
2260 rel_type: "USES".to_string(),
2261 target: target.id.clone(),
2262 remove: false,
2263 description: None,
2264 dry_run: false,
2265 },
2266 actor,
2267 Some(&client),
2268 None,
2269 )
2270 .unwrap();
2271 assert_eq!(outcome.action, RelateAction::Added);
2272
2273 let post = engine.get_entity(&source.id).unwrap();
2278 let last_modified = post
2279 .metadata
2280 .get("last_modified")
2281 .map(|v| v.to_frontmatter_string())
2282 .unwrap_or_default();
2283 assert!(
2284 last_modified.starts_with("20"),
2285 "last_modified must carry an ISO timestamp post-relate; got: {last_modified:?}"
2286 );
2287 }
2288
2289 #[test]
2294 fn relate_add_noop_does_not_bump_last_modified() {
2295 let tmp = TempDir::new().unwrap();
2296 let (mut engine, source) = engine_with_seed(&tmp, "S");
2297 let (actor, client) = cli_actor();
2298 let target = engine
2299 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2300 .unwrap();
2301 let first = engine
2302 .relate_entity(
2303 RelateEntityArgs {
2304 source: source.id.clone(),
2305 expected_hash: Some(source.content_hash.clone()),
2306 rel_type: "USES".to_string(),
2307 target: target.id.clone(),
2308 remove: false,
2309 description: None,
2310 dry_run: false,
2311 },
2312 actor,
2313 Some(&client),
2314 None,
2315 )
2316 .unwrap();
2317 let pre = engine.get_entity(&source.id).unwrap();
2318 let pre_stamp = pre
2319 .metadata
2320 .get("last_modified")
2321 .map(|v| v.to_frontmatter_string())
2322 .unwrap_or_default();
2323
2324 let dup = engine
2326 .relate_entity(
2327 RelateEntityArgs {
2328 source: source.id.clone(),
2329 expected_hash: Some(first.content_hash.clone()),
2330 rel_type: "USES".to_string(),
2331 target: target.id.clone(),
2332 remove: false,
2333 description: None,
2334 dry_run: false,
2335 },
2336 actor,
2337 Some(&client),
2338 None,
2339 )
2340 .unwrap();
2341 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
2342
2343 let post = engine.get_entity(&source.id).unwrap();
2344 let post_stamp = post
2345 .metadata
2346 .get("last_modified")
2347 .map(|v| v.to_frontmatter_string())
2348 .unwrap_or_default();
2349 assert_eq!(
2350 pre_stamp, post_stamp,
2351 "last_modified must not advance on a duplicate-add no-op (no disk write happened)"
2352 );
2353 }
2354
2355 #[test]
2360 fn cross_mem_relate_to_uncreated_mem_emits_typed_warning() {
2361 use memstead_schema::workspace_config::CrossLinkValue;
2362 let tmp = TempDir::new().unwrap();
2363 let (mut engine, source) = engine_with_seed(&tmp, "S");
2364 let (actor, client) = cli_actor();
2365 let mut settings = crate::workspace::WorkspaceSettings::default();
2369 settings.cross_mem_links.insert(
2370 "specs".to_string(),
2371 CrossLinkValue::List(vec!["uncreated-mem".to_string()]),
2372 );
2373 engine.set_settings(settings);
2374
2375 let absent = crate::EntityId::new("uncreated-mem", "ghost");
2376 let outcome = engine
2377 .relate_entity(
2378 RelateEntityArgs {
2379 source: source.id.clone(),
2380 expected_hash: Some(source.content_hash.clone()),
2381 rel_type: "USES".to_string(),
2382 target: absent.clone(),
2383 remove: false,
2384 description: None,
2385 dry_run: false,
2386 },
2387 actor,
2388 Some(&client),
2389 None,
2390 )
2391 .unwrap();
2392 assert_eq!(outcome.action, RelateAction::Added);
2393
2394 let saw_uncreated = outcome.warnings.iter().any(|w| {
2396 matches!(
2397 w,
2398 WarningHint::CrossMemTargetMemUncreated {
2399 from_mem,
2400 to_mem,
2401 target_id,
2402 } if from_mem == "specs"
2403 && to_mem == "uncreated-mem"
2404 && target_id == &absent
2405 )
2406 });
2407 assert!(
2408 saw_uncreated,
2409 "CrossMemTargetMemUncreated warning must surface; got: {:?}",
2410 outcome.warnings
2411 );
2412 assert!(engine.store().contains(&absent));
2414 }
2415
2416 #[test]
2422 fn cross_mem_relate_policy_refusal_preempts_uncreated_mem_warning() {
2423 let tmp = TempDir::new().unwrap();
2424 let (mut engine, source) = engine_with_seed(&tmp, "S");
2425 let (actor, client) = cli_actor();
2426 let absent = crate::EntityId::new("uncreated-mem", "ghost");
2428 let err = engine
2429 .relate_entity(
2430 RelateEntityArgs {
2431 source: source.id.clone(),
2432 expected_hash: Some(source.content_hash.clone()),
2433 rel_type: "USES".to_string(),
2434 target: absent.clone(),
2435 remove: false,
2436 description: None,
2437 dry_run: false,
2438 },
2439 actor,
2440 Some(&client),
2441 None,
2442 )
2443 .unwrap_err();
2444 assert!(matches!(err, EngineError::CrossMemLinkNotAllowed { .. }));
2445 assert!(!engine.store().contains(&absent));
2447 }
2448
2449 #[test]
2455 fn relate_remove_garbage_collects_orphan_stub() {
2456 let tmp = TempDir::new().unwrap();
2457 let (mut engine, source) = engine_with_seed(&tmp, "Src");
2458 let (actor, client) = cli_actor();
2459 let stub_id = crate::EntityId::new("specs", "ghost-target");
2460
2461 let added = engine
2463 .relate_entity(
2464 RelateEntityArgs {
2465 source: source.id.clone(),
2466 expected_hash: Some(source.content_hash.clone()),
2467 rel_type: "USES".to_string(),
2468 target: stub_id.clone(),
2469 remove: false,
2470 description: None,
2471 dry_run: false,
2472 },
2473 actor,
2474 Some(&client),
2475 None,
2476 )
2477 .unwrap();
2478 assert!(engine.store().contains(&stub_id));
2479
2480 let removed = engine
2481 .relate_entity(
2482 RelateEntityArgs {
2483 source: source.id.clone(),
2484 expected_hash: Some(added.content_hash.clone()),
2485 rel_type: "USES".to_string(),
2486 target: stub_id.clone(),
2487 remove: true,
2488 description: None,
2489 dry_run: false,
2490 },
2491 actor,
2492 Some(&client),
2493 None,
2494 )
2495 .unwrap();
2496 assert_eq!(removed.action, RelateAction::Removed);
2497 assert_eq!(
2498 removed.orphan_stubs_removed,
2499 vec![stub_id.clone()],
2500 "orphan stub must be GC'd in the same call"
2501 );
2502 assert!(
2503 !engine.store().contains(&stub_id),
2504 "stub must be gone from the store after GC"
2505 );
2506 }
2507
2508 #[test]
2514 fn relate_remove_does_not_gc_stub_with_surviving_incoming_edge() {
2515 let tmp = TempDir::new().unwrap();
2516 let (mut engine, source_a) = engine_with_seed(&tmp, "SrcA");
2517 let (actor, client) = cli_actor();
2518 let source_b = engine
2519 .create_entity(
2520 empty_create_args("specs", "SrcB"),
2521 actor,
2522 Some(&client),
2523 None,
2524 )
2525 .unwrap();
2526 let stub_id = crate::EntityId::new("specs", "ghost-target");
2527
2528 let a_added = engine
2530 .relate_entity(
2531 RelateEntityArgs {
2532 source: source_a.id.clone(),
2533 expected_hash: Some(source_a.content_hash.clone()),
2534 rel_type: "USES".to_string(),
2535 target: stub_id.clone(),
2536 remove: false,
2537 description: None,
2538 dry_run: false,
2539 },
2540 actor,
2541 Some(&client),
2542 None,
2543 )
2544 .unwrap();
2545 let _b_added = engine
2546 .relate_entity(
2547 RelateEntityArgs {
2548 source: source_b.id.clone(),
2549 expected_hash: Some(source_b.content_hash.clone()),
2550 rel_type: "USES".to_string(),
2551 target: stub_id.clone(),
2552 remove: false,
2553 description: None,
2554 dry_run: false,
2555 },
2556 actor,
2557 Some(&client),
2558 None,
2559 )
2560 .unwrap();
2561
2562 let removed = engine
2565 .relate_entity(
2566 RelateEntityArgs {
2567 source: source_a.id.clone(),
2568 expected_hash: Some(a_added.content_hash.clone()),
2569 rel_type: "USES".to_string(),
2570 target: stub_id.clone(),
2571 remove: true,
2572 description: None,
2573 dry_run: false,
2574 },
2575 actor,
2576 Some(&client),
2577 None,
2578 )
2579 .unwrap();
2580 assert_eq!(removed.action, RelateAction::Removed);
2581 assert!(
2582 removed.orphan_stubs_removed.is_empty(),
2583 "stub with surviving referrer must not be GC'd; got: {:?}",
2584 removed.orphan_stubs_removed
2585 );
2586 assert!(
2587 engine.store().contains(&stub_id),
2588 "stub must remain in store while another referrer holds it"
2589 );
2590 }
2591
2592 mod cross_mem {
2602 use std::collections::BTreeMap;
2603 use std::path::Path;
2604
2605 use indexmap::IndexMap;
2606 use memstead_schema::SchemaRef;
2607 use memstead_schema::workspace_config::CrossLinkValue;
2608 use tempfile::TempDir;
2609
2610 use crate::backend::MemBackend;
2611 use crate::engine::test_helpers::*;
2612 use crate::engine::{
2613 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateAction,
2614 RelateEntityArgs,
2615 };
2616 use crate::storage::FilesystemMemWriter;
2617
2618 use crate::workspace::{
2619 Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
2620 };
2621
2622 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
2623 let dir = root.join(name);
2624 std::fs::create_dir_all(dir.join("types")).unwrap();
2625 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
2626 for (type_name, body) in types {
2627 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
2628 }
2629 }
2630
2631 const TYPE_BODY: &str = r#"description: t
2632when_to_use: Here
2633sections:
2634 - key: body
2635 heading: Body
2636 required: true
2637 search_weight: 10.0
2638 catch_all: true
2639 write_rules: []
2640metadata_fields: []
2641title_weight: 100.0
2642text_fields:
2643 - body
2644hierarchy_relationship: _default
2645no_self_loop_relationships: []
2646updatable_fields:
2647 - title
2648 - body
2649health_required_fields:
2650 - body
2651staleness_threshold_days: 90
2652write_rules: []
2653"#;
2654
2655 fn make_type_yaml(name: &str) -> String {
2656 format!("name: {name}\n{TYPE_BODY}")
2657 }
2658
2659 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
2660 Mount {
2661 mem: mem.to_string(),
2662 schema: Some(pin),
2663 storage: MountStorage::Folder { path },
2664 capability: MountCapability::Write,
2665 lifecycle: MountLifecycle::Eager,
2666 cross_linkable: true,
2667 migration_target: None,
2668 }
2669 }
2670
2671 fn two_mem_engine() -> (TempDir, Engine, CreateEntityOutcome, CreateEntityOutcome) {
2674 let tmp = TempDir::new().unwrap();
2675
2676 let src_manifest = r#"name: src-cv
2679version: 0.1.0
2680description: source schema
2681when_to_use: tests
2682types:
2683 - doc
2684relationships:
2685 mode: strict
2686 definitions:
2687 - name: IMPLEMENTS
2688 description: intra-mem only
2689 default_weight: 1.0
2690 - name: _default
2691 description: fallback
2692 default_weight: 1.0
2693cross_mem_relationships:
2694 - to_schema: tgt-cv
2695 definitions:
2696 - name: ADDRESSES
2697 description: outbound shape-pinned
2698 default_weight: 1.0
2699 source_types: [doc]
2700 target_types: [req]
2701community:
2702 resolution: 1.0
2703 seed: 42
2704"#;
2705 let tgt_manifest = r#"name: tgt-cv
2708version: 0.1.0
2709description: target schema
2710when_to_use: tests
2711types:
2712 - req
2713relationships:
2714 mode: strict
2715 definitions:
2716 - name: PART_OF
2717 description: hierarchy
2718 default_weight: 3.0
2719 acyclic: true
2720 - name: _default
2721 description: fallback
2722 default_weight: 1.0
2723community:
2724 resolution: 1.0
2725 seed: 42
2726"#;
2727 let schemas_dir = tmp.path().join("schemas");
2728 std::fs::create_dir_all(&schemas_dir).unwrap();
2729 write_schema_files(
2730 &schemas_dir,
2731 "src-cv",
2732 src_manifest,
2733 &[("doc", &make_type_yaml("doc"))],
2734 );
2735 write_schema_files(
2736 &schemas_dir,
2737 "tgt-cv",
2738 tgt_manifest,
2739 &[("req", &make_type_yaml("req"))],
2740 );
2741
2742 let src_dir = tmp.path().join("mem-src");
2743 let tgt_dir = tmp.path().join("mem-tgt");
2744 std::fs::create_dir_all(&src_dir).unwrap();
2745 std::fs::create_dir_all(&tgt_dir).unwrap();
2746
2747 let src_writer = FilesystemMemWriter::new(src_dir.clone());
2748 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
2749 let src_pin = SchemaRef::new("src-cv", semver::Version::new(0, 1, 0));
2750 let tgt_pin = SchemaRef::new("tgt-cv", semver::Version::new(0, 1, 0));
2751
2752 let mut engine = Engine::from_mounts_with_schemas_dir(
2753 vec![
2754 (
2755 folder_mount_with_pin("src", src_dir, src_pin),
2756 Box::new(src_writer) as Box<dyn MemBackend>,
2757 ),
2758 (
2759 folder_mount_with_pin("tgt", tgt_dir, tgt_pin),
2760 Box::new(tgt_writer) as Box<dyn MemBackend>,
2761 ),
2762 ],
2763 Some(&schemas_dir),
2764 )
2765 .expect("two-mem engine constructs");
2766
2767 let mut settings = WorkspaceSettings::default();
2771 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2772 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2773 settings.cross_mem_links = links;
2774 engine.set_settings(settings);
2775
2776 let (actor, client) = cli_actor();
2777 let src_entity = engine
2778 .create_entity(
2779 CreateEntityArgs {
2780 anchors: Vec::new(),
2781 mem: "src".to_string(),
2782 title: "Doc One".to_string(),
2783 entity_type: "doc".to_string(),
2784 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2785 metadata: IndexMap::new(),
2786 relations: Vec::new(),
2787 dry_run: false,
2788 },
2789 actor,
2790 Some(&client),
2791 None,
2792 )
2793 .expect("source entity creates");
2794 let tgt_entity = engine
2795 .create_entity(
2796 CreateEntityArgs {
2797 anchors: Vec::new(),
2798 mem: "tgt".to_string(),
2799 title: "Req One".to_string(),
2800 entity_type: "req".to_string(),
2801 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2802 metadata: IndexMap::new(),
2803 relations: Vec::new(),
2804 dry_run: false,
2805 },
2806 actor,
2807 Some(&client),
2808 None,
2809 )
2810 .expect("target entity creates");
2811
2812 (tmp, engine, src_entity, tgt_entity)
2813 }
2814
2815 #[test]
2816 fn cross_different_schema_admits_declared_edge() {
2817 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2818 let (actor, client) = cli_actor();
2819 let outcome = engine
2820 .relate_entity(
2821 RelateEntityArgs {
2822 source: src.id.clone(),
2823 expected_hash: Some(src.content_hash.clone()),
2824 rel_type: "ADDRESSES".to_string(),
2825 target: tgt.id.clone(),
2826 remove: false,
2827 description: None,
2828 dry_run: false,
2829 },
2830 actor,
2831 Some(&client),
2832 None,
2833 )
2834 .expect("declared cross-mem edge admits");
2835 assert_eq!(outcome.rel_type, "ADDRESSES");
2836 }
2837
2838 #[test]
2844 fn same_name_different_version_uses_intra_mem_vocabulary() {
2845 let tmp = TempDir::new().unwrap();
2846
2847 let manifest_for = |version: &str| {
2848 format!(
2849 r#"name: same-dom
2850version: {version}
2851description: same-domain schema
2852when_to_use: tests
2853types:
2854 - doc
2855relationships:
2856 mode: strict
2857 definitions:
2858 - name: IMPLEMENTS
2859 description: intra-mem vocabulary
2860 default_weight: 1.0
2861 - name: _default
2862 description: fallback
2863 default_weight: 1.0
2864community:
2865 resolution: 1.0
2866 seed: 42
2867"#
2868 )
2869 };
2870 let schemas_dir = tmp.path().join("schemas");
2871 std::fs::create_dir_all(&schemas_dir).unwrap();
2872 write_schema_files(
2875 &schemas_dir,
2876 "same-dom-0.1.0",
2877 &manifest_for("0.1.0"),
2878 &[("doc", &make_type_yaml("doc"))],
2879 );
2880 write_schema_files(
2881 &schemas_dir,
2882 "same-dom-0.2.0",
2883 &manifest_for("0.2.0"),
2884 &[("doc", &make_type_yaml("doc"))],
2885 );
2886
2887 let src_dir = tmp.path().join("mem-src");
2888 let tgt_dir = tmp.path().join("mem-tgt");
2889 std::fs::create_dir_all(&src_dir).unwrap();
2890 std::fs::create_dir_all(&tgt_dir).unwrap();
2891 let src_pin = SchemaRef::new("same-dom", semver::Version::new(0, 1, 0));
2892 let tgt_pin = SchemaRef::new("same-dom", semver::Version::new(0, 2, 0));
2893 let mut engine = Engine::from_mounts_with_schemas_dir(
2894 vec![
2895 (
2896 folder_mount_with_pin("src", src_dir.clone(), src_pin),
2897 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2898 ),
2899 (
2900 folder_mount_with_pin("tgt", tgt_dir.clone(), tgt_pin),
2901 Box::new(FilesystemMemWriter::new(tgt_dir)) as Box<dyn MemBackend>,
2902 ),
2903 ],
2904 Some(&schemas_dir),
2905 )
2906 .expect("same-domain two-version engine constructs");
2907
2908 let mut settings = WorkspaceSettings::default();
2909 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2910 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2911 settings.cross_mem_links = links;
2912 engine.set_settings(settings);
2913
2914 let (actor, client) = cli_actor();
2915 let mk_entity = |engine: &mut Engine, mem: &str, title: &str| {
2916 engine
2917 .create_entity(
2918 CreateEntityArgs {
2919 anchors: Vec::new(),
2920 mem: mem.to_string(),
2921 title: title.to_string(),
2922 entity_type: "doc".to_string(),
2923 sections: IndexMap::from_iter([(
2924 "body".to_string(),
2925 "seed".to_string(),
2926 )]),
2927 metadata: IndexMap::new(),
2928 relations: Vec::new(),
2929 dry_run: false,
2930 },
2931 actor,
2932 Some(&client),
2933 None,
2934 )
2935 .expect("entity creates")
2936 };
2937 let src_entity = mk_entity(&mut engine, "src", "Doc A");
2938 let tgt_entity = mk_entity(&mut engine, "tgt", "Doc B");
2939
2940 let outcome = engine
2941 .relate_entity(
2942 RelateEntityArgs {
2943 source: src_entity.id.clone(),
2944 expected_hash: Some(src_entity.content_hash.clone()),
2945 rel_type: "IMPLEMENTS".to_string(),
2946 target: tgt_entity.id.clone(),
2947 remove: false,
2948 description: None,
2949 dry_run: false,
2950 },
2951 actor,
2952 Some(&client),
2953 None,
2954 )
2955 .expect("same-domain edge uses the intra-schema vocabulary across versions");
2956 assert_eq!(outcome.rel_type, "IMPLEMENTS");
2957 }
2958
2959 #[test]
2960 fn cross_different_schema_unknown_rel_type_returns_invalid_rel_type() {
2961 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2965 let (actor, client) = cli_actor();
2966 let err = engine
2967 .relate_entity(
2968 RelateEntityArgs {
2969 source: src.id.clone(),
2970 expected_hash: Some(src.content_hash.clone()),
2971 rel_type: "IMPLEMENTS".to_string(),
2972 target: tgt.id.clone(),
2973 remove: false,
2974 description: None,
2975 dry_run: false,
2976 },
2977 actor,
2978 Some(&client),
2979 None,
2980 )
2981 .unwrap_err();
2982 match err {
2983 EngineError::Validation(
2984 crate::runtime_validator::ValidationError::InvalidRelationshipType {
2985 input,
2986 allowed,
2987 ..
2988 },
2989 ) => {
2990 assert_eq!(input, "IMPLEMENTS");
2991 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
2992 assert!(names.iter().any(|n| n == "ADDRESSES"));
2993 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
2994 }
2995 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
2996 }
2997 }
2998
2999 #[test]
3000 fn cross_different_schema_shape_violation_returns_invalid_rel_shape() {
3001 }
3018
3019 #[test]
3020 fn cross_different_schema_no_matching_entry_returns_edge_not_declared() {
3021 let tmp = TempDir::new().unwrap();
3024 let src_manifest = r#"name: src-cv
3025version: 0.1.0
3026description: source schema
3027when_to_use: tests
3028types:
3029 - doc
3030relationships:
3031 mode: strict
3032 definitions:
3033 - name: IMPLEMENTS
3034 description: intra-mem
3035 default_weight: 1.0
3036 - name: _default
3037 description: fallback
3038 default_weight: 1.0
3039cross_mem_relationships:
3040 - to_schema: tgt-cv
3041 definitions:
3042 - name: ADDRESSES
3043 description: outbound
3044 default_weight: 1.0
3045 source_types: [doc]
3046 target_types: [req]
3047community:
3048 resolution: 1.0
3049 seed: 42
3050"#;
3051 let other_manifest = r#"name: other-cv
3053version: 0.1.0
3054description: foreign schema
3055when_to_use: tests
3056types:
3057 - thing
3058relationships:
3059 mode: strict
3060 definitions:
3061 - name: _default
3062 description: fallback
3063 default_weight: 1.0
3064community:
3065 resolution: 1.0
3066 seed: 42
3067"#;
3068 let schemas_dir = tmp.path().join("schemas");
3069 std::fs::create_dir_all(&schemas_dir).unwrap();
3070 write_schema_files(
3071 &schemas_dir,
3072 "src-cv",
3073 src_manifest,
3074 &[("doc", &make_type_yaml("doc"))],
3075 );
3076 write_schema_files(
3077 &schemas_dir,
3078 "other-cv",
3079 other_manifest,
3080 &[("thing", &make_type_yaml("thing"))],
3081 );
3082 let src_dir = tmp.path().join("mem-src");
3083 let other_dir = tmp.path().join("mem-other");
3084 std::fs::create_dir_all(&src_dir).unwrap();
3085 std::fs::create_dir_all(&other_dir).unwrap();
3086
3087 let mut engine = Engine::from_mounts_with_schemas_dir(
3088 vec![
3089 (
3090 folder_mount_with_pin(
3091 "src",
3092 src_dir.clone(),
3093 SchemaRef::new("src-cv", semver::Version::new(0, 1, 0)),
3094 ),
3095 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
3096 ),
3097 (
3098 folder_mount_with_pin(
3099 "other",
3100 other_dir.clone(),
3101 SchemaRef::new("other-cv", semver::Version::new(0, 1, 0)),
3102 ),
3103 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
3104 ),
3105 ],
3106 Some(&schemas_dir),
3107 )
3108 .expect("engine constructs");
3109
3110 let mut settings = WorkspaceSettings::default();
3111 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
3112 links.insert("src".to_string(), CrossLinkValue::Wildcard);
3113 settings.cross_mem_links = links;
3114 engine.set_settings(settings);
3115
3116 let (actor, client) = cli_actor();
3117 let src_entity = engine
3118 .create_entity(
3119 CreateEntityArgs {
3120 anchors: Vec::new(),
3121 mem: "src".to_string(),
3122 title: "D".to_string(),
3123 entity_type: "doc".to_string(),
3124 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3125 metadata: IndexMap::new(),
3126 relations: Vec::new(),
3127 dry_run: false,
3128 },
3129 actor,
3130 Some(&client),
3131 None,
3132 )
3133 .unwrap();
3134 let other_entity = engine
3135 .create_entity(
3136 CreateEntityArgs {
3137 anchors: Vec::new(),
3138 mem: "other".to_string(),
3139 title: "T".to_string(),
3140 entity_type: "thing".to_string(),
3141 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3142 metadata: IndexMap::new(),
3143 relations: Vec::new(),
3144 dry_run: false,
3145 },
3146 actor,
3147 Some(&client),
3148 None,
3149 )
3150 .unwrap();
3151
3152 let err = engine
3153 .relate_entity(
3154 RelateEntityArgs {
3155 source: src_entity.id.clone(),
3156 expected_hash: Some(src_entity.content_hash.clone()),
3157 rel_type: "ADDRESSES".to_string(),
3158 target: other_entity.id.clone(),
3159 remove: false,
3160 description: None,
3161 dry_run: false,
3162 },
3163 actor,
3164 Some(&client),
3165 None,
3166 )
3167 .unwrap_err();
3168 match err {
3169 EngineError::CrossMemEdgeNotDeclared {
3170 source_schema,
3171 target_schema,
3172 rel_type,
3173 from_id,
3174 to_id,
3175 } => {
3176 assert_eq!(source_schema, "src-cv@0.1.0");
3177 assert_eq!(target_schema, "other-cv@0.1.0");
3178 assert_eq!(rel_type, "ADDRESSES");
3179 assert_eq!(from_id, src_entity.id.to_string());
3180 assert_eq!(to_id, other_entity.id.to_string());
3181 }
3182 other => panic!("expected CrossMemEdgeNotDeclared, got {other:?}"),
3183 }
3184 }
3185
3186 #[test]
3187 fn intra_mem_with_cross_mem_only_rel_type_returns_invalid_rel_type() {
3188 let (_tmp, mut engine, src, _tgt) = two_mem_engine();
3193 let (actor, client) = cli_actor();
3194 let intra_target = engine
3196 .create_entity(
3197 CreateEntityArgs {
3198 anchors: Vec::new(),
3199 mem: "src".to_string(),
3200 title: "Doc Two".to_string(),
3201 entity_type: "doc".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 let src_fresh = engine.get_entity(&src.id).unwrap();
3215 let err = engine
3216 .relate_entity(
3217 RelateEntityArgs {
3218 source: src.id.clone(),
3219 expected_hash: Some(src_fresh.content_hash.clone()),
3220 rel_type: "ADDRESSES".to_string(),
3221 target: intra_target.id.clone(),
3222 remove: false,
3223 description: None,
3224 dry_run: false,
3225 },
3226 actor,
3227 Some(&client),
3228 None,
3229 )
3230 .unwrap_err();
3231 match err {
3232 EngineError::Validation(
3233 crate::runtime_validator::ValidationError::InvalidRelationshipType {
3234 input,
3235 ..
3236 },
3237 ) => {
3238 assert_eq!(input, "ADDRESSES");
3239 }
3240 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
3241 }
3242 }
3243
3244 #[test]
3245 fn vocabulary_admissible_edge_blocked_by_policy_returns_cross_mem_link_not_allowed() {
3246 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3250 engine.set_settings(WorkspaceSettings::default());
3252 let (actor, client) = cli_actor();
3253 let err = engine
3254 .relate_entity(
3255 RelateEntityArgs {
3256 source: src.id.clone(),
3257 expected_hash: Some(src.content_hash.clone()),
3258 rel_type: "ADDRESSES".to_string(),
3259 target: tgt.id.clone(),
3260 remove: false,
3261 description: None,
3262 dry_run: false,
3263 },
3264 actor,
3265 Some(&client),
3266 None,
3267 )
3268 .unwrap_err();
3269 assert!(
3270 matches!(err, EngineError::CrossMemLinkNotAllowed { .. }),
3271 "expected CrossMemLinkNotAllowed, got {err:?}"
3272 );
3273 }
3274
3275 #[test]
3282 fn cross_mem_remove_bypasses_policy_after_revoke() {
3283 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3284 let (actor, client) = cli_actor();
3285
3286 let added = engine
3288 .relate_entity(
3289 RelateEntityArgs {
3290 source: src.id.clone(),
3291 expected_hash: Some(src.content_hash.clone()),
3292 rel_type: "ADDRESSES".to_string(),
3293 target: tgt.id.clone(),
3294 remove: false,
3295 description: None,
3296 dry_run: false,
3297 },
3298 actor,
3299 Some(&client),
3300 None,
3301 )
3302 .expect("declared cross-mem edge admits under grant");
3303 assert_eq!(added.action, RelateAction::Added);
3304
3305 engine.set_settings(WorkspaceSettings::default());
3307
3308 let add_err = engine
3311 .relate_entity(
3312 RelateEntityArgs {
3313 source: src.id.clone(),
3314 expected_hash: Some(added.content_hash.clone()),
3315 rel_type: "ADDRESSES".to_string(),
3316 target: tgt.id.clone(),
3317 remove: false,
3318 description: None,
3319 dry_run: false,
3320 },
3321 actor,
3322 Some(&client),
3323 None,
3324 )
3325 .unwrap_err();
3326 assert!(
3327 matches!(add_err, EngineError::CrossMemLinkNotAllowed { .. }),
3328 "add path must still refuse under denial, got {add_err:?}"
3329 );
3330
3331 let removed = engine
3334 .relate_entity(
3335 RelateEntityArgs {
3336 source: src.id.clone(),
3337 expected_hash: Some(added.content_hash.clone()),
3338 rel_type: "ADDRESSES".to_string(),
3339 target: tgt.id.clone(),
3340 remove: true,
3341 description: None,
3342 dry_run: false,
3343 },
3344 actor,
3345 Some(&client),
3346 None,
3347 )
3348 .expect("remove must bypass the policy gate post-revoke");
3349 assert_eq!(removed.action, RelateAction::Removed);
3350
3351 let outgoing = engine.store().outgoing(&src.id);
3353 assert!(
3354 !outgoing
3355 .iter()
3356 .any(|e| e.target == tgt.id && e.rel_type == "ADDRESSES"),
3357 "ADDRESSES edge must be gone after remove"
3358 );
3359 }
3360
3361 #[test]
3365 fn cross_mem_remove_of_absent_edge_under_denial_is_no_op() {
3366 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3367 engine.set_settings(WorkspaceSettings::default());
3369 let (actor, client) = cli_actor();
3370 let outcome = engine
3371 .relate_entity(
3372 RelateEntityArgs {
3373 source: src.id.clone(),
3374 expected_hash: Some(src.content_hash.clone()),
3375 rel_type: "ADDRESSES".to_string(),
3376 target: tgt.id.clone(),
3377 remove: true,
3378 description: None,
3379 dry_run: false,
3380 },
3381 actor,
3382 Some(&client),
3383 None,
3384 )
3385 .expect("absent-edge remove must not refuse on policy");
3386 assert!(
3387 matches!(outcome.action, RelateAction::NoOpAbsent),
3388 "expected NoOpAbsent, got {:?}",
3389 outcome.action
3390 );
3391 }
3392
3393 fn engine_with_tgt_capability(
3402 capability: MountCapability,
3403 ) -> (TempDir, Engine, CreateEntityOutcome) {
3404 let tmp = TempDir::new().unwrap();
3405
3406 let src_manifest = r#"name: src-al
3407version: 0.1.0
3408description: source schema with alias pointer
3409when_to_use: tests
3410types:
3411 - doc
3412relationships:
3413 mode: strict
3414 definitions:
3415 - name: ADDRESSES
3416 description: explicit cross-mem
3417 default_weight: 1.0
3418 - name: REFERENCES
3419 description: alias pointer
3420 default_weight: 1.0
3421 - name: _default
3422 description: fallback
3423 default_weight: 1.0
3424cross_mem_relationships:
3425 - to_schema: tgt-al
3426 definitions:
3427 - name: ADDRESSES
3428 description: explicit cross-mem
3429 default_weight: 1.0
3430 - name: REFERENCES
3431 description: alias-emitted cross-mem
3432 default_weight: 1.0
3433alias_target_rel_type: REFERENCES
3434community:
3435 resolution: 1.0
3436 seed: 42
3437"#;
3438 let tgt_manifest = r#"name: tgt-al
3439version: 0.1.0
3440description: target schema
3441when_to_use: tests
3442types:
3443 - req
3444relationships:
3445 mode: strict
3446 definitions:
3447 - name: _default
3448 description: fallback
3449 default_weight: 1.0
3450community:
3451 resolution: 1.0
3452 seed: 42
3453"#;
3454 let schemas_dir = tmp.path().join("schemas");
3455 std::fs::create_dir_all(&schemas_dir).unwrap();
3456 write_schema_files(
3457 &schemas_dir,
3458 "src-al",
3459 src_manifest,
3460 &[("doc", &make_type_yaml("doc"))],
3461 );
3462 write_schema_files(
3463 &schemas_dir,
3464 "tgt-al",
3465 tgt_manifest,
3466 &[("req", &make_type_yaml("req"))],
3467 );
3468
3469 let src_dir = tmp.path().join("mem-src");
3470 let tgt_dir = tmp.path().join("mem-tgt");
3471 std::fs::create_dir_all(&src_dir).unwrap();
3472 std::fs::create_dir_all(&tgt_dir).unwrap();
3473 std::fs::write(
3476 tgt_dir.join("req-one.md"),
3477 "---\ntype: req\n---\n# Req One\n\n## Body\n\nseed.\n",
3478 )
3479 .unwrap();
3480
3481 let src_writer = FilesystemMemWriter::new(src_dir.clone());
3482 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
3483 let src_pin = SchemaRef::new("src-al", semver::Version::new(0, 1, 0));
3484 let tgt_pin = SchemaRef::new("tgt-al", semver::Version::new(0, 1, 0));
3485
3486 let tgt_mount = Mount {
3487 mem: "tgt".to_string(),
3488 schema: Some(tgt_pin),
3489 storage: MountStorage::Folder {
3490 path: tgt_dir.clone(),
3491 },
3492 capability,
3493 lifecycle: MountLifecycle::Eager,
3494 cross_linkable: true,
3495 migration_target: None,
3496 };
3497 let mut engine = Engine::from_mounts_with_schemas_dir(
3498 vec![
3499 (
3500 folder_mount_with_pin("src", src_dir, src_pin),
3501 Box::new(src_writer) as Box<dyn MemBackend>,
3502 ),
3503 (tgt_mount, Box::new(tgt_writer) as Box<dyn MemBackend>),
3504 ],
3505 Some(&schemas_dir),
3506 )
3507 .expect("two-mem engine constructs");
3508
3509 let mut settings = WorkspaceSettings::default();
3510 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
3511 links.insert("src".to_string(), CrossLinkValue::Wildcard);
3512 settings.cross_mem_links = links;
3513 engine.set_settings(settings);
3514
3515 let (actor, client) = cli_actor();
3516 let src_entity = engine
3517 .create_entity(
3518 CreateEntityArgs {
3519 anchors: Vec::new(),
3520 mem: "src".to_string(),
3521 title: "Doc One".to_string(),
3522 entity_type: "doc".to_string(),
3523 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
3524 metadata: IndexMap::new(),
3525 relations: Vec::new(),
3526 dry_run: false,
3527 },
3528 actor,
3529 Some(&client),
3530 None,
3531 )
3532 .expect("source entity creates");
3533
3534 (tmp, engine, src_entity)
3535 }
3536
3537 fn assert_cross_mem_target_not_found(err: EngineError, expected_target: &str) {
3538 match err {
3539 EngineError::CrossMemTargetNotFound {
3540 target_id,
3541 target_mem,
3542 } => {
3543 assert_eq!(target_id, expected_target);
3544 assert_eq!(target_mem, "tgt");
3545 }
3546 other => panic!("expected CrossMemTargetNotFound, got {other:?}"),
3547 }
3548 }
3549
3550 #[test]
3555 fn relate_dry_run_to_missing_target_in_readonly_mem_refuses_identically() {
3556 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3557 let (actor, client) = cli_actor();
3558 let args = |dry_run: bool| RelateEntityArgs {
3559 source: src.id.clone(),
3560 expected_hash: Some(src.content_hash.clone()),
3561 rel_type: "ADDRESSES".to_string(),
3562 target: crate::EntityId::new("tgt", "missing"),
3563 remove: false,
3564 description: None,
3565 dry_run,
3566 };
3567 let rehearsed = engine
3568 .relate_entity(args(true), actor, Some(&client), None)
3569 .unwrap_err();
3570 let real = engine
3571 .relate_entity(args(false), actor, Some(&client), None)
3572 .unwrap_err();
3573 assert_eq!(format!("{rehearsed:?}"), format!("{real:?}"));
3574 assert_cross_mem_target_not_found(rehearsed, "tgt--missing");
3575 }
3576
3577 #[test]
3578 fn relate_to_missing_target_in_readonly_mem_refuses() {
3579 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3580 let (actor, client) = cli_actor();
3581 let err = engine
3582 .relate_entity(
3583 RelateEntityArgs {
3584 source: src.id.clone(),
3585 expected_hash: Some(src.content_hash.clone()),
3586 rel_type: "ADDRESSES".to_string(),
3587 target: crate::EntityId::new("tgt", "missing"),
3588 remove: false,
3589 description: None,
3590 dry_run: false,
3591 },
3592 actor,
3593 Some(&client),
3594 None,
3595 )
3596 .unwrap_err();
3597 assert_cross_mem_target_not_found(err, "tgt--missing");
3598 }
3599
3600 #[test]
3605 fn create_inline_relation_to_missing_target_in_readonly_mem_refuses() {
3606 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3607 let (actor, client) = cli_actor();
3608 let err = engine
3609 .create_entity(
3610 CreateEntityArgs {
3611 anchors: Vec::new(),
3612 mem: "src".to_string(),
3613 title: "Doc Two".to_string(),
3614 entity_type: "doc".to_string(),
3615 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3616 metadata: IndexMap::new(),
3617 relations: vec![crate::ops::RelateArg {
3618 to: crate::EntityId::new("tgt", "missing"),
3619 rel_type: "ADDRESSES".to_string(),
3620 description: None,
3621 }],
3622 dry_run: false,
3623 },
3624 actor,
3625 Some(&client),
3626 None,
3627 )
3628 .unwrap_err();
3629 assert_cross_mem_target_not_found(err, "tgt--missing");
3630 }
3631
3632 #[test]
3636 fn create_body_link_to_missing_target_in_readonly_mem_refuses() {
3637 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3638 let (actor, client) = cli_actor();
3639 let err = engine
3640 .create_entity(
3641 CreateEntityArgs {
3642 anchors: Vec::new(),
3643 mem: "src".to_string(),
3644 title: "Doc Three".to_string(),
3645 entity_type: "doc".to_string(),
3646 sections: IndexMap::from_iter([(
3647 "body".to_string(),
3648 "see [[tgt--missing]].".to_string(),
3649 )]),
3650 metadata: IndexMap::new(),
3651 relations: Vec::new(),
3652 dry_run: false,
3653 },
3654 actor,
3655 Some(&client),
3656 None,
3657 )
3658 .unwrap_err();
3659 assert_cross_mem_target_not_found(err, "tgt--missing");
3660 }
3661
3662 #[test]
3663 fn update_body_link_to_missing_target_in_readonly_mem_refuses() {
3664 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3665 let (actor, client) = cli_actor();
3666 let err = engine
3667 .update_entity(
3668 crate::engine::UpdateEntityArgs {
3669 anchors: Vec::new(),
3670 id: src.id.clone(),
3671 expected_hash: Some(src.content_hash.clone()),
3672 sections: IndexMap::from_iter([(
3673 "body".to_string(),
3674 "now see [[tgt--missing]].".to_string(),
3675 )]),
3676 append_sections: IndexMap::new(),
3677 patch_sections: IndexMap::new(),
3678 metadata: IndexMap::new(),
3679 metadata_unset: Vec::new(),
3680 declare_relations: Vec::new(),
3681 dry_run: false,
3682 relations_unset: Vec::new(),
3683 anchors_unset: Vec::new(),
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 body_link_to_existing_target_in_readonly_mem_admits_and_emits_edge() {
3698 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3699 let (actor, client) = cli_actor();
3700 let created = engine
3701 .create_entity(
3702 CreateEntityArgs {
3703 anchors: Vec::new(),
3704 mem: "src".to_string(),
3705 title: "Doc Four".to_string(),
3706 entity_type: "doc".to_string(),
3707 sections: IndexMap::from_iter([(
3708 "body".to_string(),
3709 "see [[tgt--req-one]].".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 .expect("body link to existing read-only target admits");
3720 let outgoing = engine.store().outgoing(&created.id);
3721 assert!(
3722 outgoing.iter().any(|e| e.rel_type == "REFERENCES"
3723 && e.target == crate::EntityId::new("tgt", "req-one")),
3724 "alias REFERENCES edge to the read-only target must materialise; got {outgoing:?}"
3725 );
3726 }
3727
3728 #[test]
3732 fn body_link_to_missing_target_in_write_mem_still_stubs() {
3733 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::Write);
3734 let (actor, client) = cli_actor();
3735 let created = engine
3736 .create_entity(
3737 CreateEntityArgs {
3738 anchors: Vec::new(),
3739 mem: "src".to_string(),
3740 title: "Doc Five".to_string(),
3741 entity_type: "doc".to_string(),
3742 sections: IndexMap::from_iter([(
3743 "body".to_string(),
3744 "see [[tgt--missing]].".to_string(),
3745 )]),
3746 metadata: IndexMap::new(),
3747 relations: Vec::new(),
3748 dry_run: false,
3749 },
3750 actor,
3751 Some(&client),
3752 None,
3753 )
3754 .expect("forward reference into a Write sibling mem keeps stubbing");
3755 assert!(
3756 engine
3757 .store()
3758 .contains(&crate::EntityId::new("tgt", "missing")),
3759 "auto-stub must land for the Write-mem forward reference"
3760 );
3761 let outgoing = engine.store().outgoing(&created.id);
3762 assert!(
3763 outgoing.iter().any(|e| e.rel_type == "REFERENCES"),
3764 "alias edge must still emit for the stubbed target"
3765 );
3766 }
3767 }
3768
3769 #[test]
3778 fn batch_relate_applies_adds_and_removes_in_order_one_commit() {
3779 let tmp = TempDir::new().unwrap();
3780 let mem_dir = tmp.path().to_path_buf();
3781 let writer = FilesystemMemWriter::new(mem_dir.clone());
3782 let mut engine = Engine::from_mounts(vec![(
3783 folder_mount("specs", mem_dir),
3784 Box::new(writer) as Box<dyn MemBackend>,
3785 )])
3786 .unwrap();
3787 let (actor, client) = cli_actor();
3788
3789 for title in ["A", "B", "C"] {
3790 engine
3791 .create_entity(
3792 empty_create_args("specs", title),
3793 actor,
3794 Some(&client),
3795 None,
3796 )
3797 .unwrap();
3798 }
3799 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
3800 let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
3801 source: id(from),
3802 expected_hash: None,
3803 rel_type: "USES".to_string(),
3804 target: id(to),
3805 remove,
3806 description: None,
3807 dry_run: false,
3808 };
3809
3810 let result = engine
3811 .batch_relate(
3812 vec![
3813 (edge("a", "b", false), Some("add a-b".to_string())),
3814 (edge("a", "c", false), Some("add a-c".to_string())),
3815 (edge("a", "c", true), Some("undo a-c".to_string())),
3816 ],
3817 actor,
3818 Some(&client),
3819 false,
3820 )
3821 .unwrap();
3822 assert!(result.applied, "{result:?}");
3823 assert_eq!(result.succeeded, 3);
3824 assert_eq!(result.failed, 0);
3825 assert!(!result.commit_sha.is_empty(), "one real commit");
3826 let actions: Vec<&str> = result.results.iter().map(|r| r.action.as_str()).collect();
3827 assert_eq!(
3828 actions,
3829 vec!["added", "added", "removed"],
3830 "in-order application: the remove sees the same batch's add"
3831 );
3832
3833 let a = engine.get_entity(&id("a")).unwrap();
3835 assert_eq!(a.relationships.len(), 1, "{:?}", a.relationships);
3836 assert_eq!(a.relationships[0].rel_type, "USES");
3837 assert_eq!(a.relationships[0].target, id("b"));
3838 }
3839
3840 #[test]
3848 fn relate_dry_run_reports_would_be_stub_and_writes_nothing() {
3849 let tmp = TempDir::new().unwrap();
3850 let (mut engine, source) = engine_with_seed(&tmp, "Src");
3851 engine.set_mutation_clock(std::sync::Arc::new(|| {
3857 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_000_000)
3858 }));
3859 let (actor, client) = cli_actor();
3860 let absent = crate::EntityId::new("specs", "ghost-target");
3861 let args = |dry_run: bool| RelateEntityArgs {
3862 source: source.id.clone(),
3863 expected_hash: Some(source.content_hash.clone()),
3864 rel_type: "USES".to_string(),
3865 target: absent.clone(),
3866 remove: false,
3867 description: None,
3868 dry_run,
3869 };
3870
3871 let rehearsed = engine
3872 .relate_entity(args(true), actor, Some(&client), None)
3873 .unwrap();
3874 assert_eq!(rehearsed.action, RelateAction::Added);
3875 assert!(
3876 rehearsed.commit_sha.is_empty(),
3877 "marker form: empty commit_sha"
3878 );
3879 assert!(
3880 rehearsed.warnings.iter().any(
3881 |w| matches!(w, crate::ops::WarningHint::AutoStubCreated { stub_id, pending: true } if *stub_id == absent)
3882 ),
3883 "would-be stub must be reported as pending: {:?}",
3884 rehearsed.warnings
3885 );
3886 let rehearsed_stub = rehearsed
3889 .warnings
3890 .iter()
3891 .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
3892 .unwrap();
3893 assert_eq!(rehearsed_stub.code(), "AUTO_STUB_CREATED");
3894 let msg = rehearsed_stub.message();
3895 assert!(
3896 msg.contains("would be auto-created") && !msg.contains("stub auto-created."),
3897 "dry-run wording must be conditional: {msg}"
3898 );
3899 assert!(
3900 !engine.store().contains(&absent),
3901 "would-be stub reported, never created"
3902 );
3903 let stored = engine.store().get(&source.id).unwrap();
3906 assert_eq!(stored.content_hash, source.content_hash);
3907 assert_ne!(rehearsed.content_hash, source.content_hash);
3908 assert!(stored.relationships.is_empty(), "no edge landed");
3909
3910 let real = engine
3913 .relate_entity(args(false), actor, Some(&client), None)
3914 .unwrap();
3915 assert!(!real.commit_sha.is_empty(), "the real relate commits");
3916 assert_eq!(
3917 real.content_hash, rehearsed.content_hash,
3918 "prospective hash must equal the real post-write hash"
3919 );
3920 assert!(engine.store().get(&absent).expect("real call stubs").stub);
3921 let real_msg = real
3923 .warnings
3924 .iter()
3925 .find(|w| {
3926 matches!(
3927 w,
3928 crate::ops::WarningHint::AutoStubCreated { pending: false, .. }
3929 )
3930 })
3931 .expect("real relate carries the non-pending stub warning")
3932 .message();
3933 assert!(
3934 real_msg.contains("did not exist — stub auto-created."),
3935 "real wording unchanged: {real_msg}"
3936 );
3937 }
3938
3939 #[test]
3943 fn relate_dry_run_refuses_identically_to_real() {
3944 let tmp = TempDir::new().unwrap();
3945 let (mut engine, source) = engine_with_seed(&tmp, "Src");
3946 let (actor, client) = cli_actor();
3947 let args = |dry_run: bool| RelateEntityArgs {
3949 source: source.id.clone(),
3950 expected_hash: None,
3951 rel_type: "USES".to_string(),
3952 target: crate::EntityId("bad target with spaces".to_string()),
3953 remove: false,
3954 description: None,
3955 dry_run,
3956 };
3957 let rehearsed = engine
3958 .relate_entity(args(true), actor, Some(&client), None)
3959 .unwrap_err();
3960 let real = engine
3961 .relate_entity(args(false), actor, Some(&client), None)
3962 .unwrap_err();
3963 assert_eq!(
3964 format!("{rehearsed:?}"),
3965 format!("{real:?}"),
3966 "identical typed refusal"
3967 );
3968 assert_eq!(rehearsed.code(), real.code());
3969 }
3970
3971 #[test]
3978 fn batch_relate_dry_run_reports_receipt_and_commits_nothing() {
3979 let tmp = TempDir::new().unwrap();
3980 let mem_dir = tmp.path().to_path_buf();
3981 let writer = FilesystemMemWriter::new(mem_dir.clone());
3982 let mut engine = Engine::from_mounts(vec![(
3983 folder_mount("specs", mem_dir),
3984 Box::new(writer) as Box<dyn MemBackend>,
3985 )])
3986 .unwrap();
3987 let (actor, client) = cli_actor();
3988 for title in ["A", "B"] {
3989 engine
3990 .create_entity(
3991 empty_create_args("specs", title),
3992 actor,
3993 Some(&client),
3994 None,
3995 )
3996 .unwrap();
3997 }
3998 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
3999 let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
4000 source: id(from),
4001 expected_hash: None,
4002 rel_type: "USES".to_string(),
4003 target: id(to),
4004 remove,
4005 description: None,
4006 dry_run: false,
4007 };
4008 let batch = || {
4009 vec![
4010 (edge("a", "b", false), None),
4011 (edge("a", "ghost", false), None), (edge("a", "b", true), None), ]
4014 };
4015 let head_before = engine
4016 .mem_head_sha("specs")
4017 .ok()
4018 .flatten()
4019 .unwrap_or_default();
4020
4021 let rehearsed = engine
4022 .batch_relate(batch(), actor, Some(&client), true)
4023 .unwrap();
4024 assert!(rehearsed.applied, "{rehearsed:?}");
4025 assert!(
4026 rehearsed.commit_sha.is_empty(),
4027 "marker form: empty commit_sha"
4028 );
4029 let actions: Vec<&str> = rehearsed
4030 .results
4031 .iter()
4032 .map(|r| r.action.as_str())
4033 .collect();
4034 assert_eq!(
4035 actions,
4036 vec!["added", "added", "removed"],
4037 "in-order rehearsal semantics"
4038 );
4039 assert!(!engine.store().contains(&id("ghost")), "no stub created");
4041 assert!(
4042 engine
4043 .get_entity(&id("a"))
4044 .unwrap()
4045 .relationships
4046 .is_empty(),
4047 "no edge landed"
4048 );
4049 let head_after = engine
4050 .mem_head_sha("specs")
4051 .ok()
4052 .flatten()
4053 .unwrap_or_default();
4054 assert_eq!(head_before, head_after, "no commit landed");
4055
4056 let real = engine
4058 .batch_relate(batch(), actor, Some(&client), false)
4059 .unwrap();
4060 assert!(real.applied, "{real:?}");
4061 assert!(!real.commit_sha.is_empty());
4062 }
4063
4064 #[test]
4068 fn batch_relate_dry_run_refuses_identically_to_real() {
4069 let tmp = TempDir::new().unwrap();
4070 let mem_dir = tmp.path().to_path_buf();
4071 let writer = FilesystemMemWriter::new(mem_dir.clone());
4072 let mut engine = Engine::from_mounts(vec![(
4073 folder_mount("specs", mem_dir),
4074 Box::new(writer) as Box<dyn MemBackend>,
4075 )])
4076 .unwrap();
4077 let (actor, client) = cli_actor();
4078 for title in ["A", "B"] {
4079 engine
4080 .create_entity(
4081 empty_create_args("specs", title),
4082 actor,
4083 Some(&client),
4084 None,
4085 )
4086 .unwrap();
4087 }
4088 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4089 let batch = || {
4090 vec![
4091 (
4092 RelateEntityArgs {
4093 source: id("a"),
4094 expected_hash: None,
4095 rel_type: "USES".to_string(),
4096 target: id("b"),
4097 remove: false,
4098 description: None,
4099 dry_run: false,
4100 },
4101 None,
4102 ),
4103 (
4104 RelateEntityArgs {
4105 source: id("a"),
4106 expected_hash: None,
4107 rel_type: "USES".to_string(),
4108 target: crate::EntityId("bad target".to_string()),
4109 remove: false,
4110 description: None,
4111 dry_run: false,
4112 },
4113 None,
4114 ),
4115 ]
4116 };
4117 let rehearsed = engine
4118 .batch_relate(batch(), actor, Some(&client), true)
4119 .unwrap();
4120 let real = engine
4121 .batch_relate(batch(), actor, Some(&client), false)
4122 .unwrap();
4123 assert!(!rehearsed.applied && !real.applied);
4124 let envelope = |r: &crate::ops::BatchResult| {
4125 r.results
4126 .iter()
4127 .map(|e| {
4128 (
4129 e.id.to_string(),
4130 e.action.clone(),
4131 e.error.as_ref().map(|err| {
4132 (err.code.clone(), err.message.clone(), err.details.clone())
4133 }),
4134 )
4135 })
4136 .collect::<Vec<_>>()
4137 };
4138 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
4139 assert!(
4140 engine
4141 .get_entity(&id("a"))
4142 .unwrap()
4143 .relationships
4144 .is_empty(),
4145 "neither run landed the valid entry"
4146 );
4147 }
4148
4149 #[test]
4154 fn batch_relate_refuses_whole_batch_reporting_every_failure() {
4155 let tmp = TempDir::new().unwrap();
4156 let mem_dir = tmp.path().to_path_buf();
4157 let writer = FilesystemMemWriter::new(mem_dir.clone());
4158 let mut engine = Engine::from_mounts(vec![(
4159 folder_mount("specs", mem_dir),
4160 Box::new(writer) as Box<dyn MemBackend>,
4161 )])
4162 .unwrap();
4163 let (actor, client) = cli_actor();
4164
4165 let a = engine
4166 .create_entity(empty_create_args("specs", "A"), actor, Some(&client), None)
4167 .unwrap();
4168 engine
4169 .create_entity(empty_create_args("specs", "B"), actor, Some(&client), None)
4170 .unwrap();
4171 let head_before = engine
4172 .mem_head_sha("specs")
4173 .ok()
4174 .flatten()
4175 .unwrap_or_default();
4176 let count_before = engine.store().all_entities().count();
4177
4178 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4179 let result = engine
4180 .batch_relate(
4181 vec![
4182 (
4184 RelateEntityArgs {
4185 source: id("a"),
4186 expected_hash: None,
4187 rel_type: "USES".to_string(),
4188 target: id("b"),
4189 remove: false,
4190 description: None,
4191 dry_run: false,
4192 },
4193 None,
4194 ),
4195 (
4197 RelateEntityArgs {
4198 source: id("ghost"),
4199 expected_hash: None,
4200 rel_type: "USES".to_string(),
4201 target: id("b"),
4202 remove: false,
4203 description: None,
4204 dry_run: false,
4205 },
4206 None,
4207 ),
4208 (
4210 RelateEntityArgs {
4211 source: id("b"),
4212 expected_hash: Some("definitely-wrong".to_string()),
4213 rel_type: "USES".to_string(),
4214 target: id("a"),
4215 remove: false,
4216 description: None,
4217 dry_run: false,
4218 },
4219 None,
4220 ),
4221 ],
4222 actor,
4223 Some(&client),
4224 false,
4225 )
4226 .unwrap();
4227 assert!(!result.applied);
4228 assert_eq!(result.failed, 2, "{result:?}");
4229 assert!(result.commit_sha.is_empty());
4230 let codes: Vec<(usize, &str)> = result
4231 .results
4232 .iter()
4233 .enumerate()
4234 .filter(|(_, r)| r.action == "error")
4235 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
4236 .collect();
4237 assert_eq!(
4238 codes,
4239 vec![(1, "ENTITY_NOT_FOUND"), (2, "HASH_MISMATCH")],
4240 "every failing entry named with index + typed code: {result:?}"
4241 );
4242 assert_eq!(result.results[0].action, "not_applied");
4243
4244 let a_after = engine.get_entity(&a.id).unwrap();
4247 assert!(
4248 a_after.relationships.is_empty(),
4249 "staged edge must roll back: {:?}",
4250 a_after.relationships
4251 );
4252 assert_eq!(a_after.content_hash, a.content_hash);
4253 let head_after = engine
4254 .mem_head_sha("specs")
4255 .ok()
4256 .flatten()
4257 .unwrap_or_default();
4258 assert_eq!(head_before, head_after, "mem head unmoved");
4259 assert_eq!(engine.store().all_entities().count(), count_before);
4260 }
4261}
4262
4263#[cfg(test)]
4264mod derivation_tests {
4265 use indexmap::IndexMap;
4266 use tempfile::TempDir;
4267
4268 use crate::backend::MemBackend;
4269 use crate::engine::{
4270 CreateEntityArgs, Engine, RelateAction, RelateEntityArgs, UpdateEntityArgs,
4271 };
4272 use crate::ops::WarningHint;
4273 use crate::storage::FilesystemMemWriter;
4274 use crate::vcs::Actor;
4275 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4276
4277 fn deriv_schema() -> std::sync::Arc<memstead_schema::Schema> {
4281 let manifest = r#"name: deriv
4282version: 0.1.0
4283description: derivation fixture
4284when_to_use: tests
4285types:
4286 - note
4287relationships:
4288 mode: strict
4289 definitions:
4290 - name: PART_OF
4291 description: hier
4292 default_weight: 3.0
4293 - name: DERIVED_FROM
4294 description: source derives from target
4295 default_weight: 2.0
4296 derivation: true
4297 - name: SUPPORTS
4298 description: plain edge
4299 default_weight: 1.0
4300 - name: _default
4301 description: fallback
4302 default_weight: 1.0
4303community:
4304 resolution: 1.0
4305 seed: 42
4306"#;
4307 let type_yaml = r#"name: note
4308description: t
4309when_to_use: tests
4310sections:
4311 - key: body
4312 heading: Body
4313 required: true
4314 search_weight: 10.0
4315 catch_all: true
4316 write_rules: []
4317metadata_fields: []
4318title_weight: 100.0
4319text_fields:
4320 - body
4321hierarchy_relationship: PART_OF
4322no_self_loop_relationships: []
4323updatable_fields:
4324 - title
4325 - body
4326health_required_fields:
4327 - body
4328staleness_threshold_days: 90
4329write_rules: []
4330"#;
4331 std::sync::Arc::new(
4332 memstead_schema::loader::load_schema_from_memory(
4333 manifest,
4334 &[("note".to_string(), type_yaml.to_string())],
4335 )
4336 .expect("fixture schema loads"),
4337 )
4338 }
4339
4340 fn engine_at(tmp: &TempDir) -> Engine {
4341 let mem_dir = tmp.path().to_path_buf();
4342 let writer = FilesystemMemWriter::new(mem_dir.clone());
4343 let mount = Mount {
4344 mem: "m".to_string(),
4345 schema: Some("deriv@0.1.0".parse().unwrap()),
4346 storage: MountStorage::Folder { path: mem_dir },
4347 capability: MountCapability::Write,
4348 lifecycle: MountLifecycle::Eager,
4349 cross_linkable: true,
4350 migration_target: None,
4351 };
4352 Engine::from_mounts_with_schemas_dir_and_extra(
4353 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
4354 None,
4355 vec![deriv_schema()],
4356 )
4357 .unwrap()
4358 }
4359
4360 fn note(title: &str, body: &str) -> CreateEntityArgs {
4361 CreateEntityArgs {
4362 anchors: Vec::new(),
4363 mem: "m".to_string(),
4364 title: title.to_string(),
4365 entity_type: "note".to_string(),
4366 sections: IndexMap::from_iter([("body".to_string(), body.to_string())]),
4367 metadata: IndexMap::new(),
4368 relations: Vec::new(),
4369 dry_run: false,
4370 }
4371 }
4372
4373 fn relate_args(from: &crate::EntityId, rel: &str, to: &crate::EntityId) -> RelateEntityArgs {
4374 RelateEntityArgs {
4375 source: from.clone(),
4376 expected_hash: None,
4377 rel_type: rel.to_string(),
4378 target: to.clone(),
4379 remove: false,
4380 description: None,
4381 dry_run: false,
4382 }
4383 }
4384
4385 #[test]
4390 fn derivation_write_edit_report_reassert_clear() {
4391 let tmp = TempDir::new().unwrap();
4392 let mut engine = engine_at(&tmp);
4393 let target = engine
4394 .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4395 .unwrap();
4396 let source = engine
4397 .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4398 .unwrap();
4399
4400 let added = engine
4402 .relate_entity(
4403 relate_args(&source.id, "DERIVED_FROM", &target.id),
4404 Actor::Cli,
4405 None,
4406 None,
4407 )
4408 .unwrap();
4409 assert_eq!(added.action, RelateAction::Added);
4410 assert!(
4411 engine.derivation_report("m").unwrap().is_empty(),
4412 "freshly baselined edge must not report"
4413 );
4414
4415 let t_now = engine.get_entity(&target.id).unwrap().content_hash.clone();
4417 engine
4418 .update_entity(
4419 UpdateEntityArgs {
4420 anchors: Vec::new(),
4421 id: target.id.clone(),
4422 expected_hash: Some(t_now),
4423 sections: IndexMap::from_iter([("body".to_string(), "v2".to_string())]),
4424 append_sections: IndexMap::new(),
4425 patch_sections: IndexMap::new(),
4426 metadata: IndexMap::new(),
4427 metadata_unset: Vec::new(),
4428 declare_relations: Vec::new(),
4429 dry_run: false,
4430 relations_unset: Vec::new(),
4431 anchors_unset: Vec::new(),
4432 },
4433 Actor::Cli,
4434 None,
4435 None,
4436 )
4437 .unwrap();
4438 let report = engine.derivation_report("m").unwrap();
4439 assert_eq!(report.len(), 1, "{report:?}");
4440 assert_eq!(report[0].source, source.id);
4441 assert_eq!(report[0].rel_type, "DERIVED_FROM");
4442 assert_eq!(report[0].target, target.id);
4443 assert_eq!(report[0].state, "stale");
4444 assert!(report[0].baseline.is_some());
4445
4446 let s_now = engine.get_entity(&source.id).unwrap().content_hash.clone();
4451 engine
4452 .update_entity(
4453 UpdateEntityArgs {
4454 anchors: Vec::new(),
4455 id: source.id.clone(),
4456 expected_hash: Some(s_now),
4457 sections: IndexMap::from_iter([(
4458 "body".to_string(),
4459 "conclusion v2".to_string(),
4460 )]),
4461 append_sections: IndexMap::new(),
4462 patch_sections: IndexMap::new(),
4463 metadata: IndexMap::new(),
4464 metadata_unset: Vec::new(),
4465 declare_relations: Vec::new(),
4466 dry_run: false,
4467 relations_unset: Vec::new(),
4468 anchors_unset: Vec::new(),
4469 },
4470 Actor::Cli,
4471 None,
4472 None,
4473 )
4474 .unwrap();
4475 let report = engine.derivation_report("m").unwrap();
4476 assert_eq!(report.len(), 1, "source edit adds nothing: {report:?}");
4477 assert_eq!(report[0].state, "stale");
4478
4479 let md_before = std::fs::read(tmp.path().join("source.md")).unwrap();
4483 let hash_before = engine.get_entity(&source.id).unwrap().content_hash.clone();
4484 let refreshed = engine
4485 .relate_entity(
4486 relate_args(&source.id, "DERIVED_FROM", &target.id),
4487 Actor::Cli,
4488 None,
4489 None,
4490 )
4491 .unwrap();
4492 assert_eq!(refreshed.action, RelateAction::NoOpAlreadyPresent);
4493 assert_eq!(refreshed.content_hash, hash_before, "_hash unchanged");
4494 assert!(
4495 !refreshed.commit_sha.is_empty(),
4496 "the sidecar refresh persists via a real commit"
4497 );
4498 assert!(
4499 refreshed
4500 .warnings
4501 .iter()
4502 .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4503 "the refresh is STATED, never a bare no-op: {:?}",
4504 refreshed.warnings
4505 );
4506 let md_after = std::fs::read(tmp.path().join("source.md")).unwrap();
4507 assert_eq!(md_before, md_after, "baselines never touch the markdown");
4508 assert!(
4509 engine.derivation_report("m").unwrap().is_empty(),
4510 "re-assert clears the staleness"
4511 );
4512
4513 engine
4516 .relate_entity(
4517 relate_args(&source.id, "SUPPORTS", &target.id),
4518 Actor::Cli,
4519 None,
4520 None,
4521 )
4522 .unwrap();
4523 let noop = engine
4524 .relate_entity(
4525 relate_args(&source.id, "SUPPORTS", &target.id),
4526 Actor::Cli,
4527 None,
4528 None,
4529 )
4530 .unwrap();
4531 assert_eq!(noop.action, RelateAction::NoOpAlreadyPresent);
4532 assert!(noop.commit_sha.is_empty(), "undeclared no-op stays bare");
4533 assert!(
4534 !noop
4535 .warnings
4536 .iter()
4537 .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4538 );
4539 assert!(engine.derivation_report("m").unwrap().is_empty());
4541 }
4542
4543 #[test]
4548 fn missing_baseline_reports_unbaselined_never_fabricated() {
4549 let tmp = TempDir::new().unwrap();
4550 let mut engine = engine_at(&tmp);
4551 let target = engine
4552 .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4553 .unwrap();
4554 let source = engine
4555 .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4556 .unwrap();
4557 engine
4558 .relate_entity(
4559 relate_args(&source.id, "DERIVED_FROM", &target.id),
4560 Actor::Cli,
4561 None,
4562 None,
4563 )
4564 .unwrap();
4565 let sidecar = tmp.path().join(".memstead").join("derivations.json");
4567 assert!(sidecar.exists(), "baseline persisted to the sidecar");
4568 assert!(
4569 engine
4570 .get_entity(&crate::EntityId::new("m", "derivations"))
4571 .is_none()
4572 );
4573
4574 std::fs::remove_file(&sidecar).unwrap();
4577 let report = engine.derivation_report("m").unwrap();
4578 assert_eq!(report.len(), 1, "{report:?}");
4579 assert_eq!(report[0].state, "unbaselined");
4580 assert!(report[0].baseline.is_none());
4581 }
4582}