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