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) type_def: std::sync::Arc<memstead_schema::TypeDefinition>,
48}
49
50pub(super) enum RelatePrepareOutcome {
52 Done(RelateEntityOutcome),
56 Prepared(PreparedRelate),
58}
59
60impl Engine {
61 pub fn relate_entity(
80 &mut self,
81 args: RelateEntityArgs,
82 actor: Actor,
83 client: Option<&ClientId>,
84 note: Option<&str>,
85 ) -> Result<RelateEntityOutcome, EngineError> {
86 let source_mem = args.source.mem().to_string();
87 let target_mem = args.target.mem().to_string();
88
89 let mut drift_warnings = self.reload_if_stale(Some(&source_mem));
97 if target_mem != source_mem {
98 drift_warnings.append(&mut self.reload_if_stale(Some(&target_mem)));
99 }
100
101 let dry_run = args.dry_run;
102 let prepared = match self.prepare_relate(args, drift_warnings)? {
103 RelatePrepareOutcome::Done(outcome) => {
104 if !dry_run
115 && matches!(
116 outcome.action,
117 super::super::RelateAction::NoOpAlreadyPresent
118 )
119 {
120 return self.refresh_derivation_baseline_on_noop(outcome, actor, client, note);
121 }
122 return Ok(outcome);
123 }
124 RelatePrepareOutcome::Prepared(p) => p,
125 };
126
127 if dry_run {
134 let parse_result = parse_markdown(
135 &prepared.markdown,
136 &prepared.file_path,
137 prepared.type_def.as_ref(),
138 &prepared.source_mem,
139 )
140 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
141 let mut warnings = prepared.warnings;
145 if let Some(w) = self.note_missing_warning("relate_entity", note) {
146 warnings.push(w);
147 }
148 return Ok(RelateEntityOutcome {
149 from: prepared.from,
150 to: prepared.to,
151 rel_type: prepared.rel_type,
152 action: prepared.action,
153 content_hash: parse_result.entity.content_hash,
154 commit_sha: String::new(),
155 source: "explicit".to_string(),
156 orphan_stubs_removed: Vec::new(),
157 warnings,
158 });
159 }
160
161 self.stage_prepared_relate(&prepared)?;
162
163 if let Some(schema) = self.schemas.get(&prepared.source_mem)
169 && super::rel_type_declares_derivation(schema, &prepared.rel_type)
170 {
171 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
172 let (from, rel, to) = (
173 prepared.from.to_string(),
174 prepared.rel_type.clone(),
175 prepared.to.to_string(),
176 );
177 match prepared.action {
178 RelateAction::Added => {
179 let hash = self
180 .store
181 .get(&prepared.to)
182 .map(|e| e.content_hash.clone())
183 .unwrap_or_default();
184 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
185 }
186 RelateAction::Removed => {
187 super::stage_derivation_sidecar(backend, |s| s.remove(&from, &rel, &to))?;
188 }
189 _ => {}
190 }
191 }
192
193 let backend = self.mounts[prepared.mount_idx].backend.as_ref();
194 let commit_subject = format!("memstead: relate {}", prepared.from);
195 let ctx = CommitContext {
196 actor,
197 client: client.cloned(),
198 tool: Some("relate_entity"),
199 note: note.map(String::from),
200 role: self.current_role,
201 logical_operation_id: None,
202 entity_ids: None,
203 };
204 let commit_sha = backend.commit(&commit_subject, &ctx)?;
205
206 backend.append_provenance(
207 &Provenance::new(
208 std::time::SystemTime::now(),
209 ProvenanceKind::Relate,
210 Some(prepared.from.to_string()),
211 actor,
212 client.cloned(),
213 note.map(String::from),
214 )
215 .with_role(self.current_role),
216 )?;
217
218 self.record_self_write(prepared.mount_idx, &commit_sha);
219 self.stamp_mutation_versions(prepared.mount_idx);
220
221 let content_hash = self.apply_prepared_relate_to_store(&prepared)?;
222
223 let orphan_stubs_removed: Vec<EntityId> =
237 if matches!(prepared.action, RelateAction::Removed) {
238 super::gc_orphan_stubs_among(&mut self.store, std::iter::once(&prepared.to))
239 } else {
240 Vec::new()
241 };
242
243 self.invalidate_communities();
244 self.invalidate_search_indexes();
245
246 let PreparedRelate {
247 from,
248 to,
249 rel_type,
250 action,
251 mut warnings,
252 ..
253 } = prepared;
254
255 if let Some(w) = self.note_missing_warning("relate_entity", note) {
261 warnings.push(w);
262 }
263
264 Ok(RelateEntityOutcome {
265 from,
266 to,
267 rel_type,
268 action,
269 content_hash,
270 commit_sha,
271 source: "explicit".to_string(),
272 warnings,
273 orphan_stubs_removed,
274 })
275 }
276
277 fn refresh_derivation_baseline_on_noop(
288 &mut self,
289 mut outcome: RelateEntityOutcome,
290 actor: Actor,
291 client: Option<&ClientId>,
292 note: Option<&str>,
293 ) -> Result<RelateEntityOutcome, EngineError> {
294 let source_mem = outcome.from.mem().to_string();
295 let declared = self
296 .schemas
297 .get(&source_mem)
298 .is_some_and(|s| super::rel_type_declares_derivation(s, &outcome.rel_type));
299 if !declared {
300 return Ok(outcome);
301 }
302 let Some(mount_idx) = self.mounts.iter().position(|m| m.mount.mem == source_mem) else {
303 return Ok(outcome);
304 };
305 let hash = self
306 .store
307 .get(&outcome.to)
308 .map(|e| e.content_hash.clone())
309 .unwrap_or_default();
310 let (from, rel, to) = (
311 outcome.from.to_string(),
312 outcome.rel_type.clone(),
313 outcome.to.to_string(),
314 );
315 let backend = self.mounts[mount_idx].backend.as_ref();
316 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
317 let ctx = CommitContext {
318 actor,
319 client: client.cloned(),
320 tool: Some("relate_entity"),
321 note: note.map(String::from),
322 role: self.current_role,
323 logical_operation_id: None,
324 entity_ids: None,
325 };
326 let commit_sha = backend.commit(
327 &format!("memstead: derivation re-baseline {}", outcome.from),
328 &ctx,
329 )?;
330 self.record_self_write(mount_idx, &commit_sha);
331 self.stamp_mutation_versions(mount_idx);
332 outcome.commit_sha = commit_sha;
333 outcome
334 .warnings
335 .push(WarningHint::DerivationBaselineRefreshed {
336 from: outcome.from.clone(),
337 rel_type: outcome.rel_type.clone(),
338 to: outcome.to.clone(),
339 });
340 Ok(outcome)
341 }
342
343 fn prepare_relate(
351 &mut self,
352 args: RelateEntityArgs,
353 mut drift_warnings: Vec<WarningHint>,
354 ) -> Result<RelatePrepareOutcome, EngineError> {
355 let mut args = args;
356 let source_mem = args.source.mem().to_string();
357 let target_mem = args.target.mem().to_string();
358
359 validate_relation_target_grammar(&args.target)?;
375
376 let mut target_mem_uncreated = false;
382 if source_mem != target_mem {
383 if !args.remove {
394 super::validate_cross_mem_add_policy(self, &source_mem, &args.target)?;
395 }
396 if let Some(mount) = self.mount(&target_mem)
404 && mount.capability == MountCapability::ReadOnly
405 && !self.store.contains(&args.target)
406 {
407 return Err(EngineError::CrossMemTargetNotFound {
408 target_id: args.target.to_string(),
409 target_mem: target_mem.clone(),
410 });
411 }
412 if self.mount(&target_mem).is_none() {
420 target_mem_uncreated = true;
421 }
422 }
423
424 if let Ok(canonical) = crate::entity::id::validate_rel_type(&args.rel_type) {
431 args.rel_type = canonical;
432 }
433 args.description = normalise_description(args.description.as_deref());
437
438 let mount_idx = self
439 .mounts
440 .iter()
441 .position(|m| m.mount.mem == source_mem)
442 .ok_or_else(|| self.unknown_mem_error(&source_mem))?;
443 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
444 return Err(EngineError::ReadOnlyMount(source_mem));
445 }
446
447 let schema = self
448 .schemas
449 .get(&source_mem)
450 .expect("schema present for every registered mount");
451
452 let target_schema = if source_mem == target_mem {
466 None
467 } else {
468 self.schemas.get(&target_mem).cloned()
469 };
470 let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
471 let (name, version) = s.id();
472 SchemaRef::new(name, version)
473 });
474 let cross_mem_different = match (&target_schema_ref, schema.id()) {
475 (Some(target), (src_name, _)) => target.name != src_name,
476 (None, _) => false,
477 };
478
479 let mut warnings: Vec<WarningHint> = Vec::new();
480 warnings.append(&mut drift_warnings);
482 if !cross_mem_different {
487 match validate_rel_type(&args.rel_type, schema.as_ref())? {
488 RelationshipCheck::Ok => {}
489 RelationshipCheck::OpenWarning(message) => {
490 warnings.push(WarningHint::UndeclaredRelationshipOpen {
491 rel_type: args.rel_type.clone(),
492 message,
493 });
494 }
495 }
496 }
497
498 let entity: Entity = self
502 .store
503 .get(&args.source)
504 .ok_or_else(|| EngineError::NotFound {
505 id: args.source.to_string(),
506 })?
507 .clone();
508
509 if entity.stub {
517 return Err(EngineError::StubCannotRelate {
518 id: args.source.to_string(),
519 });
520 }
521
522 let target_type = self
523 .store
524 .get(&args.target)
525 .map(|e| e.entity_type.clone())
526 .filter(|t| !t.is_empty());
527 if !args.remove {
542 validate_description_posture(
543 self,
544 &args.rel_type,
545 args.description.as_deref(),
546 &source_mem,
547 &target_mem,
548 &args.source,
549 &args.target,
550 )?;
551 super::validate_manual_authoring_posture(
559 self,
560 &args.rel_type,
561 &source_mem,
562 &args.source,
563 &args.target,
564 )?;
565 }
566
567 if !args.remove {
568 if cross_mem_different {
569 let target_ref = target_schema_ref
572 .as_ref()
573 .expect("target_schema_ref is Some when cross_mem_different");
574 match validate_cross_mem_edge(
575 &args.rel_type,
576 entity.entity_type.as_str(),
577 target_type.as_deref(),
578 schema.as_ref(),
579 target_ref,
580 ) {
581 CrossMemRelCheck::Ok => {}
582 CrossMemRelCheck::EdgeNotDeclared => {
583 let (src_name, src_version) = schema.id();
584 return Err(EngineError::CrossMemEdgeNotDeclared {
585 source_schema: SchemaRef::new(src_name, src_version).as_display(),
586 target_schema: target_ref.as_display(),
587 rel_type: args.rel_type.clone(),
588 from_id: args.source.to_string(),
589 to_id: args.target.to_string(),
590 });
591 }
592 CrossMemRelCheck::Invalid(v) => {
593 return Err(EngineError::Validation(v));
594 }
595 }
596 } else {
597 validate_rel_shape(
598 &args.rel_type,
599 entity.entity_type.as_str(),
600 target_type.as_deref(),
601 schema.as_ref(),
602 )?;
603 }
604 }
605
606 if let Some(expected) = args.expected_hash.as_deref()
607 && entity.content_hash != expected
608 {
609 return Err(EngineError::HashMismatch {
610 id: args.source.to_string(),
611 current: entity.content_hash.clone(),
612 is_stub: entity.stub,
613 });
614 }
615
616 if !args.remove {
622 super::validate_edge_acyclicity(
623 &self.store,
624 schema,
625 &args.source,
626 entity.entity_type.as_str(),
627 &args.target,
628 &args.rel_type,
629 )?;
630 }
631
632 let type_def = schema
633 .get_type(&entity.entity_type)
634 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
635
636 let mut next = entity.clone();
637 let already = next
638 .relationships
639 .iter()
640 .position(|r| r.rel_type == args.rel_type && r.target == args.target);
641
642 if args.remove && already.is_some() {
649 let other_relation_to_target_exists = entity
650 .relationships
651 .iter()
652 .any(|r| r.target == args.target && r.rel_type != args.rel_type);
653 if !other_relation_to_target_exists {
654 let mut surviving_sections: Vec<String> = Vec::new();
661 for (section_key, body) in entity.sections.iter() {
662 let inline_targets =
663 crate::entity::parser::extract_inline_links_lenient(body, &source_mem);
664 if inline_targets.iter().any(|t| t == &args.target) {
665 surviving_sections.push(section_key.clone());
666 }
667 }
668 if !surviving_sections.is_empty() {
669 return Err(EngineError::RelationHasBodyLinks {
670 from_id: args.source.to_string(),
671 to_id: args.target.to_string(),
672 rel_type: args.rel_type.clone(),
673 body_links: surviving_sections,
674 });
675 }
676 }
677 }
678
679 let action = if args.remove {
680 match already {
681 Some(idx) => {
682 next.relationships.remove(idx);
683 RelateAction::Removed
684 }
685 None => RelateAction::NoOpAbsent,
686 }
687 } else {
688 match already {
689 Some(_) => RelateAction::NoOpAlreadyPresent,
690 None => {
691 next.relationships.push(Relationship {
692 rel_type: args.rel_type.clone(),
693 target: args.target.clone(),
694 description: normalise_description(args.description.as_deref()),
695 });
696 RelateAction::Added
697 }
698 }
699 };
700
701 if matches!(action, RelateAction::Removed) {
709 let blocked: Vec<_> =
710 crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref())
711 .into_iter()
712 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
713 .collect();
714 if !blocked.is_empty() {
715 return Err(EngineError::RequiredOutgoingUnsatisfied {
716 entity_type: next.entity_type.clone(),
717 entity_id: args.source.to_string(),
718 missing: blocked,
719 });
720 }
721 let blocked: Vec<_> = crate::ops::health::unsatisfied_constraints(
728 &self.store,
729 &next,
730 type_def.as_ref(),
731 Some(&args.source),
732 )
733 .into_iter()
734 .filter(|v| {
735 matches!(
736 v,
737 crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { .. }
738 ) && v.severity() == memstead_schema::ConstraintSeverity::Block
739 })
740 .collect();
741 if !blocked.is_empty() {
742 return Err(EngineError::ConstraintUnsatisfied {
743 entity_type: next.entity_type.clone(),
744 entity_id: args.source.to_string(),
745 violations: blocked,
746 });
747 }
748 }
749
750 let mut stub_target: Option<EntityId> = None;
763 if matches!(action, RelateAction::Added) && !self.store.contains(&args.target) {
764 stub_target = Some(args.target.clone());
765 warnings.push(WarningHint::AutoStubCreated {
770 stub_id: args.target.clone(),
771 pending: args.dry_run,
772 });
773 if target_mem_uncreated {
778 warnings.push(WarningHint::CrossMemTargetMemUncreated {
779 from_mem: source_mem.clone(),
780 to_mem: target_mem.clone(),
781 target_id: args.target.clone(),
782 });
783 }
784 }
785
786 if matches!(
792 action,
793 RelateAction::NoOpAlreadyPresent | RelateAction::NoOpAbsent
794 ) {
795 match action {
796 RelateAction::NoOpAlreadyPresent => {
797 warnings.push(WarningHint::DuplicateRelationship {
798 rel_type: args.rel_type.clone(),
799 from: args.source.clone(),
800 to: args.target.clone(),
801 });
802 }
803 RelateAction::NoOpAbsent => {
804 warnings.push(WarningHint::NoSuchRelationship {
805 rel_type: args.rel_type.clone(),
806 from: args.source.clone(),
807 to: args.target.clone(),
808 });
809 }
810 _ => unreachable!(),
811 }
812 return Ok(RelatePrepareOutcome::Done(RelateEntityOutcome {
813 from: args.source,
814 to: args.target,
815 rel_type: args.rel_type,
816 action,
817 content_hash: entity.content_hash.clone(),
818 commit_sha: String::new(),
819 source: "explicit".to_string(),
820 warnings,
821 orphan_stubs_removed: Vec::new(),
824 }));
825 }
826
827 let today = self.now_iso();
835 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
836
837 let file_path = next.file_path.clone();
838 let markdown = generate_markdown(&next, type_def.as_ref());
839
840 Ok(RelatePrepareOutcome::Prepared(PreparedRelate {
841 mount_idx,
842 source_mem,
843 from: args.source,
844 to: args.target,
845 rel_type: args.rel_type,
846 action,
847 file_path,
848 markdown,
849 warnings,
850 stub_target,
851 type_def,
852 }))
853 }
854
855 fn stage_prepared_relate(&mut self, p: &PreparedRelate) -> Result<(), EngineError> {
861 if let Some(stub_id) = &p.stub_target {
862 self.store.upsert(
863 stub_id.clone(),
864 make_stub(stub_id, crate::entity::StubKind::ForwardReference),
865 );
866 }
867 self.mounts[p.mount_idx]
868 .backend
869 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())?;
870 Ok(())
871 }
872
873 fn apply_prepared_relate_to_store(
881 &mut self,
882 p: &PreparedRelate,
883 ) -> Result<String, EngineError> {
884 let parse_result = parse_markdown(
885 &p.markdown,
886 &p.file_path,
887 p.type_def.as_ref(),
888 &p.source_mem,
889 )
890 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
891 let content_hash = parse_result.entity.content_hash.clone();
892 let fallback = engine_fallback_type();
893 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
894 crate::entity::store_builder::remap_alias_target_edge_sources(
895 &mut self.store,
896 &self.schemas,
897 );
898 Ok(content_hash)
899 }
900
901 pub fn batch_relate(
938 &mut self,
939 relates: Vec<(RelateEntityArgs, Option<String>)>,
940 actor: Actor,
941 client: Option<&ClientId>,
942 dry_run: bool,
943 ) -> Result<crate::ops::BatchResult, EngineError> {
944 if relates.is_empty() {
945 return Ok(crate::ops::BatchResult {
946 orphan_stubs_removed: Vec::new(),
947 errors_suppressed: 0,
948 applied: true,
949 results: Vec::new(),
950 succeeded: 0,
951 failed: 0,
952 commit_sha: String::new(),
953 });
954 }
955
956 let mut touched_mems: Vec<String> = relates
960 .iter()
961 .flat_map(|(a, _)| [a.source.mem().to_string(), a.target.mem().to_string()])
962 .collect();
963 touched_mems.sort();
964 touched_mems.dedup();
965 for m in &touched_mems {
966 self.reload_if_stale(Some(m));
967 }
968
969 let store_snapshot = self.store.clone();
974
975 enum ItemState {
976 Applied(&'static str),
977 Noop,
978 Error,
979 }
980 let mut items: Vec<(EntityId, ItemState)> = Vec::with_capacity(relates.len());
981 let mut prepared: Vec<PreparedRelate> = Vec::new();
982 let mut notes: Vec<Option<String>> = Vec::new();
983 let mut errors: Vec<(usize, EngineError)> = Vec::new();
984
985 for (i, (args, note)) in relates.into_iter().enumerate() {
986 let source_id = args.source.clone();
987 let mut args = args;
992 args.dry_run = false;
993 match self.prepare_relate(args, Vec::new()) {
994 Ok(RelatePrepareOutcome::Done(_)) => {
995 items.push((source_id, ItemState::Noop));
996 }
997 Ok(RelatePrepareOutcome::Prepared(p)) => {
998 if let Err(e) = self.stage_prepared_relate(&p) {
1001 self.store = store_snapshot;
1002 self.discard_all_pending();
1003 return Err(e);
1004 }
1005 if let Err(e) = self.apply_prepared_relate_to_store(&p) {
1006 self.store = store_snapshot;
1007 self.discard_all_pending();
1008 return Err(e);
1009 }
1010 if let Some(schema) = self.schemas.get(&p.source_mem)
1017 && super::rel_type_declares_derivation(schema, &p.rel_type)
1018 {
1019 let backend = self.mounts[p.mount_idx].backend.as_ref();
1020 let (from, rel, to) =
1021 (p.from.to_string(), p.rel_type.clone(), p.to.to_string());
1022 let staged = match p.action {
1023 RelateAction::Added => {
1024 let hash = self
1025 .store
1026 .get(&p.to)
1027 .map(|e| e.content_hash.clone())
1028 .unwrap_or_default();
1029 super::stage_derivation_sidecar(backend, |s| {
1030 s.set(&from, &rel, &to, &hash)
1031 })
1032 }
1033 RelateAction::Removed => {
1034 super::stage_derivation_sidecar(backend, |s| {
1035 s.remove(&from, &rel, &to)
1036 })
1037 }
1038 _ => Ok(()),
1039 };
1040 if let Err(e) = staged {
1041 self.store = store_snapshot;
1042 self.discard_all_pending();
1043 return Err(e);
1044 }
1045 }
1046 let label = match p.action {
1047 RelateAction::Added => "added",
1048 RelateAction::Removed => "removed",
1049 _ => unreachable!("no-ops resolve to Done"),
1050 };
1051 items.push((source_id, ItemState::Applied(label)));
1052 prepared.push(p);
1053 notes.push(note);
1054 }
1055 Err(e) => {
1056 items.push((source_id, ItemState::Error));
1057 errors.push((i, e));
1058 }
1059 }
1060 }
1061
1062 if !errors.is_empty() {
1063 self.store = store_snapshot;
1065 self.discard_all_pending();
1066 let failed = errors.len();
1067 let mut error_map: std::collections::HashMap<usize, EngineError> =
1068 errors.into_iter().collect();
1069 let mut reported = 0usize;
1070 let mut suppressed = 0usize;
1071 let results: Vec<crate::ops::BatchEntry> = items
1072 .into_iter()
1073 .enumerate()
1074 .map(|(i, (id, _))| match error_map.remove(&i) {
1075 Some(e) => {
1076 if reported < Self::BATCH_ERROR_REPORT_CAP {
1077 reported += 1;
1078 crate::ops::BatchEntry {
1079 id,
1080 action: "error".to_string(),
1081 error: Some(super::update::batch_error_envelope(&e)),
1082 }
1083 } else {
1084 suppressed += 1;
1085 crate::ops::BatchEntry {
1086 id,
1087 action: "error".to_string(),
1088 error: None,
1089 }
1090 }
1091 }
1092 None => crate::ops::BatchEntry {
1093 id,
1094 action: "not_applied".to_string(),
1095 error: None,
1096 },
1097 })
1098 .collect();
1099 return Ok(crate::ops::BatchResult {
1100 orphan_stubs_removed: Vec::new(),
1101 errors_suppressed: suppressed,
1102 applied: false,
1103 results,
1104 succeeded: 0,
1105 failed,
1106 commit_sha: String::new(),
1107 });
1108 }
1109
1110 if dry_run {
1116 let removed_targets: Vec<EntityId> = prepared
1117 .iter()
1118 .filter(|p| matches!(p.action, RelateAction::Removed))
1119 .map(|p| p.to.clone())
1120 .collect();
1121 let orphan_stubs_removed =
1122 super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1123 self.store = store_snapshot;
1124 self.discard_all_pending();
1125 let succeeded = items.len();
1126 let results: Vec<crate::ops::BatchEntry> = items
1127 .into_iter()
1128 .map(|(id, state)| crate::ops::BatchEntry {
1129 id,
1130 action: match state {
1131 ItemState::Applied(label) => label.to_string(),
1132 ItemState::Noop => "noop".to_string(),
1133 ItemState::Error => unreachable!("refusal path returned above"),
1134 },
1135 error: None,
1136 })
1137 .collect();
1138 return Ok(crate::ops::BatchResult {
1139 orphan_stubs_removed,
1140 errors_suppressed: 0,
1141 applied: true,
1142 results,
1143 succeeded,
1144 failed: 0,
1145 commit_sha: String::new(),
1146 });
1147 }
1148
1149 let mut distinct_mounts: Vec<usize> = Vec::new();
1151 for p in &prepared {
1152 if !distinct_mounts.contains(&p.mount_idx) {
1153 distinct_mounts.push(p.mount_idx);
1154 }
1155 }
1156 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1157 for &m in &distinct_mounts {
1158 let mut entity_ids: Vec<String> = Vec::new();
1161 let mut edge_count = 0usize;
1162 for p in prepared.iter().filter(|p| p.mount_idx == m) {
1163 edge_count += 1;
1164 let s = p.from.to_string();
1165 if !entity_ids.contains(&s) {
1166 entity_ids.push(s);
1167 }
1168 }
1169 let subject = format!("memstead: batch-relate ({edge_count} edges)");
1170 let ctx = CommitContext {
1171 actor,
1172 client: client.cloned(),
1173 tool: Some("batch_relate"),
1174 note: None,
1175 role: self.current_role,
1176 logical_operation_id: None,
1177 entity_ids: Some(entity_ids),
1178 };
1179 match self.mounts[m].backend.commit(&subject, &ctx) {
1180 Ok(sha) => mount_commits.push((m, sha)),
1181 Err(e) => {
1182 self.store = store_snapshot;
1187 self.discard_all_pending();
1188 return Err(e.into());
1189 }
1190 }
1191 }
1192
1193 for (p, note) in prepared.iter().zip(notes.iter()) {
1195 let commit_sha = mount_commits
1196 .iter()
1197 .find(|(m, _)| *m == p.mount_idx)
1198 .map(|(_, s)| s.clone())
1199 .unwrap_or_default();
1200 self.mounts[p.mount_idx].backend.append_provenance(
1201 &Provenance::new(
1202 std::time::SystemTime::now(),
1203 ProvenanceKind::Relate,
1204 Some(p.from.to_string()),
1205 actor,
1206 client.cloned(),
1207 note.clone(),
1208 )
1209 .with_role(self.current_role),
1210 )?;
1211 self.record_self_write(p.mount_idx, &commit_sha);
1212 self.stamp_mutation_versions(p.mount_idx);
1213 }
1214
1215 let removed_targets: Vec<EntityId> = prepared
1218 .iter()
1219 .filter(|p| matches!(p.action, RelateAction::Removed))
1220 .map(|p| p.to.clone())
1221 .collect();
1222 let orphan_stubs_removed =
1223 super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1224
1225 self.invalidate_communities();
1226 self.invalidate_search_indexes();
1227
1228 let commit_sha = mount_commits
1229 .last()
1230 .map(|(_, s)| s.clone())
1231 .unwrap_or_default();
1232 let succeeded = items.len();
1233 let results: Vec<crate::ops::BatchEntry> = items
1234 .into_iter()
1235 .map(|(id, state)| crate::ops::BatchEntry {
1236 id,
1237 action: match state {
1238 ItemState::Applied(label) => label.to_string(),
1239 ItemState::Noop => "noop".to_string(),
1240 ItemState::Error => unreachable!("refusal path returned above"),
1241 },
1242 error: None,
1243 })
1244 .collect();
1245
1246 Ok(crate::ops::BatchResult {
1247 orphan_stubs_removed,
1248 errors_suppressed: 0,
1249 applied: true,
1250 results,
1251 succeeded,
1252 failed: 0,
1253 commit_sha,
1254 })
1255 }
1256
1257 pub fn relate(
1264 &mut self,
1265 from: &EntityId,
1266 to: &EntityId,
1267 rel_type: &str,
1268 remove: bool,
1269 ctx: &CommitContext<'_>,
1270 ) -> Result<RelateEntityOutcome, EngineError> {
1271 let args = RelateEntityArgs {
1272 source: from.clone(),
1273 expected_hash: None,
1274 rel_type: rel_type.to_string(),
1275 target: to.clone(),
1276 remove,
1277 description: None,
1278 dry_run: false,
1279 };
1280 self.relate_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1281 }
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286
1287 use indexmap::IndexMap;
1288 use tempfile::TempDir;
1289
1290 use crate::backend::MemBackend;
1291 use crate::engine::test_helpers::*;
1292 use crate::engine::{CreateEntityArgs, Engine, EngineError, RelateAction, RelateEntityArgs};
1293 use crate::ops::WarningHint;
1294 use crate::storage::FilesystemMemWriter;
1295 use crate::vcs::{Actor, CommitContext};
1296
1297 #[test]
1298 fn relate_alias_delegates_to_relate_entity() {
1299 let tmp = TempDir::new().unwrap();
1304 let mem_dir = tmp.path().to_path_buf();
1305 let writer = FilesystemMemWriter::new(mem_dir.clone());
1306 let mut engine = Engine::from_mounts(vec![(
1307 folder_mount("specs", mem_dir),
1308 Box::new(writer) as Box<dyn MemBackend>,
1309 )])
1310 .unwrap();
1311
1312 let a = engine
1314 .create_entity(
1315 CreateEntityArgs {
1316 anchors: Vec::new(),
1317 mem: "specs".to_string(),
1318 title: "A".to_string(),
1319 entity_type: "spec".to_string(),
1320 sections: IndexMap::from_iter([
1321 ("identity".to_string(), "seed identity".to_string()),
1322 ("purpose".to_string(), "seed purpose".to_string()),
1323 ]),
1324 metadata: IndexMap::new(),
1325 relations: Vec::new(),
1326 dry_run: false,
1327 },
1328 Actor::Cli,
1329 None,
1330 None,
1331 )
1332 .unwrap();
1333 let b = engine
1334 .create_entity(
1335 CreateEntityArgs {
1336 anchors: Vec::new(),
1337 mem: "specs".to_string(),
1338 title: "B".to_string(),
1339 entity_type: "spec".to_string(),
1340 sections: IndexMap::from_iter([
1341 ("identity".to_string(), "seed identity".to_string()),
1342 ("purpose".to_string(), "seed purpose".to_string()),
1343 ]),
1344 metadata: IndexMap::new(),
1345 relations: Vec::new(),
1346 dry_run: false,
1347 },
1348 Actor::Cli,
1349 None,
1350 None,
1351 )
1352 .unwrap();
1353
1354 let ctx = CommitContext::internal();
1356 let result = engine.relate(&a.id, &b.id, "PART_OF", false, &ctx).unwrap();
1357 assert_eq!(result.from, a.id);
1358 assert_eq!(result.to, b.id);
1359 assert_eq!(result.rel_type, "PART_OF");
1360 let outgoing: Vec<_> = engine.store().outgoing(&a.id).to_vec();
1362 assert!(
1363 outgoing
1364 .iter()
1365 .any(|e| e.target == b.id && e.rel_type == "PART_OF")
1366 );
1367 }
1368
1369 #[test]
1370 fn relate_entity_appends_relationship_and_logs_provenance() {
1371 let tmp = TempDir::new().unwrap();
1372 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1373 let (actor, client) = cli_actor();
1374 let target = engine
1375 .create_entity(
1376 empty_create_args("specs", "Target"),
1377 actor,
1378 Some(&client),
1379 None,
1380 )
1381 .unwrap();
1382
1383 let outcome = engine
1384 .relate_entity(
1385 RelateEntityArgs {
1386 source: source.id.clone(),
1387 expected_hash: Some(source.content_hash.clone()),
1388 rel_type: "USES".to_string(),
1389 target: target.id.clone(),
1390 remove: false,
1391 description: None,
1392 dry_run: false,
1393 },
1394 actor,
1395 Some(&client),
1396 None,
1397 )
1398 .unwrap();
1399 assert_eq!(outcome.action, RelateAction::Added);
1400 assert_ne!(outcome.content_hash, source.content_hash);
1401 let edges = engine.store().outgoing(&source.id);
1403 assert!(
1404 edges
1405 .iter()
1406 .any(|e| e.rel_type == "USES" && e.target == target.id),
1407 "expected USES edge in store"
1408 );
1409 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
1411 assert!(log.contains("\"kind\":\"relate\""));
1412 }
1413
1414 #[test]
1415 fn relate_entity_no_op_when_already_present() {
1416 let tmp = TempDir::new().unwrap();
1417 let (mut engine, source) = engine_with_seed(&tmp, "Already");
1418 let (actor, client) = cli_actor();
1419 let target = engine
1420 .create_entity(empty_create_args("specs", "T2"), actor, Some(&client), None)
1421 .unwrap();
1422 let first = engine
1423 .relate_entity(
1424 RelateEntityArgs {
1425 source: source.id.clone(),
1426 expected_hash: Some(source.content_hash.clone()),
1427 rel_type: "USES".to_string(),
1428 target: target.id.clone(),
1429 remove: false,
1430 description: None,
1431 dry_run: false,
1432 },
1433 actor,
1434 Some(&client),
1435 None,
1436 )
1437 .unwrap();
1438 let second = engine
1439 .relate_entity(
1440 RelateEntityArgs {
1441 source: source.id.clone(),
1442 expected_hash: Some(first.content_hash.clone()),
1443 rel_type: "USES".to_string(),
1444 target: target.id.clone(),
1445 remove: false,
1446 description: None,
1447 dry_run: false,
1448 },
1449 actor,
1450 Some(&client),
1451 None,
1452 )
1453 .unwrap();
1454 assert_eq!(second.action, RelateAction::NoOpAlreadyPresent);
1455 assert_eq!(second.content_hash, first.content_hash);
1457 }
1458
1459 #[test]
1460 fn relate_entity_returns_commit_sha_on_real_write() {
1461 let tmp = TempDir::new().unwrap();
1462 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1463 let (actor, client) = cli_actor();
1464 let target = engine
1465 .create_entity(
1466 empty_create_args("specs", "Target"),
1467 actor,
1468 Some(&client),
1469 None,
1470 )
1471 .unwrap();
1472
1473 let outcome = engine
1474 .relate_entity(
1475 RelateEntityArgs {
1476 source: source.id.clone(),
1477 expected_hash: Some(source.content_hash.clone()),
1478 rel_type: "USES".to_string(),
1479 target: target.id.clone(),
1480 remove: false,
1481 description: None,
1482 dry_run: false,
1483 },
1484 actor,
1485 Some(&client),
1486 None,
1487 )
1488 .unwrap();
1489 assert!(
1493 !outcome.commit_sha.is_empty(),
1494 "commit_sha must be populated on a real write"
1495 );
1496 }
1497
1498 #[test]
1499 fn relate_entity_no_op_paths_carry_typed_warnings_and_empty_commit_sha() {
1500 let tmp = TempDir::new().unwrap();
1501 let (mut engine, source) = engine_with_seed(&tmp, "S");
1502 let (actor, client) = cli_actor();
1503 let target = engine
1504 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1505 .unwrap();
1506
1507 let first = engine
1509 .relate_entity(
1510 RelateEntityArgs {
1511 source: source.id.clone(),
1512 expected_hash: Some(source.content_hash.clone()),
1513 rel_type: "USES".to_string(),
1514 target: target.id.clone(),
1515 remove: false,
1516 description: None,
1517 dry_run: false,
1518 },
1519 actor,
1520 Some(&client),
1521 None,
1522 )
1523 .unwrap();
1524
1525 let dup = engine
1528 .relate_entity(
1529 RelateEntityArgs {
1530 source: source.id.clone(),
1531 expected_hash: Some(first.content_hash.clone()),
1532 rel_type: "USES".to_string(),
1533 target: target.id.clone(),
1534 remove: false,
1535 description: None,
1536 dry_run: false,
1537 },
1538 actor,
1539 Some(&client),
1540 None,
1541 )
1542 .unwrap();
1543 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1544 assert!(dup.commit_sha.is_empty());
1545 assert_eq!(dup.warnings.len(), 1);
1546 assert!(matches!(
1547 dup.warnings[0],
1548 WarningHint::DuplicateRelationship { .. }
1549 ));
1550
1551 let no_such = engine
1554 .relate_entity(
1555 RelateEntityArgs {
1556 source: source.id.clone(),
1557 expected_hash: Some(first.content_hash.clone()),
1558 rel_type: "DEPENDS_ON".to_string(),
1559 target: target.id.clone(),
1560 remove: true,
1561 description: None,
1562 dry_run: false,
1563 },
1564 actor,
1565 Some(&client),
1566 None,
1567 )
1568 .unwrap();
1569 assert_eq!(no_such.action, RelateAction::NoOpAbsent);
1570 assert!(no_such.commit_sha.is_empty());
1571 assert_eq!(no_such.warnings.len(), 1);
1572 assert!(matches!(
1573 no_such.warnings[0],
1574 WarningHint::NoSuchRelationship { .. }
1575 ));
1576 }
1577
1578 #[test]
1579 fn relate_entity_creates_stub_for_absent_target_on_add_path() {
1580 let tmp = TempDir::new().unwrap();
1581 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1582 let (actor, client) = cli_actor();
1583 let absent_target = crate::EntityId::new("specs", "ghost-target");
1584 assert!(!engine.store().contains(&absent_target));
1586
1587 let outcome = engine
1588 .relate_entity(
1589 RelateEntityArgs {
1590 source: source.id.clone(),
1591 expected_hash: Some(source.content_hash.clone()),
1592 rel_type: "USES".to_string(),
1593 target: absent_target.clone(),
1594 remove: false,
1595 description: None,
1596 dry_run: false,
1597 },
1598 actor,
1599 Some(&client),
1600 None,
1601 )
1602 .unwrap();
1603
1604 assert_eq!(outcome.action, RelateAction::Added);
1605 assert_eq!(outcome.source, "explicit");
1606 let stub_warning = outcome
1612 .warnings
1613 .iter()
1614 .find_map(|w| match w {
1615 crate::ops::WarningHint::AutoStubCreated { stub_id, .. } => Some(stub_id.clone()),
1616 _ => None,
1617 })
1618 .expect("AutoStubCreated warning must surface when target was absent");
1619 assert_eq!(stub_warning, absent_target);
1620 let msg = outcome
1623 .warnings
1624 .iter()
1625 .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
1626 .unwrap()
1627 .message();
1628 assert!(
1629 msg.contains("stub auto-created"),
1630 "real relate keeps the performed-effect wording: {msg}"
1631 );
1632
1633 let stub = engine.store().get(&absent_target).expect("stub upserted");
1635 assert!(stub.stub);
1636 assert!(stub.entity_type.is_empty());
1637 assert!(stub.file_path.is_empty());
1638 }
1639
1640 #[test]
1641 fn relate_entity_skips_stub_creation_when_target_already_exists() {
1642 let tmp = TempDir::new().unwrap();
1643 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1644 let (actor, client) = cli_actor();
1645 let target = engine
1646 .create_entity(
1647 empty_create_args("specs", "Real"),
1648 actor,
1649 Some(&client),
1650 None,
1651 )
1652 .unwrap();
1653
1654 let outcome = engine
1655 .relate_entity(
1656 RelateEntityArgs {
1657 source: source.id.clone(),
1658 expected_hash: Some(source.content_hash.clone()),
1659 rel_type: "USES".to_string(),
1660 target: target.id.clone(),
1661 remove: false,
1662 description: None,
1663 dry_run: false,
1664 },
1665 actor,
1666 Some(&client),
1667 None,
1668 )
1669 .unwrap();
1670
1671 assert!(
1672 !outcome
1673 .warnings
1674 .iter()
1675 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1676 "AutoStubCreated must not surface when target was already in store"
1677 );
1678 assert_eq!(outcome.source, "explicit");
1679 let target_after = engine.store().get(&target.id).unwrap();
1681 assert!(!target_after.stub);
1682 }
1683
1684 #[test]
1685 fn relate_entity_does_not_create_stub_on_remove_path() {
1686 let tmp = TempDir::new().unwrap();
1687 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1688 let (actor, client) = cli_actor();
1689 let absent_target = crate::EntityId::new("specs", "never-existed");
1690
1691 let outcome = engine
1692 .relate_entity(
1693 RelateEntityArgs {
1694 source: source.id.clone(),
1695 expected_hash: Some(source.content_hash.clone()),
1696 rel_type: "USES".to_string(),
1697 target: absent_target.clone(),
1698 remove: true,
1699 description: None,
1700 dry_run: false,
1701 },
1702 actor,
1703 Some(&client),
1704 None,
1705 )
1706 .unwrap();
1707
1708 assert_eq!(outcome.action, RelateAction::NoOpAbsent);
1711 assert!(
1712 !outcome
1713 .warnings
1714 .iter()
1715 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1716 "remove path must never auto-stub the target",
1717 );
1718 assert!(!engine.store().contains(&absent_target));
1719 }
1720
1721 #[test]
1722 fn relate_entity_remove_refuses_when_source_body_still_references_target() {
1723 use crate::engine::CreateEntityArgs;
1724 use indexmap::IndexMap;
1725
1726 let tmp = TempDir::new().unwrap();
1727 let mem_dir = tmp.path().to_path_buf();
1728 let writer = FilesystemMemWriter::new(mem_dir.clone());
1729 let mut engine = Engine::from_mounts(vec![(
1730 folder_mount("specs", mem_dir.clone()),
1731 Box::new(writer) as Box<dyn MemBackend>,
1732 )])
1733 .unwrap();
1734 let (actor, client) = cli_actor();
1735
1736 let target = engine
1737 .create_entity(
1738 empty_create_args("specs", "Target"),
1739 actor,
1740 Some(&client),
1741 None,
1742 )
1743 .unwrap();
1744
1745 let mut sections: IndexMap<String, String> = IndexMap::new();
1751 sections.insert("identity".to_string(), "source identity".to_string());
1752 sections.insert(
1753 "purpose".to_string(),
1754 "discussion stems from [[target]]".to_string(),
1755 );
1756 let source = engine
1757 .create_entity(
1758 CreateEntityArgs {
1759 anchors: Vec::new(),
1760 mem: "specs".to_string(),
1761 title: "Source".to_string(),
1762 entity_type: "spec".to_string(),
1763 sections,
1764 metadata: IndexMap::new(),
1765 relations: Vec::new(),
1766 dry_run: false,
1767 },
1768 actor,
1769 Some(&client),
1770 None,
1771 )
1772 .unwrap();
1773 let related = source.clone();
1774
1775 let err = engine
1779 .relate_entity(
1780 RelateEntityArgs {
1781 source: source.id.clone(),
1782 expected_hash: Some(related.content_hash.clone()),
1783 rel_type: "REFERENCES".to_string(),
1784 target: target.id.clone(),
1785 remove: true,
1786 description: None,
1787 dry_run: false,
1788 },
1789 actor,
1790 Some(&client),
1791 None,
1792 )
1793 .unwrap_err();
1794 match err {
1795 EngineError::RelationHasBodyLinks {
1796 from_id,
1797 to_id,
1798 rel_type,
1799 body_links,
1800 } => {
1801 assert_eq!(from_id, source.id.to_string());
1802 assert_eq!(to_id, target.id.to_string());
1803 assert_eq!(rel_type, "REFERENCES");
1804 assert_eq!(body_links, vec!["purpose".to_string()]);
1805 }
1806 other => panic!("expected RelationHasBodyLinks, got {other:?}"),
1807 }
1808 let in_mem = engine.get_entity(&source.id).unwrap();
1811 assert!(
1812 in_mem
1813 .relationships
1814 .iter()
1815 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
1816 "relation must survive the refused remove; got {:?}",
1817 in_mem.relationships
1818 );
1819 }
1820
1821 #[test]
1822 fn relate_entity_remove_succeeds_when_body_no_longer_references_target() {
1823 let tmp = TempDir::new().unwrap();
1824 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1825 let (actor, client) = cli_actor();
1826 let target = engine
1827 .create_entity(
1828 empty_create_args("specs", "Other"),
1829 actor,
1830 Some(&client),
1831 None,
1832 )
1833 .unwrap();
1834 let related = 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: target.id.clone(),
1846 remove: false,
1847 description: None,
1848 dry_run: false,
1849 },
1850 actor,
1851 Some(&client),
1852 None,
1853 )
1854 .unwrap();
1855 let removed = engine
1856 .relate_entity(
1857 RelateEntityArgs {
1858 source: source.id.clone(),
1859 expected_hash: Some(related.content_hash.clone()),
1860 rel_type: "USES".to_string(),
1861 target: target.id.clone(),
1862 remove: true,
1863 description: None,
1864 dry_run: false,
1865 },
1866 actor,
1867 Some(&client),
1868 None,
1869 )
1870 .unwrap();
1871 assert_eq!(removed.action, RelateAction::Removed);
1872 }
1873
1874 #[test]
1875 fn relate_entity_auto_stub_is_tagged_forward_reference() {
1876 use crate::entity::StubKind;
1881
1882 let tmp = TempDir::new().unwrap();
1883 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1884 let (actor, client) = cli_actor();
1885 let absent_target = crate::EntityId::new("specs", "absent-target");
1886
1887 let _ = engine
1888 .relate_entity(
1889 RelateEntityArgs {
1890 source: source.id.clone(),
1891 expected_hash: Some(source.content_hash.clone()),
1892 rel_type: "USES".to_string(),
1893 target: absent_target.clone(),
1894 remove: false,
1895 description: None,
1896 dry_run: false,
1897 },
1898 actor,
1899 Some(&client),
1900 None,
1901 )
1902 .unwrap();
1903
1904 let stub = engine
1905 .get_entity(&absent_target)
1906 .expect("relate auto-stubbed target must be in the store");
1907 assert!(stub.stub, "auto-stubbed target must carry stub: true");
1908 assert_eq!(
1909 stub.stub_kind,
1910 Some(StubKind::ForwardReference),
1911 "auto-stub from relate must be tagged ForwardReference; got {:?}",
1912 stub.stub_kind
1913 );
1914 }
1915
1916 #[test]
1917 fn relate_entity_case_insensitive_rel_type_input_canonicalises_to_upper_snake_case() {
1918 let tmp = TempDir::new().unwrap();
1923 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1924 let (actor, client) = cli_actor();
1925 let target = engine
1926 .create_entity(
1927 empty_create_args("specs", "Target"),
1928 actor,
1929 Some(&client),
1930 None,
1931 )
1932 .unwrap();
1933
1934 let lower = engine
1936 .relate_entity(
1937 RelateEntityArgs {
1938 source: source.id.clone(),
1939 expected_hash: Some(source.content_hash.clone()),
1940 rel_type: "uses".to_string(),
1941 target: target.id.clone(),
1942 remove: false,
1943 description: None,
1944 dry_run: false,
1945 },
1946 actor,
1947 Some(&client),
1948 None,
1949 )
1950 .unwrap();
1951 assert_eq!(lower.rel_type, "USES", "response must echo canonical form");
1952 assert_eq!(lower.action, RelateAction::Added);
1953 let edges = engine.store().outgoing(&source.id);
1954 assert!(
1955 edges
1956 .iter()
1957 .any(|e| e.rel_type == "USES" && e.target == target.id),
1958 "store must hold UPPER_SNAKE_CASE rel_type after lowercase input"
1959 );
1960
1961 let dup = engine
1965 .relate_entity(
1966 RelateEntityArgs {
1967 source: source.id.clone(),
1968 expected_hash: Some(lower.content_hash.clone()),
1969 rel_type: "Uses".to_string(),
1970 target: target.id.clone(),
1971 remove: false,
1972 description: None,
1973 dry_run: false,
1974 },
1975 actor,
1976 Some(&client),
1977 None,
1978 )
1979 .unwrap();
1980 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1981 assert_eq!(dup.rel_type, "USES");
1982 assert!(matches!(
1983 dup.warnings[0],
1984 WarningHint::DuplicateRelationship { .. }
1985 ));
1986 }
1987
1988 #[test]
1989 fn relate_entity_rejects_cross_mem_when_policy_denies() {
1990 let tmp = TempDir::new().unwrap();
1997 let (mut engine, source) = engine_with_seed(&tmp, "S");
1998 let (actor, client) = cli_actor();
1999 let err = engine
2000 .relate_entity(
2001 RelateEntityArgs {
2002 source: source.id.clone(),
2003 expected_hash: Some(source.content_hash.clone()),
2004 rel_type: "USES".to_string(),
2005 target: crate::EntityId::new("other-mem", "thing"),
2006 remove: false,
2007 description: None,
2008 dry_run: false,
2009 },
2010 actor,
2011 Some(&client),
2012 None,
2013 )
2014 .unwrap_err();
2015 match err {
2016 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2017 assert_eq!(from_mem, "specs");
2018 assert_eq!(to_mem, "other-mem");
2019 }
2020 other => panic!("expected CrossMemLinkNotAllowed, got {other:?}"),
2021 }
2022 }
2023
2024 #[test]
2033 fn relate_entity_malformed_bare_target_surfaces_invalid_entity_id_not_cross_mem() {
2034 let tmp = TempDir::new().unwrap();
2035 let (mut engine, source) = engine_with_seed(&tmp, "S");
2036 let (actor, client) = cli_actor();
2037 let err = engine
2038 .relate_entity(
2039 RelateEntityArgs {
2040 source: source.id.clone(),
2041 expected_hash: Some(source.content_hash.clone()),
2042 rel_type: "USES".to_string(),
2043 target: crate::EntityId("bad target with spaces!!".to_string()),
2046 remove: false,
2047 description: None,
2048 dry_run: false,
2049 },
2050 actor,
2051 Some(&client),
2052 None,
2053 )
2054 .unwrap_err();
2055 assert!(
2056 matches!(err, EngineError::InvalidEntityId { .. }),
2057 "malformed bare-string target must surface INVALID_ENTITY_ID, got: {err:?}"
2058 );
2059 }
2060
2061 #[test]
2066 fn relate_entity_malformed_prefixed_target_surfaces_invalid_entity_id() {
2067 let tmp = TempDir::new().unwrap();
2068 let (mut engine, source) = engine_with_seed(&tmp, "S");
2069 let (actor, client) = cli_actor();
2070 let source_mem = source.id.mem().to_string();
2071 let err = engine
2072 .relate_entity(
2073 RelateEntityArgs {
2074 source: source.id.clone(),
2075 expected_hash: Some(source.content_hash.clone()),
2076 rel_type: "USES".to_string(),
2077 target: crate::EntityId(format!("{source_mem}--bad target with spaces!!")),
2078 remove: false,
2079 description: None,
2080 dry_run: false,
2081 },
2082 actor,
2083 Some(&client),
2084 None,
2085 )
2086 .unwrap_err();
2087 assert!(
2088 matches!(err, EngineError::InvalidEntityId { .. }),
2089 "prefixed malformed target must still surface INVALID_ENTITY_ID, got: {err:?}"
2090 );
2091 }
2092
2093 #[test]
2099 fn relate_add_bumps_last_modified_on_source_entity() {
2100 let tmp = TempDir::new().unwrap();
2101 let (mut engine, source) = engine_with_seed(&tmp, "S");
2102 let (actor, client) = cli_actor();
2103 let target = engine
2104 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2105 .unwrap();
2106
2107 let outcome = engine
2108 .relate_entity(
2109 RelateEntityArgs {
2110 source: source.id.clone(),
2111 expected_hash: Some(source.content_hash.clone()),
2112 rel_type: "USES".to_string(),
2113 target: target.id.clone(),
2114 remove: false,
2115 description: None,
2116 dry_run: false,
2117 },
2118 actor,
2119 Some(&client),
2120 None,
2121 )
2122 .unwrap();
2123 assert_eq!(outcome.action, RelateAction::Added);
2124
2125 let post = engine.get_entity(&source.id).unwrap();
2130 let last_modified = post
2131 .metadata
2132 .get("last_modified")
2133 .map(|v| v.to_frontmatter_string())
2134 .unwrap_or_default();
2135 assert!(
2136 last_modified.starts_with("20"),
2137 "last_modified must carry an ISO timestamp post-relate; got: {last_modified:?}"
2138 );
2139 }
2140
2141 #[test]
2146 fn relate_add_noop_does_not_bump_last_modified() {
2147 let tmp = TempDir::new().unwrap();
2148 let (mut engine, source) = engine_with_seed(&tmp, "S");
2149 let (actor, client) = cli_actor();
2150 let target = engine
2151 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2152 .unwrap();
2153 let first = engine
2154 .relate_entity(
2155 RelateEntityArgs {
2156 source: source.id.clone(),
2157 expected_hash: Some(source.content_hash.clone()),
2158 rel_type: "USES".to_string(),
2159 target: target.id.clone(),
2160 remove: false,
2161 description: None,
2162 dry_run: false,
2163 },
2164 actor,
2165 Some(&client),
2166 None,
2167 )
2168 .unwrap();
2169 let pre = engine.get_entity(&source.id).unwrap();
2170 let pre_stamp = pre
2171 .metadata
2172 .get("last_modified")
2173 .map(|v| v.to_frontmatter_string())
2174 .unwrap_or_default();
2175
2176 let dup = engine
2178 .relate_entity(
2179 RelateEntityArgs {
2180 source: source.id.clone(),
2181 expected_hash: Some(first.content_hash.clone()),
2182 rel_type: "USES".to_string(),
2183 target: target.id.clone(),
2184 remove: false,
2185 description: None,
2186 dry_run: false,
2187 },
2188 actor,
2189 Some(&client),
2190 None,
2191 )
2192 .unwrap();
2193 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
2194
2195 let post = engine.get_entity(&source.id).unwrap();
2196 let post_stamp = post
2197 .metadata
2198 .get("last_modified")
2199 .map(|v| v.to_frontmatter_string())
2200 .unwrap_or_default();
2201 assert_eq!(
2202 pre_stamp, post_stamp,
2203 "last_modified must not advance on a duplicate-add no-op (no disk write happened)"
2204 );
2205 }
2206
2207 #[test]
2212 fn cross_mem_relate_to_uncreated_mem_emits_typed_warning() {
2213 use memstead_schema::workspace_config::CrossLinkValue;
2214 let tmp = TempDir::new().unwrap();
2215 let (mut engine, source) = engine_with_seed(&tmp, "S");
2216 let (actor, client) = cli_actor();
2217 let mut settings = crate::workspace::WorkspaceSettings::default();
2221 settings.cross_mem_links.insert(
2222 "specs".to_string(),
2223 CrossLinkValue::List(vec!["uncreated-mem".to_string()]),
2224 );
2225 engine.set_settings(settings);
2226
2227 let absent = crate::EntityId::new("uncreated-mem", "ghost");
2228 let outcome = engine
2229 .relate_entity(
2230 RelateEntityArgs {
2231 source: source.id.clone(),
2232 expected_hash: Some(source.content_hash.clone()),
2233 rel_type: "USES".to_string(),
2234 target: absent.clone(),
2235 remove: false,
2236 description: None,
2237 dry_run: false,
2238 },
2239 actor,
2240 Some(&client),
2241 None,
2242 )
2243 .unwrap();
2244 assert_eq!(outcome.action, RelateAction::Added);
2245
2246 let saw_uncreated = outcome.warnings.iter().any(|w| {
2248 matches!(
2249 w,
2250 WarningHint::CrossMemTargetMemUncreated {
2251 from_mem,
2252 to_mem,
2253 target_id,
2254 } if from_mem == "specs"
2255 && to_mem == "uncreated-mem"
2256 && target_id == &absent
2257 )
2258 });
2259 assert!(
2260 saw_uncreated,
2261 "CrossMemTargetMemUncreated warning must surface; got: {:?}",
2262 outcome.warnings
2263 );
2264 assert!(engine.store().contains(&absent));
2266 }
2267
2268 #[test]
2274 fn cross_mem_relate_policy_refusal_preempts_uncreated_mem_warning() {
2275 let tmp = TempDir::new().unwrap();
2276 let (mut engine, source) = engine_with_seed(&tmp, "S");
2277 let (actor, client) = cli_actor();
2278 let absent = crate::EntityId::new("uncreated-mem", "ghost");
2280 let err = engine
2281 .relate_entity(
2282 RelateEntityArgs {
2283 source: source.id.clone(),
2284 expected_hash: Some(source.content_hash.clone()),
2285 rel_type: "USES".to_string(),
2286 target: absent.clone(),
2287 remove: false,
2288 description: None,
2289 dry_run: false,
2290 },
2291 actor,
2292 Some(&client),
2293 None,
2294 )
2295 .unwrap_err();
2296 assert!(matches!(err, EngineError::CrossMemLinkNotAllowed { .. }));
2297 assert!(!engine.store().contains(&absent));
2299 }
2300
2301 #[test]
2307 fn relate_remove_garbage_collects_orphan_stub() {
2308 let tmp = TempDir::new().unwrap();
2309 let (mut engine, source) = engine_with_seed(&tmp, "Src");
2310 let (actor, client) = cli_actor();
2311 let stub_id = crate::EntityId::new("specs", "ghost-target");
2312
2313 let added = engine
2315 .relate_entity(
2316 RelateEntityArgs {
2317 source: source.id.clone(),
2318 expected_hash: Some(source.content_hash.clone()),
2319 rel_type: "USES".to_string(),
2320 target: stub_id.clone(),
2321 remove: false,
2322 description: None,
2323 dry_run: false,
2324 },
2325 actor,
2326 Some(&client),
2327 None,
2328 )
2329 .unwrap();
2330 assert!(engine.store().contains(&stub_id));
2331
2332 let removed = engine
2333 .relate_entity(
2334 RelateEntityArgs {
2335 source: source.id.clone(),
2336 expected_hash: Some(added.content_hash.clone()),
2337 rel_type: "USES".to_string(),
2338 target: stub_id.clone(),
2339 remove: true,
2340 description: None,
2341 dry_run: false,
2342 },
2343 actor,
2344 Some(&client),
2345 None,
2346 )
2347 .unwrap();
2348 assert_eq!(removed.action, RelateAction::Removed);
2349 assert_eq!(
2350 removed.orphan_stubs_removed,
2351 vec![stub_id.clone()],
2352 "orphan stub must be GC'd in the same call"
2353 );
2354 assert!(
2355 !engine.store().contains(&stub_id),
2356 "stub must be gone from the store after GC"
2357 );
2358 }
2359
2360 #[test]
2366 fn relate_remove_does_not_gc_stub_with_surviving_incoming_edge() {
2367 let tmp = TempDir::new().unwrap();
2368 let (mut engine, source_a) = engine_with_seed(&tmp, "SrcA");
2369 let (actor, client) = cli_actor();
2370 let source_b = engine
2371 .create_entity(
2372 empty_create_args("specs", "SrcB"),
2373 actor,
2374 Some(&client),
2375 None,
2376 )
2377 .unwrap();
2378 let stub_id = crate::EntityId::new("specs", "ghost-target");
2379
2380 let a_added = engine
2382 .relate_entity(
2383 RelateEntityArgs {
2384 source: source_a.id.clone(),
2385 expected_hash: Some(source_a.content_hash.clone()),
2386 rel_type: "USES".to_string(),
2387 target: stub_id.clone(),
2388 remove: false,
2389 description: None,
2390 dry_run: false,
2391 },
2392 actor,
2393 Some(&client),
2394 None,
2395 )
2396 .unwrap();
2397 let _b_added = engine
2398 .relate_entity(
2399 RelateEntityArgs {
2400 source: source_b.id.clone(),
2401 expected_hash: Some(source_b.content_hash.clone()),
2402 rel_type: "USES".to_string(),
2403 target: stub_id.clone(),
2404 remove: false,
2405 description: None,
2406 dry_run: false,
2407 },
2408 actor,
2409 Some(&client),
2410 None,
2411 )
2412 .unwrap();
2413
2414 let removed = engine
2417 .relate_entity(
2418 RelateEntityArgs {
2419 source: source_a.id.clone(),
2420 expected_hash: Some(a_added.content_hash.clone()),
2421 rel_type: "USES".to_string(),
2422 target: stub_id.clone(),
2423 remove: true,
2424 description: None,
2425 dry_run: false,
2426 },
2427 actor,
2428 Some(&client),
2429 None,
2430 )
2431 .unwrap();
2432 assert_eq!(removed.action, RelateAction::Removed);
2433 assert!(
2434 removed.orphan_stubs_removed.is_empty(),
2435 "stub with surviving referrer must not be GC'd; got: {:?}",
2436 removed.orphan_stubs_removed
2437 );
2438 assert!(
2439 engine.store().contains(&stub_id),
2440 "stub must remain in store while another referrer holds it"
2441 );
2442 }
2443
2444 mod cross_mem {
2454 use std::collections::BTreeMap;
2455 use std::path::Path;
2456
2457 use indexmap::IndexMap;
2458 use memstead_schema::SchemaRef;
2459 use memstead_schema::workspace_config::CrossLinkValue;
2460 use tempfile::TempDir;
2461
2462 use crate::backend::MemBackend;
2463 use crate::engine::test_helpers::*;
2464 use crate::engine::{
2465 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateAction,
2466 RelateEntityArgs,
2467 };
2468 use crate::storage::FilesystemMemWriter;
2469
2470 use crate::workspace::{
2471 Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
2472 };
2473
2474 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
2475 let dir = root.join(name);
2476 std::fs::create_dir_all(dir.join("types")).unwrap();
2477 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
2478 for (type_name, body) in types {
2479 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
2480 }
2481 }
2482
2483 const TYPE_BODY: &str = r#"description: t
2484when_to_use: Here
2485sections:
2486 - key: body
2487 heading: Body
2488 required: true
2489 search_weight: 10.0
2490 catch_all: true
2491 write_rules: []
2492metadata_fields: []
2493title_weight: 100.0
2494text_fields:
2495 - body
2496hierarchy_relationship: _default
2497no_self_loop_relationships: []
2498updatable_fields:
2499 - title
2500 - body
2501health_required_fields:
2502 - body
2503staleness_threshold_days: 90
2504write_rules: []
2505"#;
2506
2507 fn make_type_yaml(name: &str) -> String {
2508 format!("name: {name}\n{TYPE_BODY}")
2509 }
2510
2511 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
2512 Mount {
2513 mem: mem.to_string(),
2514 schema: Some(pin),
2515 storage: MountStorage::Folder { path },
2516 capability: MountCapability::Write,
2517 lifecycle: MountLifecycle::Eager,
2518 cross_linkable: true,
2519 migration_target: None,
2520 }
2521 }
2522
2523 fn two_mem_engine() -> (TempDir, Engine, CreateEntityOutcome, CreateEntityOutcome) {
2526 let tmp = TempDir::new().unwrap();
2527
2528 let src_manifest = r#"name: src-cv
2531version: 0.1.0
2532description: source schema
2533when_to_use: tests
2534types:
2535 - doc
2536relationships:
2537 mode: strict
2538 definitions:
2539 - name: IMPLEMENTS
2540 description: intra-mem only
2541 default_weight: 1.0
2542 - name: _default
2543 description: fallback
2544 default_weight: 1.0
2545cross_mem_relationships:
2546 - to_schema: tgt-cv
2547 definitions:
2548 - name: ADDRESSES
2549 description: outbound shape-pinned
2550 default_weight: 1.0
2551 source_types: [doc]
2552 target_types: [req]
2553community:
2554 resolution: 1.0
2555 seed: 42
2556"#;
2557 let tgt_manifest = r#"name: tgt-cv
2560version: 0.1.0
2561description: target schema
2562when_to_use: tests
2563types:
2564 - req
2565relationships:
2566 mode: strict
2567 definitions:
2568 - name: PART_OF
2569 description: hierarchy
2570 default_weight: 3.0
2571 acyclic: true
2572 - name: _default
2573 description: fallback
2574 default_weight: 1.0
2575community:
2576 resolution: 1.0
2577 seed: 42
2578"#;
2579 let schemas_dir = tmp.path().join("schemas");
2580 std::fs::create_dir_all(&schemas_dir).unwrap();
2581 write_schema_files(
2582 &schemas_dir,
2583 "src-cv",
2584 src_manifest,
2585 &[("doc", &make_type_yaml("doc"))],
2586 );
2587 write_schema_files(
2588 &schemas_dir,
2589 "tgt-cv",
2590 tgt_manifest,
2591 &[("req", &make_type_yaml("req"))],
2592 );
2593
2594 let src_dir = tmp.path().join("mem-src");
2595 let tgt_dir = tmp.path().join("mem-tgt");
2596 std::fs::create_dir_all(&src_dir).unwrap();
2597 std::fs::create_dir_all(&tgt_dir).unwrap();
2598
2599 let src_writer = FilesystemMemWriter::new(src_dir.clone());
2600 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
2601 let src_pin = SchemaRef::new("src-cv", semver::Version::new(0, 1, 0));
2602 let tgt_pin = SchemaRef::new("tgt-cv", semver::Version::new(0, 1, 0));
2603
2604 let mut engine = Engine::from_mounts_with_schemas_dir(
2605 vec![
2606 (
2607 folder_mount_with_pin("src", src_dir, src_pin),
2608 Box::new(src_writer) as Box<dyn MemBackend>,
2609 ),
2610 (
2611 folder_mount_with_pin("tgt", tgt_dir, tgt_pin),
2612 Box::new(tgt_writer) as Box<dyn MemBackend>,
2613 ),
2614 ],
2615 Some(&schemas_dir),
2616 )
2617 .expect("two-mem engine constructs");
2618
2619 let mut settings = WorkspaceSettings::default();
2623 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2624 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2625 settings.cross_mem_links = links;
2626 engine.set_settings(settings);
2627
2628 let (actor, client) = cli_actor();
2629 let src_entity = engine
2630 .create_entity(
2631 CreateEntityArgs {
2632 anchors: Vec::new(),
2633 mem: "src".to_string(),
2634 title: "Doc One".to_string(),
2635 entity_type: "doc".to_string(),
2636 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2637 metadata: IndexMap::new(),
2638 relations: Vec::new(),
2639 dry_run: false,
2640 },
2641 actor,
2642 Some(&client),
2643 None,
2644 )
2645 .expect("source entity creates");
2646 let tgt_entity = engine
2647 .create_entity(
2648 CreateEntityArgs {
2649 anchors: Vec::new(),
2650 mem: "tgt".to_string(),
2651 title: "Req One".to_string(),
2652 entity_type: "req".to_string(),
2653 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2654 metadata: IndexMap::new(),
2655 relations: Vec::new(),
2656 dry_run: false,
2657 },
2658 actor,
2659 Some(&client),
2660 None,
2661 )
2662 .expect("target entity creates");
2663
2664 (tmp, engine, src_entity, tgt_entity)
2665 }
2666
2667 #[test]
2668 fn cross_different_schema_admits_declared_edge() {
2669 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2670 let (actor, client) = cli_actor();
2671 let outcome = engine
2672 .relate_entity(
2673 RelateEntityArgs {
2674 source: src.id.clone(),
2675 expected_hash: Some(src.content_hash.clone()),
2676 rel_type: "ADDRESSES".to_string(),
2677 target: tgt.id.clone(),
2678 remove: false,
2679 description: None,
2680 dry_run: false,
2681 },
2682 actor,
2683 Some(&client),
2684 None,
2685 )
2686 .expect("declared cross-mem edge admits");
2687 assert_eq!(outcome.rel_type, "ADDRESSES");
2688 }
2689
2690 #[test]
2696 fn same_name_different_version_uses_intra_mem_vocabulary() {
2697 let tmp = TempDir::new().unwrap();
2698
2699 let manifest_for = |version: &str| {
2700 format!(
2701 r#"name: same-dom
2702version: {version}
2703description: same-domain schema
2704when_to_use: tests
2705types:
2706 - doc
2707relationships:
2708 mode: strict
2709 definitions:
2710 - name: IMPLEMENTS
2711 description: intra-mem vocabulary
2712 default_weight: 1.0
2713 - name: _default
2714 description: fallback
2715 default_weight: 1.0
2716community:
2717 resolution: 1.0
2718 seed: 42
2719"#
2720 )
2721 };
2722 let schemas_dir = tmp.path().join("schemas");
2723 std::fs::create_dir_all(&schemas_dir).unwrap();
2724 write_schema_files(
2727 &schemas_dir,
2728 "same-dom-0.1.0",
2729 &manifest_for("0.1.0"),
2730 &[("doc", &make_type_yaml("doc"))],
2731 );
2732 write_schema_files(
2733 &schemas_dir,
2734 "same-dom-0.2.0",
2735 &manifest_for("0.2.0"),
2736 &[("doc", &make_type_yaml("doc"))],
2737 );
2738
2739 let src_dir = tmp.path().join("mem-src");
2740 let tgt_dir = tmp.path().join("mem-tgt");
2741 std::fs::create_dir_all(&src_dir).unwrap();
2742 std::fs::create_dir_all(&tgt_dir).unwrap();
2743 let src_pin = SchemaRef::new("same-dom", semver::Version::new(0, 1, 0));
2744 let tgt_pin = SchemaRef::new("same-dom", semver::Version::new(0, 2, 0));
2745 let mut engine = Engine::from_mounts_with_schemas_dir(
2746 vec![
2747 (
2748 folder_mount_with_pin("src", src_dir.clone(), src_pin),
2749 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2750 ),
2751 (
2752 folder_mount_with_pin("tgt", tgt_dir.clone(), tgt_pin),
2753 Box::new(FilesystemMemWriter::new(tgt_dir)) as Box<dyn MemBackend>,
2754 ),
2755 ],
2756 Some(&schemas_dir),
2757 )
2758 .expect("same-domain two-version engine constructs");
2759
2760 let mut settings = WorkspaceSettings::default();
2761 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2762 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2763 settings.cross_mem_links = links;
2764 engine.set_settings(settings);
2765
2766 let (actor, client) = cli_actor();
2767 let mk_entity = |engine: &mut Engine, mem: &str, title: &str| {
2768 engine
2769 .create_entity(
2770 CreateEntityArgs {
2771 anchors: Vec::new(),
2772 mem: mem.to_string(),
2773 title: title.to_string(),
2774 entity_type: "doc".to_string(),
2775 sections: IndexMap::from_iter([(
2776 "body".to_string(),
2777 "seed".to_string(),
2778 )]),
2779 metadata: IndexMap::new(),
2780 relations: Vec::new(),
2781 dry_run: false,
2782 },
2783 actor,
2784 Some(&client),
2785 None,
2786 )
2787 .expect("entity creates")
2788 };
2789 let src_entity = mk_entity(&mut engine, "src", "Doc A");
2790 let tgt_entity = mk_entity(&mut engine, "tgt", "Doc B");
2791
2792 let outcome = engine
2793 .relate_entity(
2794 RelateEntityArgs {
2795 source: src_entity.id.clone(),
2796 expected_hash: Some(src_entity.content_hash.clone()),
2797 rel_type: "IMPLEMENTS".to_string(),
2798 target: tgt_entity.id.clone(),
2799 remove: false,
2800 description: None,
2801 dry_run: false,
2802 },
2803 actor,
2804 Some(&client),
2805 None,
2806 )
2807 .expect("same-domain edge uses the intra-schema vocabulary across versions");
2808 assert_eq!(outcome.rel_type, "IMPLEMENTS");
2809 }
2810
2811 #[test]
2812 fn cross_different_schema_unknown_rel_type_returns_invalid_rel_type() {
2813 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2817 let (actor, client) = cli_actor();
2818 let err = engine
2819 .relate_entity(
2820 RelateEntityArgs {
2821 source: src.id.clone(),
2822 expected_hash: Some(src.content_hash.clone()),
2823 rel_type: "IMPLEMENTS".to_string(),
2824 target: tgt.id.clone(),
2825 remove: false,
2826 description: None,
2827 dry_run: false,
2828 },
2829 actor,
2830 Some(&client),
2831 None,
2832 )
2833 .unwrap_err();
2834 match err {
2835 EngineError::Validation(
2836 crate::runtime_validator::ValidationError::InvalidRelationshipType {
2837 input,
2838 allowed,
2839 ..
2840 },
2841 ) => {
2842 assert_eq!(input, "IMPLEMENTS");
2843 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
2844 assert!(names.iter().any(|n| n == "ADDRESSES"));
2845 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
2846 }
2847 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
2848 }
2849 }
2850
2851 #[test]
2852 fn cross_different_schema_shape_violation_returns_invalid_rel_shape() {
2853 }
2870
2871 #[test]
2872 fn cross_different_schema_no_matching_entry_returns_edge_not_declared() {
2873 let tmp = TempDir::new().unwrap();
2876 let src_manifest = r#"name: src-cv
2877version: 0.1.0
2878description: source schema
2879when_to_use: tests
2880types:
2881 - doc
2882relationships:
2883 mode: strict
2884 definitions:
2885 - name: IMPLEMENTS
2886 description: intra-mem
2887 default_weight: 1.0
2888 - name: _default
2889 description: fallback
2890 default_weight: 1.0
2891cross_mem_relationships:
2892 - to_schema: tgt-cv
2893 definitions:
2894 - name: ADDRESSES
2895 description: outbound
2896 default_weight: 1.0
2897 source_types: [doc]
2898 target_types: [req]
2899community:
2900 resolution: 1.0
2901 seed: 42
2902"#;
2903 let other_manifest = r#"name: other-cv
2905version: 0.1.0
2906description: foreign schema
2907when_to_use: tests
2908types:
2909 - thing
2910relationships:
2911 mode: strict
2912 definitions:
2913 - name: _default
2914 description: fallback
2915 default_weight: 1.0
2916community:
2917 resolution: 1.0
2918 seed: 42
2919"#;
2920 let schemas_dir = tmp.path().join("schemas");
2921 std::fs::create_dir_all(&schemas_dir).unwrap();
2922 write_schema_files(
2923 &schemas_dir,
2924 "src-cv",
2925 src_manifest,
2926 &[("doc", &make_type_yaml("doc"))],
2927 );
2928 write_schema_files(
2929 &schemas_dir,
2930 "other-cv",
2931 other_manifest,
2932 &[("thing", &make_type_yaml("thing"))],
2933 );
2934 let src_dir = tmp.path().join("mem-src");
2935 let other_dir = tmp.path().join("mem-other");
2936 std::fs::create_dir_all(&src_dir).unwrap();
2937 std::fs::create_dir_all(&other_dir).unwrap();
2938
2939 let mut engine = Engine::from_mounts_with_schemas_dir(
2940 vec![
2941 (
2942 folder_mount_with_pin(
2943 "src",
2944 src_dir.clone(),
2945 SchemaRef::new("src-cv", semver::Version::new(0, 1, 0)),
2946 ),
2947 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2948 ),
2949 (
2950 folder_mount_with_pin(
2951 "other",
2952 other_dir.clone(),
2953 SchemaRef::new("other-cv", semver::Version::new(0, 1, 0)),
2954 ),
2955 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
2956 ),
2957 ],
2958 Some(&schemas_dir),
2959 )
2960 .expect("engine constructs");
2961
2962 let mut settings = WorkspaceSettings::default();
2963 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2964 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2965 settings.cross_mem_links = links;
2966 engine.set_settings(settings);
2967
2968 let (actor, client) = cli_actor();
2969 let src_entity = engine
2970 .create_entity(
2971 CreateEntityArgs {
2972 anchors: Vec::new(),
2973 mem: "src".to_string(),
2974 title: "D".to_string(),
2975 entity_type: "doc".to_string(),
2976 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2977 metadata: IndexMap::new(),
2978 relations: Vec::new(),
2979 dry_run: false,
2980 },
2981 actor,
2982 Some(&client),
2983 None,
2984 )
2985 .unwrap();
2986 let other_entity = engine
2987 .create_entity(
2988 CreateEntityArgs {
2989 anchors: Vec::new(),
2990 mem: "other".to_string(),
2991 title: "T".to_string(),
2992 entity_type: "thing".to_string(),
2993 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2994 metadata: IndexMap::new(),
2995 relations: Vec::new(),
2996 dry_run: false,
2997 },
2998 actor,
2999 Some(&client),
3000 None,
3001 )
3002 .unwrap();
3003
3004 let err = engine
3005 .relate_entity(
3006 RelateEntityArgs {
3007 source: src_entity.id.clone(),
3008 expected_hash: Some(src_entity.content_hash.clone()),
3009 rel_type: "ADDRESSES".to_string(),
3010 target: other_entity.id.clone(),
3011 remove: false,
3012 description: None,
3013 dry_run: false,
3014 },
3015 actor,
3016 Some(&client),
3017 None,
3018 )
3019 .unwrap_err();
3020 match err {
3021 EngineError::CrossMemEdgeNotDeclared {
3022 source_schema,
3023 target_schema,
3024 rel_type,
3025 from_id,
3026 to_id,
3027 } => {
3028 assert_eq!(source_schema, "src-cv@0.1.0");
3029 assert_eq!(target_schema, "other-cv@0.1.0");
3030 assert_eq!(rel_type, "ADDRESSES");
3031 assert_eq!(from_id, src_entity.id.to_string());
3032 assert_eq!(to_id, other_entity.id.to_string());
3033 }
3034 other => panic!("expected CrossMemEdgeNotDeclared, got {other:?}"),
3035 }
3036 }
3037
3038 #[test]
3039 fn intra_mem_with_cross_mem_only_rel_type_returns_invalid_rel_type() {
3040 let (_tmp, mut engine, src, _tgt) = two_mem_engine();
3045 let (actor, client) = cli_actor();
3046 let intra_target = engine
3048 .create_entity(
3049 CreateEntityArgs {
3050 anchors: Vec::new(),
3051 mem: "src".to_string(),
3052 title: "Doc Two".to_string(),
3053 entity_type: "doc".to_string(),
3054 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3055 metadata: IndexMap::new(),
3056 relations: Vec::new(),
3057 dry_run: false,
3058 },
3059 actor,
3060 Some(&client),
3061 None,
3062 )
3063 .unwrap();
3064 let src_fresh = engine.get_entity(&src.id).unwrap();
3067 let err = engine
3068 .relate_entity(
3069 RelateEntityArgs {
3070 source: src.id.clone(),
3071 expected_hash: Some(src_fresh.content_hash.clone()),
3072 rel_type: "ADDRESSES".to_string(),
3073 target: intra_target.id.clone(),
3074 remove: false,
3075 description: None,
3076 dry_run: false,
3077 },
3078 actor,
3079 Some(&client),
3080 None,
3081 )
3082 .unwrap_err();
3083 match err {
3084 EngineError::Validation(
3085 crate::runtime_validator::ValidationError::InvalidRelationshipType {
3086 input,
3087 ..
3088 },
3089 ) => {
3090 assert_eq!(input, "ADDRESSES");
3091 }
3092 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
3093 }
3094 }
3095
3096 #[test]
3097 fn vocabulary_admissible_edge_blocked_by_policy_returns_cross_mem_link_not_allowed() {
3098 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3102 engine.set_settings(WorkspaceSettings::default());
3104 let (actor, client) = cli_actor();
3105 let err = engine
3106 .relate_entity(
3107 RelateEntityArgs {
3108 source: src.id.clone(),
3109 expected_hash: Some(src.content_hash.clone()),
3110 rel_type: "ADDRESSES".to_string(),
3111 target: tgt.id.clone(),
3112 remove: false,
3113 description: None,
3114 dry_run: false,
3115 },
3116 actor,
3117 Some(&client),
3118 None,
3119 )
3120 .unwrap_err();
3121 assert!(
3122 matches!(err, EngineError::CrossMemLinkNotAllowed { .. }),
3123 "expected CrossMemLinkNotAllowed, got {err:?}"
3124 );
3125 }
3126
3127 #[test]
3134 fn cross_mem_remove_bypasses_policy_after_revoke() {
3135 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3136 let (actor, client) = cli_actor();
3137
3138 let added = engine
3140 .relate_entity(
3141 RelateEntityArgs {
3142 source: src.id.clone(),
3143 expected_hash: Some(src.content_hash.clone()),
3144 rel_type: "ADDRESSES".to_string(),
3145 target: tgt.id.clone(),
3146 remove: false,
3147 description: None,
3148 dry_run: false,
3149 },
3150 actor,
3151 Some(&client),
3152 None,
3153 )
3154 .expect("declared cross-mem edge admits under grant");
3155 assert_eq!(added.action, RelateAction::Added);
3156
3157 engine.set_settings(WorkspaceSettings::default());
3159
3160 let add_err = engine
3163 .relate_entity(
3164 RelateEntityArgs {
3165 source: src.id.clone(),
3166 expected_hash: Some(added.content_hash.clone()),
3167 rel_type: "ADDRESSES".to_string(),
3168 target: tgt.id.clone(),
3169 remove: false,
3170 description: None,
3171 dry_run: false,
3172 },
3173 actor,
3174 Some(&client),
3175 None,
3176 )
3177 .unwrap_err();
3178 assert!(
3179 matches!(add_err, EngineError::CrossMemLinkNotAllowed { .. }),
3180 "add path must still refuse under denial, got {add_err:?}"
3181 );
3182
3183 let removed = engine
3186 .relate_entity(
3187 RelateEntityArgs {
3188 source: src.id.clone(),
3189 expected_hash: Some(added.content_hash.clone()),
3190 rel_type: "ADDRESSES".to_string(),
3191 target: tgt.id.clone(),
3192 remove: true,
3193 description: None,
3194 dry_run: false,
3195 },
3196 actor,
3197 Some(&client),
3198 None,
3199 )
3200 .expect("remove must bypass the policy gate post-revoke");
3201 assert_eq!(removed.action, RelateAction::Removed);
3202
3203 let outgoing = engine.store().outgoing(&src.id);
3205 assert!(
3206 !outgoing
3207 .iter()
3208 .any(|e| e.target == tgt.id && e.rel_type == "ADDRESSES"),
3209 "ADDRESSES edge must be gone after remove"
3210 );
3211 }
3212
3213 #[test]
3217 fn cross_mem_remove_of_absent_edge_under_denial_is_no_op() {
3218 let (_tmp, mut engine, src, tgt) = two_mem_engine();
3219 engine.set_settings(WorkspaceSettings::default());
3221 let (actor, client) = cli_actor();
3222 let outcome = engine
3223 .relate_entity(
3224 RelateEntityArgs {
3225 source: src.id.clone(),
3226 expected_hash: Some(src.content_hash.clone()),
3227 rel_type: "ADDRESSES".to_string(),
3228 target: tgt.id.clone(),
3229 remove: true,
3230 description: None,
3231 dry_run: false,
3232 },
3233 actor,
3234 Some(&client),
3235 None,
3236 )
3237 .expect("absent-edge remove must not refuse on policy");
3238 assert!(
3239 matches!(outcome.action, RelateAction::NoOpAbsent),
3240 "expected NoOpAbsent, got {:?}",
3241 outcome.action
3242 );
3243 }
3244
3245 fn engine_with_tgt_capability(
3254 capability: MountCapability,
3255 ) -> (TempDir, Engine, CreateEntityOutcome) {
3256 let tmp = TempDir::new().unwrap();
3257
3258 let src_manifest = r#"name: src-al
3259version: 0.1.0
3260description: source schema with alias pointer
3261when_to_use: tests
3262types:
3263 - doc
3264relationships:
3265 mode: strict
3266 definitions:
3267 - name: ADDRESSES
3268 description: explicit cross-mem
3269 default_weight: 1.0
3270 - name: REFERENCES
3271 description: alias pointer
3272 default_weight: 1.0
3273 - name: _default
3274 description: fallback
3275 default_weight: 1.0
3276cross_mem_relationships:
3277 - to_schema: tgt-al
3278 definitions:
3279 - name: ADDRESSES
3280 description: explicit cross-mem
3281 default_weight: 1.0
3282 - name: REFERENCES
3283 description: alias-emitted cross-mem
3284 default_weight: 1.0
3285alias_target_rel_type: REFERENCES
3286community:
3287 resolution: 1.0
3288 seed: 42
3289"#;
3290 let tgt_manifest = r#"name: tgt-al
3291version: 0.1.0
3292description: target schema
3293when_to_use: tests
3294types:
3295 - req
3296relationships:
3297 mode: strict
3298 definitions:
3299 - name: _default
3300 description: fallback
3301 default_weight: 1.0
3302community:
3303 resolution: 1.0
3304 seed: 42
3305"#;
3306 let schemas_dir = tmp.path().join("schemas");
3307 std::fs::create_dir_all(&schemas_dir).unwrap();
3308 write_schema_files(
3309 &schemas_dir,
3310 "src-al",
3311 src_manifest,
3312 &[("doc", &make_type_yaml("doc"))],
3313 );
3314 write_schema_files(
3315 &schemas_dir,
3316 "tgt-al",
3317 tgt_manifest,
3318 &[("req", &make_type_yaml("req"))],
3319 );
3320
3321 let src_dir = tmp.path().join("mem-src");
3322 let tgt_dir = tmp.path().join("mem-tgt");
3323 std::fs::create_dir_all(&src_dir).unwrap();
3324 std::fs::create_dir_all(&tgt_dir).unwrap();
3325 std::fs::write(
3328 tgt_dir.join("req-one.md"),
3329 "---\ntype: req\n---\n# Req One\n\n## Body\n\nseed.\n",
3330 )
3331 .unwrap();
3332
3333 let src_writer = FilesystemMemWriter::new(src_dir.clone());
3334 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
3335 let src_pin = SchemaRef::new("src-al", semver::Version::new(0, 1, 0));
3336 let tgt_pin = SchemaRef::new("tgt-al", semver::Version::new(0, 1, 0));
3337
3338 let tgt_mount = Mount {
3339 mem: "tgt".to_string(),
3340 schema: Some(tgt_pin),
3341 storage: MountStorage::Folder {
3342 path: tgt_dir.clone(),
3343 },
3344 capability,
3345 lifecycle: MountLifecycle::Eager,
3346 cross_linkable: true,
3347 migration_target: None,
3348 };
3349 let mut engine = Engine::from_mounts_with_schemas_dir(
3350 vec![
3351 (
3352 folder_mount_with_pin("src", src_dir, src_pin),
3353 Box::new(src_writer) as Box<dyn MemBackend>,
3354 ),
3355 (tgt_mount, Box::new(tgt_writer) as Box<dyn MemBackend>),
3356 ],
3357 Some(&schemas_dir),
3358 )
3359 .expect("two-mem engine constructs");
3360
3361 let mut settings = WorkspaceSettings::default();
3362 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
3363 links.insert("src".to_string(), CrossLinkValue::Wildcard);
3364 settings.cross_mem_links = links;
3365 engine.set_settings(settings);
3366
3367 let (actor, client) = cli_actor();
3368 let src_entity = engine
3369 .create_entity(
3370 CreateEntityArgs {
3371 anchors: Vec::new(),
3372 mem: "src".to_string(),
3373 title: "Doc One".to_string(),
3374 entity_type: "doc".to_string(),
3375 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
3376 metadata: IndexMap::new(),
3377 relations: Vec::new(),
3378 dry_run: false,
3379 },
3380 actor,
3381 Some(&client),
3382 None,
3383 )
3384 .expect("source entity creates");
3385
3386 (tmp, engine, src_entity)
3387 }
3388
3389 fn assert_cross_mem_target_not_found(err: EngineError, expected_target: &str) {
3390 match err {
3391 EngineError::CrossMemTargetNotFound {
3392 target_id,
3393 target_mem,
3394 } => {
3395 assert_eq!(target_id, expected_target);
3396 assert_eq!(target_mem, "tgt");
3397 }
3398 other => panic!("expected CrossMemTargetNotFound, got {other:?}"),
3399 }
3400 }
3401
3402 #[test]
3407 fn relate_dry_run_to_missing_target_in_readonly_mem_refuses_identically() {
3408 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3409 let (actor, client) = cli_actor();
3410 let args = |dry_run: bool| RelateEntityArgs {
3411 source: src.id.clone(),
3412 expected_hash: Some(src.content_hash.clone()),
3413 rel_type: "ADDRESSES".to_string(),
3414 target: crate::EntityId::new("tgt", "missing"),
3415 remove: false,
3416 description: None,
3417 dry_run,
3418 };
3419 let rehearsed = engine
3420 .relate_entity(args(true), actor, Some(&client), None)
3421 .unwrap_err();
3422 let real = engine
3423 .relate_entity(args(false), actor, Some(&client), None)
3424 .unwrap_err();
3425 assert_eq!(format!("{rehearsed:?}"), format!("{real:?}"));
3426 assert_cross_mem_target_not_found(rehearsed, "tgt--missing");
3427 }
3428
3429 #[test]
3430 fn relate_to_missing_target_in_readonly_mem_refuses() {
3431 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3432 let (actor, client) = cli_actor();
3433 let err = engine
3434 .relate_entity(
3435 RelateEntityArgs {
3436 source: src.id.clone(),
3437 expected_hash: Some(src.content_hash.clone()),
3438 rel_type: "ADDRESSES".to_string(),
3439 target: crate::EntityId::new("tgt", "missing"),
3440 remove: false,
3441 description: None,
3442 dry_run: false,
3443 },
3444 actor,
3445 Some(&client),
3446 None,
3447 )
3448 .unwrap_err();
3449 assert_cross_mem_target_not_found(err, "tgt--missing");
3450 }
3451
3452 #[test]
3457 fn create_inline_relation_to_missing_target_in_readonly_mem_refuses() {
3458 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3459 let (actor, client) = cli_actor();
3460 let err = engine
3461 .create_entity(
3462 CreateEntityArgs {
3463 anchors: Vec::new(),
3464 mem: "src".to_string(),
3465 title: "Doc Two".to_string(),
3466 entity_type: "doc".to_string(),
3467 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3468 metadata: IndexMap::new(),
3469 relations: vec![crate::ops::RelateArg {
3470 to: crate::EntityId::new("tgt", "missing"),
3471 rel_type: "ADDRESSES".to_string(),
3472 description: None,
3473 }],
3474 dry_run: false,
3475 },
3476 actor,
3477 Some(&client),
3478 None,
3479 )
3480 .unwrap_err();
3481 assert_cross_mem_target_not_found(err, "tgt--missing");
3482 }
3483
3484 #[test]
3488 fn create_body_link_to_missing_target_in_readonly_mem_refuses() {
3489 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3490 let (actor, client) = cli_actor();
3491 let err = engine
3492 .create_entity(
3493 CreateEntityArgs {
3494 anchors: Vec::new(),
3495 mem: "src".to_string(),
3496 title: "Doc Three".to_string(),
3497 entity_type: "doc".to_string(),
3498 sections: IndexMap::from_iter([(
3499 "body".to_string(),
3500 "see [[tgt--missing]].".to_string(),
3501 )]),
3502 metadata: IndexMap::new(),
3503 relations: Vec::new(),
3504 dry_run: false,
3505 },
3506 actor,
3507 Some(&client),
3508 None,
3509 )
3510 .unwrap_err();
3511 assert_cross_mem_target_not_found(err, "tgt--missing");
3512 }
3513
3514 #[test]
3515 fn update_body_link_to_missing_target_in_readonly_mem_refuses() {
3516 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3517 let (actor, client) = cli_actor();
3518 let err = engine
3519 .update_entity(
3520 crate::engine::UpdateEntityArgs {
3521 anchors: Vec::new(),
3522 id: src.id.clone(),
3523 expected_hash: Some(src.content_hash.clone()),
3524 sections: IndexMap::from_iter([(
3525 "body".to_string(),
3526 "now see [[tgt--missing]].".to_string(),
3527 )]),
3528 append_sections: IndexMap::new(),
3529 patch_sections: IndexMap::new(),
3530 metadata: IndexMap::new(),
3531 metadata_unset: Vec::new(),
3532 declare_relations: Vec::new(),
3533 dry_run: false,
3534 relations_unset: Vec::new(),
3535 anchors_unset: Vec::new(),
3536 },
3537 actor,
3538 Some(&client),
3539 None,
3540 )
3541 .unwrap_err();
3542 assert_cross_mem_target_not_found(err, "tgt--missing");
3543 }
3544
3545 #[test]
3549 fn body_link_to_existing_target_in_readonly_mem_admits_and_emits_edge() {
3550 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3551 let (actor, client) = cli_actor();
3552 let created = engine
3553 .create_entity(
3554 CreateEntityArgs {
3555 anchors: Vec::new(),
3556 mem: "src".to_string(),
3557 title: "Doc Four".to_string(),
3558 entity_type: "doc".to_string(),
3559 sections: IndexMap::from_iter([(
3560 "body".to_string(),
3561 "see [[tgt--req-one]].".to_string(),
3562 )]),
3563 metadata: IndexMap::new(),
3564 relations: Vec::new(),
3565 dry_run: false,
3566 },
3567 actor,
3568 Some(&client),
3569 None,
3570 )
3571 .expect("body link to existing read-only target admits");
3572 let outgoing = engine.store().outgoing(&created.id);
3573 assert!(
3574 outgoing.iter().any(|e| e.rel_type == "REFERENCES"
3575 && e.target == crate::EntityId::new("tgt", "req-one")),
3576 "alias REFERENCES edge to the read-only target must materialise; got {outgoing:?}"
3577 );
3578 }
3579
3580 #[test]
3584 fn body_link_to_missing_target_in_write_mem_still_stubs() {
3585 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::Write);
3586 let (actor, client) = cli_actor();
3587 let created = engine
3588 .create_entity(
3589 CreateEntityArgs {
3590 anchors: Vec::new(),
3591 mem: "src".to_string(),
3592 title: "Doc Five".to_string(),
3593 entity_type: "doc".to_string(),
3594 sections: IndexMap::from_iter([(
3595 "body".to_string(),
3596 "see [[tgt--missing]].".to_string(),
3597 )]),
3598 metadata: IndexMap::new(),
3599 relations: Vec::new(),
3600 dry_run: false,
3601 },
3602 actor,
3603 Some(&client),
3604 None,
3605 )
3606 .expect("forward reference into a Write sibling mem keeps stubbing");
3607 assert!(
3608 engine
3609 .store()
3610 .contains(&crate::EntityId::new("tgt", "missing")),
3611 "auto-stub must land for the Write-mem forward reference"
3612 );
3613 let outgoing = engine.store().outgoing(&created.id);
3614 assert!(
3615 outgoing.iter().any(|e| e.rel_type == "REFERENCES"),
3616 "alias edge must still emit for the stubbed target"
3617 );
3618 }
3619 }
3620
3621 #[test]
3630 fn batch_relate_applies_adds_and_removes_in_order_one_commit() {
3631 let tmp = TempDir::new().unwrap();
3632 let mem_dir = tmp.path().to_path_buf();
3633 let writer = FilesystemMemWriter::new(mem_dir.clone());
3634 let mut engine = Engine::from_mounts(vec![(
3635 folder_mount("specs", mem_dir),
3636 Box::new(writer) as Box<dyn MemBackend>,
3637 )])
3638 .unwrap();
3639 let (actor, client) = cli_actor();
3640
3641 for title in ["A", "B", "C"] {
3642 engine
3643 .create_entity(
3644 empty_create_args("specs", title),
3645 actor,
3646 Some(&client),
3647 None,
3648 )
3649 .unwrap();
3650 }
3651 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
3652 let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
3653 source: id(from),
3654 expected_hash: None,
3655 rel_type: "USES".to_string(),
3656 target: id(to),
3657 remove,
3658 description: None,
3659 dry_run: false,
3660 };
3661
3662 let result = engine
3663 .batch_relate(
3664 vec![
3665 (edge("a", "b", false), Some("add a-b".to_string())),
3666 (edge("a", "c", false), Some("add a-c".to_string())),
3667 (edge("a", "c", true), Some("undo a-c".to_string())),
3668 ],
3669 actor,
3670 Some(&client),
3671 false,
3672 )
3673 .unwrap();
3674 assert!(result.applied, "{result:?}");
3675 assert_eq!(result.succeeded, 3);
3676 assert_eq!(result.failed, 0);
3677 assert!(!result.commit_sha.is_empty(), "one real commit");
3678 let actions: Vec<&str> = result.results.iter().map(|r| r.action.as_str()).collect();
3679 assert_eq!(
3680 actions,
3681 vec!["added", "added", "removed"],
3682 "in-order application: the remove sees the same batch's add"
3683 );
3684
3685 let a = engine.get_entity(&id("a")).unwrap();
3687 assert_eq!(a.relationships.len(), 1, "{:?}", a.relationships);
3688 assert_eq!(a.relationships[0].rel_type, "USES");
3689 assert_eq!(a.relationships[0].target, id("b"));
3690 }
3691
3692 #[test]
3700 fn relate_dry_run_reports_would_be_stub_and_writes_nothing() {
3701 let tmp = TempDir::new().unwrap();
3702 let (mut engine, source) = engine_with_seed(&tmp, "Src");
3703 engine.set_mutation_clock(std::sync::Arc::new(|| {
3709 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_000_000)
3710 }));
3711 let (actor, client) = cli_actor();
3712 let absent = crate::EntityId::new("specs", "ghost-target");
3713 let args = |dry_run: bool| RelateEntityArgs {
3714 source: source.id.clone(),
3715 expected_hash: Some(source.content_hash.clone()),
3716 rel_type: "USES".to_string(),
3717 target: absent.clone(),
3718 remove: false,
3719 description: None,
3720 dry_run,
3721 };
3722
3723 let rehearsed = engine
3724 .relate_entity(args(true), actor, Some(&client), None)
3725 .unwrap();
3726 assert_eq!(rehearsed.action, RelateAction::Added);
3727 assert!(
3728 rehearsed.commit_sha.is_empty(),
3729 "marker form: empty commit_sha"
3730 );
3731 assert!(
3732 rehearsed.warnings.iter().any(
3733 |w| matches!(w, crate::ops::WarningHint::AutoStubCreated { stub_id, pending: true } if *stub_id == absent)
3734 ),
3735 "would-be stub must be reported as pending: {:?}",
3736 rehearsed.warnings
3737 );
3738 let rehearsed_stub = rehearsed
3741 .warnings
3742 .iter()
3743 .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
3744 .unwrap();
3745 assert_eq!(rehearsed_stub.code(), "AUTO_STUB_CREATED");
3746 let msg = rehearsed_stub.message();
3747 assert!(
3748 msg.contains("would be auto-created") && !msg.contains("stub auto-created."),
3749 "dry-run wording must be conditional: {msg}"
3750 );
3751 assert!(
3752 !engine.store().contains(&absent),
3753 "would-be stub reported, never created"
3754 );
3755 let stored = engine.store().get(&source.id).unwrap();
3758 assert_eq!(stored.content_hash, source.content_hash);
3759 assert_ne!(rehearsed.content_hash, source.content_hash);
3760 assert!(stored.relationships.is_empty(), "no edge landed");
3761
3762 let real = engine
3765 .relate_entity(args(false), actor, Some(&client), None)
3766 .unwrap();
3767 assert!(!real.commit_sha.is_empty(), "the real relate commits");
3768 assert_eq!(
3769 real.content_hash, rehearsed.content_hash,
3770 "prospective hash must equal the real post-write hash"
3771 );
3772 assert!(engine.store().get(&absent).expect("real call stubs").stub);
3773 let real_msg = real
3775 .warnings
3776 .iter()
3777 .find(|w| {
3778 matches!(
3779 w,
3780 crate::ops::WarningHint::AutoStubCreated { pending: false, .. }
3781 )
3782 })
3783 .expect("real relate carries the non-pending stub warning")
3784 .message();
3785 assert!(
3786 real_msg.contains("did not exist — stub auto-created."),
3787 "real wording unchanged: {real_msg}"
3788 );
3789 }
3790
3791 #[test]
3795 fn relate_dry_run_refuses_identically_to_real() {
3796 let tmp = TempDir::new().unwrap();
3797 let (mut engine, source) = engine_with_seed(&tmp, "Src");
3798 let (actor, client) = cli_actor();
3799 let args = |dry_run: bool| RelateEntityArgs {
3801 source: source.id.clone(),
3802 expected_hash: None,
3803 rel_type: "USES".to_string(),
3804 target: crate::EntityId("bad target with spaces".to_string()),
3805 remove: false,
3806 description: None,
3807 dry_run,
3808 };
3809 let rehearsed = engine
3810 .relate_entity(args(true), actor, Some(&client), None)
3811 .unwrap_err();
3812 let real = engine
3813 .relate_entity(args(false), actor, Some(&client), None)
3814 .unwrap_err();
3815 assert_eq!(
3816 format!("{rehearsed:?}"),
3817 format!("{real:?}"),
3818 "identical typed refusal"
3819 );
3820 assert_eq!(rehearsed.code(), real.code());
3821 }
3822
3823 #[test]
3830 fn batch_relate_dry_run_reports_receipt_and_commits_nothing() {
3831 let tmp = TempDir::new().unwrap();
3832 let mem_dir = tmp.path().to_path_buf();
3833 let writer = FilesystemMemWriter::new(mem_dir.clone());
3834 let mut engine = Engine::from_mounts(vec![(
3835 folder_mount("specs", mem_dir),
3836 Box::new(writer) as Box<dyn MemBackend>,
3837 )])
3838 .unwrap();
3839 let (actor, client) = cli_actor();
3840 for title in ["A", "B"] {
3841 engine
3842 .create_entity(
3843 empty_create_args("specs", title),
3844 actor,
3845 Some(&client),
3846 None,
3847 )
3848 .unwrap();
3849 }
3850 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
3851 let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
3852 source: id(from),
3853 expected_hash: None,
3854 rel_type: "USES".to_string(),
3855 target: id(to),
3856 remove,
3857 description: None,
3858 dry_run: false,
3859 };
3860 let batch = || {
3861 vec![
3862 (edge("a", "b", false), None),
3863 (edge("a", "ghost", false), None), (edge("a", "b", true), None), ]
3866 };
3867 let head_before = engine
3868 .mem_head_sha("specs")
3869 .ok()
3870 .flatten()
3871 .unwrap_or_default();
3872
3873 let rehearsed = engine
3874 .batch_relate(batch(), actor, Some(&client), true)
3875 .unwrap();
3876 assert!(rehearsed.applied, "{rehearsed:?}");
3877 assert!(
3878 rehearsed.commit_sha.is_empty(),
3879 "marker form: empty commit_sha"
3880 );
3881 let actions: Vec<&str> = rehearsed
3882 .results
3883 .iter()
3884 .map(|r| r.action.as_str())
3885 .collect();
3886 assert_eq!(
3887 actions,
3888 vec!["added", "added", "removed"],
3889 "in-order rehearsal semantics"
3890 );
3891 assert!(!engine.store().contains(&id("ghost")), "no stub created");
3893 assert!(
3894 engine
3895 .get_entity(&id("a"))
3896 .unwrap()
3897 .relationships
3898 .is_empty(),
3899 "no edge landed"
3900 );
3901 let head_after = engine
3902 .mem_head_sha("specs")
3903 .ok()
3904 .flatten()
3905 .unwrap_or_default();
3906 assert_eq!(head_before, head_after, "no commit landed");
3907
3908 let real = engine
3910 .batch_relate(batch(), actor, Some(&client), false)
3911 .unwrap();
3912 assert!(real.applied, "{real:?}");
3913 assert!(!real.commit_sha.is_empty());
3914 }
3915
3916 #[test]
3920 fn batch_relate_dry_run_refuses_identically_to_real() {
3921 let tmp = TempDir::new().unwrap();
3922 let mem_dir = tmp.path().to_path_buf();
3923 let writer = FilesystemMemWriter::new(mem_dir.clone());
3924 let mut engine = Engine::from_mounts(vec![(
3925 folder_mount("specs", mem_dir),
3926 Box::new(writer) as Box<dyn MemBackend>,
3927 )])
3928 .unwrap();
3929 let (actor, client) = cli_actor();
3930 for title in ["A", "B"] {
3931 engine
3932 .create_entity(
3933 empty_create_args("specs", title),
3934 actor,
3935 Some(&client),
3936 None,
3937 )
3938 .unwrap();
3939 }
3940 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
3941 let batch = || {
3942 vec![
3943 (
3944 RelateEntityArgs {
3945 source: id("a"),
3946 expected_hash: None,
3947 rel_type: "USES".to_string(),
3948 target: id("b"),
3949 remove: false,
3950 description: None,
3951 dry_run: false,
3952 },
3953 None,
3954 ),
3955 (
3956 RelateEntityArgs {
3957 source: id("a"),
3958 expected_hash: None,
3959 rel_type: "USES".to_string(),
3960 target: crate::EntityId("bad target".to_string()),
3961 remove: false,
3962 description: None,
3963 dry_run: false,
3964 },
3965 None,
3966 ),
3967 ]
3968 };
3969 let rehearsed = engine
3970 .batch_relate(batch(), actor, Some(&client), true)
3971 .unwrap();
3972 let real = engine
3973 .batch_relate(batch(), actor, Some(&client), false)
3974 .unwrap();
3975 assert!(!rehearsed.applied && !real.applied);
3976 let envelope = |r: &crate::ops::BatchResult| {
3977 r.results
3978 .iter()
3979 .map(|e| {
3980 (
3981 e.id.to_string(),
3982 e.action.clone(),
3983 e.error.as_ref().map(|err| {
3984 (err.code.clone(), err.message.clone(), err.details.clone())
3985 }),
3986 )
3987 })
3988 .collect::<Vec<_>>()
3989 };
3990 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
3991 assert!(
3992 engine
3993 .get_entity(&id("a"))
3994 .unwrap()
3995 .relationships
3996 .is_empty(),
3997 "neither run landed the valid entry"
3998 );
3999 }
4000
4001 #[test]
4006 fn batch_relate_refuses_whole_batch_reporting_every_failure() {
4007 let tmp = TempDir::new().unwrap();
4008 let mem_dir = tmp.path().to_path_buf();
4009 let writer = FilesystemMemWriter::new(mem_dir.clone());
4010 let mut engine = Engine::from_mounts(vec![(
4011 folder_mount("specs", mem_dir),
4012 Box::new(writer) as Box<dyn MemBackend>,
4013 )])
4014 .unwrap();
4015 let (actor, client) = cli_actor();
4016
4017 let a = engine
4018 .create_entity(empty_create_args("specs", "A"), actor, Some(&client), None)
4019 .unwrap();
4020 engine
4021 .create_entity(empty_create_args("specs", "B"), actor, Some(&client), None)
4022 .unwrap();
4023 let head_before = engine
4024 .mem_head_sha("specs")
4025 .ok()
4026 .flatten()
4027 .unwrap_or_default();
4028 let count_before = engine.store().all_entities().count();
4029
4030 let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4031 let result = engine
4032 .batch_relate(
4033 vec![
4034 (
4036 RelateEntityArgs {
4037 source: id("a"),
4038 expected_hash: None,
4039 rel_type: "USES".to_string(),
4040 target: id("b"),
4041 remove: false,
4042 description: None,
4043 dry_run: false,
4044 },
4045 None,
4046 ),
4047 (
4049 RelateEntityArgs {
4050 source: id("ghost"),
4051 expected_hash: None,
4052 rel_type: "USES".to_string(),
4053 target: id("b"),
4054 remove: false,
4055 description: None,
4056 dry_run: false,
4057 },
4058 None,
4059 ),
4060 (
4062 RelateEntityArgs {
4063 source: id("b"),
4064 expected_hash: Some("definitely-wrong".to_string()),
4065 rel_type: "USES".to_string(),
4066 target: id("a"),
4067 remove: false,
4068 description: None,
4069 dry_run: false,
4070 },
4071 None,
4072 ),
4073 ],
4074 actor,
4075 Some(&client),
4076 false,
4077 )
4078 .unwrap();
4079 assert!(!result.applied);
4080 assert_eq!(result.failed, 2, "{result:?}");
4081 assert!(result.commit_sha.is_empty());
4082 let codes: Vec<(usize, &str)> = result
4083 .results
4084 .iter()
4085 .enumerate()
4086 .filter(|(_, r)| r.action == "error")
4087 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
4088 .collect();
4089 assert_eq!(
4090 codes,
4091 vec![(1, "ENTITY_NOT_FOUND"), (2, "HASH_MISMATCH")],
4092 "every failing entry named with index + typed code: {result:?}"
4093 );
4094 assert_eq!(result.results[0].action, "not_applied");
4095
4096 let a_after = engine.get_entity(&a.id).unwrap();
4099 assert!(
4100 a_after.relationships.is_empty(),
4101 "staged edge must roll back: {:?}",
4102 a_after.relationships
4103 );
4104 assert_eq!(a_after.content_hash, a.content_hash);
4105 let head_after = engine
4106 .mem_head_sha("specs")
4107 .ok()
4108 .flatten()
4109 .unwrap_or_default();
4110 assert_eq!(head_before, head_after, "mem head unmoved");
4111 assert_eq!(engine.store().all_entities().count(), count_before);
4112 }
4113}
4114
4115#[cfg(test)]
4116mod derivation_tests {
4117 use indexmap::IndexMap;
4118 use tempfile::TempDir;
4119
4120 use crate::backend::MemBackend;
4121 use crate::engine::{
4122 CreateEntityArgs, Engine, RelateAction, RelateEntityArgs, UpdateEntityArgs,
4123 };
4124 use crate::ops::WarningHint;
4125 use crate::storage::FilesystemMemWriter;
4126 use crate::vcs::Actor;
4127 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4128
4129 fn deriv_schema() -> std::sync::Arc<memstead_schema::Schema> {
4133 let manifest = r#"name: deriv
4134version: 0.1.0
4135description: derivation fixture
4136when_to_use: tests
4137types:
4138 - note
4139relationships:
4140 mode: strict
4141 definitions:
4142 - name: PART_OF
4143 description: hier
4144 default_weight: 3.0
4145 - name: DERIVED_FROM
4146 description: source derives from target
4147 default_weight: 2.0
4148 derivation: true
4149 - name: SUPPORTS
4150 description: plain edge
4151 default_weight: 1.0
4152 - name: _default
4153 description: fallback
4154 default_weight: 1.0
4155community:
4156 resolution: 1.0
4157 seed: 42
4158"#;
4159 let type_yaml = r#"name: note
4160description: t
4161when_to_use: tests
4162sections:
4163 - key: body
4164 heading: Body
4165 required: true
4166 search_weight: 10.0
4167 catch_all: true
4168 write_rules: []
4169metadata_fields: []
4170title_weight: 100.0
4171text_fields:
4172 - body
4173hierarchy_relationship: PART_OF
4174no_self_loop_relationships: []
4175updatable_fields:
4176 - title
4177 - body
4178health_required_fields:
4179 - body
4180staleness_threshold_days: 90
4181write_rules: []
4182"#;
4183 std::sync::Arc::new(
4184 memstead_schema::loader::load_schema_from_memory(
4185 manifest,
4186 &[("note".to_string(), type_yaml.to_string())],
4187 )
4188 .expect("fixture schema loads"),
4189 )
4190 }
4191
4192 fn engine_at(tmp: &TempDir) -> Engine {
4193 let mem_dir = tmp.path().to_path_buf();
4194 let writer = FilesystemMemWriter::new(mem_dir.clone());
4195 let mount = Mount {
4196 mem: "m".to_string(),
4197 schema: Some("deriv@0.1.0".parse().unwrap()),
4198 storage: MountStorage::Folder { path: mem_dir },
4199 capability: MountCapability::Write,
4200 lifecycle: MountLifecycle::Eager,
4201 cross_linkable: true,
4202 migration_target: None,
4203 };
4204 Engine::from_mounts_with_schemas_dir_and_extra(
4205 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
4206 None,
4207 vec![deriv_schema()],
4208 )
4209 .unwrap()
4210 }
4211
4212 fn note(title: &str, body: &str) -> CreateEntityArgs {
4213 CreateEntityArgs {
4214 anchors: Vec::new(),
4215 mem: "m".to_string(),
4216 title: title.to_string(),
4217 entity_type: "note".to_string(),
4218 sections: IndexMap::from_iter([("body".to_string(), body.to_string())]),
4219 metadata: IndexMap::new(),
4220 relations: Vec::new(),
4221 dry_run: false,
4222 }
4223 }
4224
4225 fn relate_args(from: &crate::EntityId, rel: &str, to: &crate::EntityId) -> RelateEntityArgs {
4226 RelateEntityArgs {
4227 source: from.clone(),
4228 expected_hash: None,
4229 rel_type: rel.to_string(),
4230 target: to.clone(),
4231 remove: false,
4232 description: None,
4233 dry_run: false,
4234 }
4235 }
4236
4237 #[test]
4242 fn derivation_write_edit_report_reassert_clear() {
4243 let tmp = TempDir::new().unwrap();
4244 let mut engine = engine_at(&tmp);
4245 let target = engine
4246 .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4247 .unwrap();
4248 let source = engine
4249 .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4250 .unwrap();
4251
4252 let added = engine
4254 .relate_entity(
4255 relate_args(&source.id, "DERIVED_FROM", &target.id),
4256 Actor::Cli,
4257 None,
4258 None,
4259 )
4260 .unwrap();
4261 assert_eq!(added.action, RelateAction::Added);
4262 assert!(
4263 engine.derivation_report("m").unwrap().is_empty(),
4264 "freshly baselined edge must not report"
4265 );
4266
4267 let t_now = engine.get_entity(&target.id).unwrap().content_hash.clone();
4269 engine
4270 .update_entity(
4271 UpdateEntityArgs {
4272 anchors: Vec::new(),
4273 id: target.id.clone(),
4274 expected_hash: Some(t_now),
4275 sections: IndexMap::from_iter([("body".to_string(), "v2".to_string())]),
4276 append_sections: IndexMap::new(),
4277 patch_sections: IndexMap::new(),
4278 metadata: IndexMap::new(),
4279 metadata_unset: Vec::new(),
4280 declare_relations: Vec::new(),
4281 dry_run: false,
4282 relations_unset: Vec::new(),
4283 anchors_unset: Vec::new(),
4284 },
4285 Actor::Cli,
4286 None,
4287 None,
4288 )
4289 .unwrap();
4290 let report = engine.derivation_report("m").unwrap();
4291 assert_eq!(report.len(), 1, "{report:?}");
4292 assert_eq!(report[0].source, source.id);
4293 assert_eq!(report[0].rel_type, "DERIVED_FROM");
4294 assert_eq!(report[0].target, target.id);
4295 assert_eq!(report[0].state, "stale");
4296 assert!(report[0].baseline.is_some());
4297
4298 let s_now = engine.get_entity(&source.id).unwrap().content_hash.clone();
4303 engine
4304 .update_entity(
4305 UpdateEntityArgs {
4306 anchors: Vec::new(),
4307 id: source.id.clone(),
4308 expected_hash: Some(s_now),
4309 sections: IndexMap::from_iter([(
4310 "body".to_string(),
4311 "conclusion v2".to_string(),
4312 )]),
4313 append_sections: IndexMap::new(),
4314 patch_sections: IndexMap::new(),
4315 metadata: IndexMap::new(),
4316 metadata_unset: Vec::new(),
4317 declare_relations: Vec::new(),
4318 dry_run: false,
4319 relations_unset: Vec::new(),
4320 anchors_unset: Vec::new(),
4321 },
4322 Actor::Cli,
4323 None,
4324 None,
4325 )
4326 .unwrap();
4327 let report = engine.derivation_report("m").unwrap();
4328 assert_eq!(report.len(), 1, "source edit adds nothing: {report:?}");
4329 assert_eq!(report[0].state, "stale");
4330
4331 let md_before = std::fs::read(tmp.path().join("source.md")).unwrap();
4335 let hash_before = engine.get_entity(&source.id).unwrap().content_hash.clone();
4336 let refreshed = engine
4337 .relate_entity(
4338 relate_args(&source.id, "DERIVED_FROM", &target.id),
4339 Actor::Cli,
4340 None,
4341 None,
4342 )
4343 .unwrap();
4344 assert_eq!(refreshed.action, RelateAction::NoOpAlreadyPresent);
4345 assert_eq!(refreshed.content_hash, hash_before, "_hash unchanged");
4346 assert!(
4347 !refreshed.commit_sha.is_empty(),
4348 "the sidecar refresh persists via a real commit"
4349 );
4350 assert!(
4351 refreshed
4352 .warnings
4353 .iter()
4354 .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4355 "the refresh is STATED, never a bare no-op: {:?}",
4356 refreshed.warnings
4357 );
4358 let md_after = std::fs::read(tmp.path().join("source.md")).unwrap();
4359 assert_eq!(md_before, md_after, "baselines never touch the markdown");
4360 assert!(
4361 engine.derivation_report("m").unwrap().is_empty(),
4362 "re-assert clears the staleness"
4363 );
4364
4365 engine
4368 .relate_entity(
4369 relate_args(&source.id, "SUPPORTS", &target.id),
4370 Actor::Cli,
4371 None,
4372 None,
4373 )
4374 .unwrap();
4375 let noop = engine
4376 .relate_entity(
4377 relate_args(&source.id, "SUPPORTS", &target.id),
4378 Actor::Cli,
4379 None,
4380 None,
4381 )
4382 .unwrap();
4383 assert_eq!(noop.action, RelateAction::NoOpAlreadyPresent);
4384 assert!(noop.commit_sha.is_empty(), "undeclared no-op stays bare");
4385 assert!(
4386 !noop
4387 .warnings
4388 .iter()
4389 .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4390 );
4391 assert!(engine.derivation_report("m").unwrap().is_empty());
4393 }
4394
4395 #[test]
4400 fn missing_baseline_reports_unbaselined_never_fabricated() {
4401 let tmp = TempDir::new().unwrap();
4402 let mut engine = engine_at(&tmp);
4403 let target = engine
4404 .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4405 .unwrap();
4406 let source = engine
4407 .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4408 .unwrap();
4409 engine
4410 .relate_entity(
4411 relate_args(&source.id, "DERIVED_FROM", &target.id),
4412 Actor::Cli,
4413 None,
4414 None,
4415 )
4416 .unwrap();
4417 let sidecar = tmp.path().join(".memstead").join("derivations.json");
4419 assert!(sidecar.exists(), "baseline persisted to the sidecar");
4420 assert!(
4421 engine
4422 .get_entity(&crate::EntityId::new("m", "derivations"))
4423 .is_none()
4424 );
4425
4426 std::fs::remove_file(&sidecar).unwrap();
4429 let report = engine.derivation_report("m").unwrap();
4430 assert_eq!(report.len(), 1, "{report:?}");
4431 assert_eq!(report[0].state, "unbaselined");
4432 assert!(report[0].baseline.is_none());
4433 }
4434}