1use std::cell::OnceCell;
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use memstead_schema::Schema;
19
20use crate::engine_fallback_type;
21use crate::entity::{Entity, EntityId};
22use crate::graph::{LouvainOutput, community::detect_communities};
23use crate::mem::MemRouterSnapshot;
24use crate::ops::{ContextResult, Direction, NeighborInfo, SearchResult, SearchScope, WarningHint};
25use crate::provenance::Provenance;
26#[cfg(not(target_arch = "wasm32"))]
27use crate::search_index::{MemIndex, build_all};
28use crate::store::Store;
29use crate::workspace::{MountCapability, MountStorage, WorkspaceSettings};
30
31use super::{BackendFactory, Engine, EngineError, MountedBackend};
32
33impl Engine {
34 pub fn store(&self) -> &Store {
38 &self.store
39 }
40
41 pub fn schemas(&self) -> &HashMap<String, Arc<Schema>> {
45 &self.schemas
46 }
47
48 pub fn workspace_schemas(&self) -> &[Arc<Schema>] {
57 &self.workspace_schemas
58 }
59
60 pub fn builtin_schemas(&self) -> &[Arc<Schema>] {
68 &self.builtin_schemas
69 }
70
71 pub fn schema_origin(&self, schema: &Arc<Schema>) -> crate::render::OriginClass {
97 use crate::render::OriginClass;
98 let (name, version) = schema.id();
99 let is_builtin = self.builtin_schemas.iter().any(|s| {
101 let id = s.id();
102 id.0 == name && id.1 == version
103 });
104 if is_builtin {
105 return OriginClass::FirstParty;
106 }
107 let canon = format!("{name}@{version}");
110 let pinned_by_writable = self.mounts().iter().any(|m| {
111 m.schema.as_ref().map(|s| s.to_string()).as_deref() == Some(canon.as_str())
112 && self.mem_router().is_writable(&m.mem)
113 });
114 if pinned_by_writable {
115 OriginClass::FirstParty
116 } else {
117 OriginClass::ThirdParty
118 }
119 }
120
121 pub fn mem_origin_class(&self, mem: &str) -> crate::render::OriginClass {
139 if let Some(declared) = self.declared_origins.get(mem) {
140 return *declared;
141 }
142 if self.mem_router().is_writable(mem) {
143 crate::render::OriginClass::FirstParty
144 } else {
145 crate::render::OriginClass::ThirdParty
146 }
147 }
148
149 pub fn declare_mem_origin(
160 &mut self,
161 mem: impl Into<String>,
162 origin: crate::render::OriginClass,
163 ) {
164 self.declared_origins.insert(mem.into(), origin);
165 }
166
167 pub fn load_errors(&self) -> &[(PathBuf, String)] {
171 &self.load_errors
172 }
173
174 pub fn settings(&self) -> &WorkspaceSettings {
181 &self.settings
182 }
183
184 pub fn pipeline_configs(&self) -> &crate::pipeline_store::BindingConfigs {
191 &self.pipeline_configs
192 }
193
194 pub fn pipeline_configs_json(&self) -> String {
207 let empty = || "{\"bindings\":[]}".to_string();
208 let Some(root) = self.workspace_root() else {
209 return empty();
210 };
211 match crate::pipeline_store::load_pipeline_configs(root) {
212 Ok(configs) => serde_json::to_string(&configs).unwrap_or_else(|_| empty()),
213 Err(_) => empty(),
214 }
215 }
216
217 pub fn set_pipeline_configs(&mut self, configs: crate::pipeline_store::BindingConfigs) {
222 self.pipeline_configs = configs;
223 }
224
225 pub fn note_missing_warning(&self, tool: &str, note: Option<&str>) -> Option<WarningHint> {
242 if !self.settings.mutations.require_notes.unwrap_or(false) {
243 return None;
244 }
245 let has_note = note.map(|n| !n.trim().is_empty()).unwrap_or(false);
246 if has_note {
247 return None;
248 }
249 Some(WarningHint::NoteMissing {
250 tool: tool.to_string(),
251 })
252 }
253
254 pub fn backend_factory(&self) -> BackendFactory {
260 self.backend_factory
261 }
262
263 pub fn git_branch_ops(&self) -> Option<super::GitBranchOps> {
270 self.git_branch_ops
271 }
272
273 pub fn get_entity(&self, id: &EntityId) -> Option<&Entity> {
278 self.store.get(id)
279 }
280
281 pub fn entity_anchors(&self, id: &EntityId) -> Vec<crate::anchor::Anchor> {
290 let Some(mount) = self.mounts.iter().find(|m| m.mount.mem == id.mem()) else {
291 return Vec::new();
292 };
293 let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
294 return Vec::new();
295 };
296 match crate::anchor::AnchorSidecar::from_bytes(&bytes) {
297 Ok(sc) => sc.get(id.as_ref()).to_vec(),
298 Err(_) => Vec::new(),
299 }
300 }
301
302 pub fn entity_anchors_resolved(&self, id: &EntityId) -> Vec<ResolvedAnchor> {
328 let anchors = self.entity_anchors(id);
329 anchors
330 .into_iter()
331 .map(|anchor| {
332 let observed = self.observe_anchor(&anchor);
333 let (state, observed_hash) = match observed {
334 Some((state, hash)) => (Some(state), hash),
335 None => (None, None),
336 };
337 ResolvedAnchor {
338 anchor,
339 state,
340 observed_hash,
341 }
342 })
343 .collect()
344 }
345
346 fn observe_anchor(
363 &self,
364 anchor: &crate::anchor::Anchor,
365 ) -> Option<(crate::anchor::AnchorState, Option<String>)> {
366 let root = self.workspace_root.as_deref()?;
367 observe_path_anchor(root, anchor)
368 }
369
370 pub fn anchors_referencing_artifact(
379 &self,
380 artifact_path: &str,
381 ) -> Vec<(EntityId, crate::anchor::Anchor)> {
382 let mut out = Vec::new();
383 for mount in &self.mounts {
384 let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
385 continue;
386 };
387 let Ok(sc) = crate::anchor::AnchorSidecar::from_bytes(&bytes) else {
388 continue;
389 };
390 for (eid, anchors) in &sc.entities {
391 for a in anchors {
392 if anchor_references_path(a, artifact_path) {
393 out.push((EntityId(eid.clone()), a.clone()));
394 }
395 }
396 }
397 }
398 out
399 }
400
401 pub fn mem_anchors_resolved(&self, mem: &str) -> Vec<(EntityId, ResolvedAnchor)> {
413 let Some(mount) = self.mounts.iter().find(|m| m.mount.mem == mem) else {
414 return Vec::new();
415 };
416 let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
417 return Vec::new();
418 };
419 let Ok(sc) = crate::anchor::AnchorSidecar::from_bytes(&bytes) else {
420 return Vec::new();
421 };
422 let mut out = Vec::new();
423 for (eid, anchors) in &sc.entities {
424 for anchor in anchors {
425 let observed = self.observe_anchor(anchor);
426 let (state, observed_hash) = match observed {
427 Some((state, hash)) => (Some(state), hash),
428 None => (None, None),
429 };
430 out.push((
431 EntityId(eid.clone()),
432 ResolvedAnchor {
433 anchor: anchor.clone(),
434 state,
435 observed_hash,
436 },
437 ));
438 }
439 }
440 out
441 }
442
443 pub fn verify_mem_anchors(&self, mem: &str) -> Result<MemAnchorVerification, EngineError> {
456 if !self.mem_router.is_visible(mem) {
457 return Err(self.unknown_mem_error(mem));
458 }
459 let mut report = MemAnchorVerification {
460 mem: mem.to_string(),
461 ..Default::default()
462 };
463 for (eid, resolved) in self.mem_anchors_resolved(mem) {
464 let state = match resolved.state {
465 Some(crate::anchor::AnchorState::Resolves) => {
466 report.resolved += 1;
467 "resolved"
468 }
469 Some(crate::anchor::AnchorState::Drifted) => {
470 report.drifted += 1;
471 "drifted"
472 }
473 Some(crate::anchor::AnchorState::Recheck) => {
474 report.recheck += 1;
475 "recheck"
476 }
477 Some(crate::anchor::AnchorState::Orphaned) | None => {
478 report.unresolvable += 1;
479 "unresolvable"
480 }
481 };
482 report.anchors.push(VerifiedAnchor {
483 entity_id: eid.to_string(),
484 artifact: resolved.anchor.artifact.clone(),
485 grain: resolved.anchor.grain.as_wire().to_string(),
486 class: resolved.anchor.class.as_wire().to_string(),
487 state: state.to_string(),
488 observed_hash: resolved.observed_hash,
489 });
490 }
491 Ok(report)
492 }
493
494 pub fn mem_names(&self) -> Vec<&str> {
498 self.mounts.iter().map(|m| m.mount.mem.as_str()).collect()
499 }
500
501 pub fn derivation_report(
512 &self,
513 mem: &str,
514 ) -> Result<Vec<crate::ops::health::DerivationFinding>, EngineError> {
515 if !self.mem_router.is_visible(mem) {
516 return Err(self.unknown_mem_error(mem));
517 }
518 let Some(schema) = self.schemas.get(mem) else {
519 return Ok(Vec::new());
520 };
521 let declared: std::collections::HashSet<&str> = schema
522 .manifest
523 .relationships
524 .definitions
525 .iter()
526 .filter(|d| d.derivation)
527 .map(|d| d.name.as_str())
528 .collect();
529 if declared.is_empty() {
530 return Ok(Vec::new());
531 }
532 let sidecar = self
533 .mounts
534 .iter()
535 .find(|m| m.mount.mem == mem)
536 .and_then(|m| {
537 m.backend
538 .read_entity(Path::new(crate::derivation::DERIVATION_SIDECAR_PATH))
539 .ok()
540 .flatten()
541 })
542 .and_then(|bytes| crate::derivation::DerivationSidecar::from_bytes(&bytes).ok())
543 .unwrap_or_default();
544
545 let mut out = Vec::new();
546 let mut sources: Vec<&crate::entity::Entity> = self
547 .store
548 .all_entities()
549 .filter(|e| !e.stub && e.id.mem() == mem)
550 .collect();
551 sources.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
552 for entity in sources {
553 for edge in self.store.outgoing(&entity.id) {
554 if !declared.contains(edge.rel_type.as_str())
555 || edge.source != crate::store::EdgeSource::Explicit
556 {
557 continue;
558 }
559 let current = self
560 .store
561 .get(&edge.target)
562 .map(|t| t.content_hash.clone())
563 .unwrap_or_default();
564 match sidecar.get(entity.id.as_ref(), &edge.rel_type, edge.target.as_ref()) {
565 None => out.push(crate::ops::health::DerivationFinding {
566 source: entity.id.clone(),
567 rel_type: edge.rel_type.clone(),
568 target: edge.target.clone(),
569 state: "unbaselined".to_string(),
570 baseline: None,
571 current,
572 }),
573 Some(baseline) if baseline != current => {
574 out.push(crate::ops::health::DerivationFinding {
575 source: entity.id.clone(),
576 rel_type: edge.rel_type.clone(),
577 target: edge.target.clone(),
578 state: "stale".to_string(),
579 baseline: Some(baseline.to_string()),
580 current,
581 })
582 }
583 Some(_) => {}
584 }
585 }
586 }
587 Ok(out)
588 }
589
590 pub fn mount(&self, mem: &str) -> Option<&crate::workspace::Mount> {
608 self.mounts
609 .iter()
610 .find(|m| m.mount.mem == mem)
611 .map(|m| &m.mount)
612 }
613
614 pub fn orphans_by_schema(
621 &self,
622 orphan_ids: &[EntityId],
623 ) -> std::collections::BTreeMap<String, usize> {
624 let mut by_schema = std::collections::BTreeMap::new();
625 for id in orphan_ids {
626 let schema = self
627 .store()
628 .get(id)
629 .and_then(|e| self.mount(&e.mem))
630 .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
631 .unwrap_or_default();
632 *by_schema.entry(schema).or_insert(0) += 1;
633 }
634 by_schema
635 }
636
637 pub fn communities_by_schema(
643 &self,
644 mems: &[String],
645 ) -> std::collections::BTreeMap<String, usize> {
646 let louvain = self.communities();
647 let mut buckets: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
648 std::collections::BTreeMap::new();
649 for name in mems {
650 let schema = self
651 .mount(name)
652 .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
653 .unwrap_or_default();
654 let clusters = crate::graph::community::clusters_in_mem(self.store(), louvain, name);
655 buckets.entry(schema).or_default().extend(clusters);
656 }
657 buckets
658 .into_iter()
659 .map(|(schema, set)| (schema, set.len()))
660 .collect()
661 }
662
663 pub fn mounts(&self) -> Vec<&crate::workspace::Mount> {
667 self.mounts.iter().map(|m| &m.mount).collect()
668 }
669
670 pub fn writable_mem_names(&self) -> Vec<&str> {
678 self.mounts
679 .iter()
680 .filter(|m| m.mount.capability == MountCapability::Write)
681 .map(|m| m.mount.mem.as_str())
682 .collect()
683 }
684
685 pub fn default_writable_mem(&self) -> Option<&str> {
703 self.mounts
704 .iter()
705 .find(|m| m.mount.capability == MountCapability::Write)
706 .map(|m| m.mount.mem.as_str())
707 }
708
709 pub fn folder_path_for_mem(&self, mem: &str) -> Option<&Path> {
717 match self.mount(mem).map(|m| &m.storage) {
718 Some(crate::workspace::MountStorage::Folder { path }) => Some(path.as_path()),
719 _ => None,
720 }
721 }
722
723 pub fn mem_router(&self) -> &MemRouterSnapshot {
733 &self.mem_router
734 }
735
736 pub fn gitdir_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
745 let m = self
746 .mount(mem_name)
747 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
748 match &m.storage {
749 MountStorage::GitBranch { gitdir, .. } => Ok(gitdir.clone()),
750 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
751 Err(EngineError::Mem(format!(
752 "mem '{mem_name}' has no resolved gitdir"
753 )))
754 }
755 }
756 }
757
758 pub fn worktree_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
773 let m = self
774 .mount(mem_name)
775 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
776 match &m.storage {
777 MountStorage::Folder { path } => Ok(path.clone()),
778 MountStorage::GitBranch { .. } => {
779 if let Some(root) = self.workspace_root.as_deref() {
780 let candidate = root.join(mem_name);
781 if candidate
782 .join(crate::mem::MEM_META_DIR)
783 .join("config.json")
784 .is_file()
785 {
786 return Ok(candidate.canonicalize().unwrap_or(candidate));
787 }
788 }
789 Err(EngineError::Mem(format!(
790 "mem '{mem_name}' has no working tree (mem-repo-backed)"
791 )))
792 }
793 MountStorage::Archive { .. } => Err(EngineError::Mem(format!(
794 "mem '{mem_name}' is archive-backed and has no worktree"
795 ))),
796 MountStorage::InMemory => Err(EngineError::Mem(format!(
797 "mem '{mem_name}' is in-memory and has no worktree"
798 ))),
799 }
800 }
801
802 pub fn mem_config_for(&self, mem: &str) -> Option<&memstead_schema::config::MemConfig> {
820 self.mounts
821 .iter()
822 .find(|m| m.mount.mem == mem)
823 .and_then(|m| m.mem_config.as_ref())
824 }
825
826 pub fn archive_provenance_for(&self, mem: &str) -> Option<&memstead_schema::ArchiveProvenance> {
833 self.mounts
834 .iter()
835 .find(|m| m.mount.mem == mem)
836 .and_then(|m| m.archive_provenance.as_ref())
837 }
838
839 pub fn mem_configs_named(
850 &self,
851 ) -> impl Iterator<Item = (&str, &memstead_schema::config::MemConfig)> {
852 self.mounts
853 .iter()
854 .filter_map(|m| m.mem_config.as_ref().map(|c| (m.mount.mem.as_str(), c)))
855 }
856
857 pub fn schema_for(&self, mem: &str) -> Option<std::sync::Arc<memstead_schema::Schema>> {
864 self.schemas.get(mem).cloned()
865 }
866
867 pub fn mem_head_sha(&self, mem_name: &str) -> Result<Option<String>, EngineError> {
878 let m = self
879 .mounts
880 .iter()
881 .find(|m| m.mount.mem == mem_name)
882 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
883 Ok(m.last_known_head.clone())
884 }
885
886 pub fn mem_drifted(&self, mem_name: &str) -> Result<bool, EngineError> {
901 let m = self
902 .mounts
903 .iter()
904 .find(|m| m.mount.mem == mem_name)
905 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
906 let live = m.backend.current_head().ok().flatten();
907 Ok(live != m.last_known_head)
908 }
909
910 pub fn workspace_root(&self) -> Option<&Path> {
915 self.workspace_root.as_deref()
916 }
917
918 pub fn load_warnings(&self) -> &[WarningHint] {
923 &self.load_warnings
924 }
925
926 pub fn quarantined_mems(&self) -> &[crate::engine::QuarantinedMem] {
930 &self.quarantined
931 }
932
933 pub fn quarantine_reason(&self, mem: &str) -> Option<&crate::engine::QuarantinedMem> {
935 self.quarantined.iter().find(|q| q.mount.mem == mem)
936 }
937
938 pub fn unknown_mem_error(&self, mem: &str) -> EngineError {
945 match self.quarantine_reason(mem) {
946 Some(q) => EngineError::MemQuarantined {
947 mem: mem.to_string(),
948 reason_code: q.reason_code.clone(),
949 reason_message: q.reason_message.clone(),
950 },
951 None => EngineError::UnknownMem(mem.to_string()),
952 }
953 }
954
955 pub fn boot_diagnosis(&self) -> Option<(&str, &str)> {
958 self.boot_diagnosis
959 .as_ref()
960 .map(|(c, m)| (c.as_str(), m.as_str()))
961 }
962
963 pub fn diagnostic_shell(reason_code: String, reason_message: String) -> Engine {
971 let mut engine =
972 Engine::from_mounts(Vec::new()).expect("an empty mount list always constructs");
973 engine.boot_diagnosis = Some((reason_code, reason_message));
974 engine
975 }
976
977 pub fn extend_quarantine(&mut self, entries: Vec<crate::engine::QuarantinedMem>) {
983 self.quarantined.extend(entries);
984 }
985
986 pub fn communities(&self) -> &LouvainOutput {
1015 self.community_memo.get_or_init(|| {
1016 let schema = self
1023 .schemas
1024 .iter()
1025 .min_by(|a, b| a.0.cmp(b.0))
1026 .map(|(_, s)| s.clone())
1027 .unwrap_or_else(Schema::builtin_default);
1028 let manifest = &schema.manifest;
1029 let resolution = manifest.community.resolution;
1030 let seed = manifest.community.seed;
1031 let schema_for_weights = schema.clone();
1032 detect_communities(&self.store, resolution, seed, move |rel_type| {
1033 schema_for_weights
1034 .manifest
1035 .relationships
1036 .definitions
1037 .iter()
1038 .find(|d| d.name == rel_type)
1039 .map(|d| d.default_weight as f64)
1040 .unwrap_or(1.0)
1041 })
1042 })
1043 }
1044
1045 pub fn invalidate_communities(&mut self) {
1047 self.community_memo = OnceCell::new();
1048 }
1049
1050 pub fn orphans(&self) -> Vec<EntityId> {
1054 crate::graph::query::find_orphans_with_schemas(&self.store, &self.schemas)
1055 }
1056
1057 pub fn leaf_population(&self) -> std::collections::BTreeMap<String, usize> {
1061 crate::graph::query::leaf_population(&self.store, &self.schemas)
1062 }
1063
1064 pub fn stubs(&self) -> Vec<(EntityId, Vec<EntityId>)> {
1066 crate::graph::query::find_stubs(&self.store)
1067 }
1068
1069 pub fn most_connected(&self, limit: usize) -> Vec<crate::graph::query::Connectivity> {
1071 crate::graph::query::most_connected(&self.store, limit)
1072 }
1073
1074 pub fn missing_required_outgoing(
1078 &self,
1079 mem_filter: Option<&str>,
1080 ) -> Vec<crate::ops::health::MissingRequiredOutgoingReport> {
1081 crate::ops::health::collect_missing_required_outgoing(
1082 &self.store,
1083 mem_filter,
1084 &self.schemas,
1085 )
1086 }
1087
1088 pub fn constraint_findings(
1093 &self,
1094 mem_filter: Option<&str>,
1095 ) -> Vec<crate::ops::health::ConstraintFindingReport> {
1096 crate::ops::health::collect_constraint_findings(&self.store, mem_filter, &self.schemas)
1097 }
1098
1099 pub fn schema_format_defects(&self) -> Vec<crate::ops::health::SchemaFormatDefect> {
1102 crate::ops::health::collect_schema_format_defects(&self.schemas)
1103 }
1104
1105 pub fn conformance_findings(
1111 &self,
1112 mem: &str,
1113 target_schema: Option<&memstead_schema::SchemaRef>,
1114 ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
1115 let pinned = self
1116 .schemas
1117 .get(mem)
1118 .ok_or_else(|| self.unknown_mem_error(mem))?;
1119 let effective: Arc<Schema> = match target_schema {
1120 None => pinned.clone(),
1121 Some(target) => self.resolve_schema_by_ref(target).ok_or_else(|| {
1122 let consulted: Vec<_> = self
1123 .workspace_schemas
1124 .iter()
1125 .chain(self.builtin_schemas.iter())
1126 .cloned()
1127 .collect();
1128 EngineError::SchemaNotFound {
1129 mem: mem.to_string(),
1130 pin: target.as_display(),
1131 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1132 &target.name,
1133 &target.version,
1134 &consulted,
1135 ),
1136 install_hint: None,
1137 }
1138 .with_schema_install_probe(self.workspace_root())
1139 })?,
1140 };
1141 Ok(crate::ops::integrity::conformance_findings(
1142 &self.store,
1143 mem,
1144 &effective,
1145 &self.schemas,
1146 ))
1147 }
1148
1149 pub(crate) fn resolve_schema_by_ref(
1153 &self,
1154 target: &memstead_schema::SchemaRef,
1155 ) -> Option<Arc<Schema>> {
1156 self.schemas
1157 .values()
1158 .chain(self.workspace_schemas.iter())
1159 .chain(self.builtin_schemas.iter())
1160 .find(|s| {
1161 let (name, version) = s.id();
1162 name == target.name && version == target.version
1163 })
1164 .cloned()
1165 }
1166
1167 pub fn schema_pin(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
1172 self.mounts
1173 .iter()
1174 .find(|m| m.mount.mem == mem)
1175 .and_then(|m| m.mount.schema.clone())
1176 }
1177
1178 pub fn migration_target(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
1181 self.mounts
1182 .iter()
1183 .find(|m| m.mount.mem == mem)
1184 .and_then(|m| m.mount.migration_target.clone())
1185 }
1186
1187 pub fn consistency_findings(
1191 &self,
1192 mem: &str,
1193 ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
1194 if !self.schemas.contains_key(mem) {
1195 return Err(self.unknown_mem_error(mem));
1196 }
1197 Ok(crate::ops::integrity::consistency_findings(
1198 &self.store,
1199 mem,
1200 ))
1201 }
1202
1203 pub fn health(&self) -> crate::ops::HealthSummary {
1205 let fallback = engine_fallback_type();
1206 let mut summary =
1207 crate::ops::health::compute_health(&self.store, fallback.as_ref(), &self.schemas);
1208 if !self.load_warnings.is_empty() {
1215 let mut merged = self.load_warnings.clone();
1216 merged.append(&mut summary.warnings);
1217 summary.warnings = merged;
1218 }
1219 summary.quarantined = self
1223 .quarantined
1224 .iter()
1225 .map(|q| crate::ops::QuarantinedMemReport {
1226 mem: q.mount.mem.clone(),
1227 reason_code: q.reason_code.clone(),
1228 reason_message: q.reason_message.clone(),
1229 })
1230 .collect();
1231 summary.boot_diagnosis = self
1232 .boot_diagnosis
1233 .as_ref()
1234 .map(|(code, message)| serde_json::json!({ "code": code, "message": message }));
1235 if let Some(root) = self.workspace_root.as_deref()
1241 && let Some(outer) = crate::workspace_root::find_enclosing_git_repo(root)
1242 && !crate::workspace_root::outer_repo_ignores_mem_repo(&outer, root)
1243 {
1244 summary
1245 .warnings
1246 .push(WarningHint::OuterRepoNotIgnoringMemRepo {
1247 outer_repo_root: outer.display().to_string(),
1248 workspace_root: root.display().to_string(),
1249 });
1250 }
1251 summary.warnings.extend(self.authoring_drift_findings());
1258 summary
1259 }
1260
1261 fn authoring_drift_findings(&self) -> Vec<WarningHint> {
1266 let Some(root) = self.workspace_root.as_deref() else {
1267 return Vec::new();
1268 };
1269 let mut pins: std::collections::BTreeMap<(String, String), Vec<String>> =
1272 std::collections::BTreeMap::new();
1273 for (mem, schema) in &self.schemas {
1274 let (name, version) = schema.id();
1275 pins.entry((name.to_string(), version.to_string()))
1276 .or_default()
1277 .push(mem.clone());
1278 }
1279 let mut out = Vec::new();
1280 for ((name, version), mut mems) in pins {
1281 mems.sort();
1282 let Some(stamped_path) = self.read_install_provenance(root, &name, &version) else {
1283 continue;
1284 };
1285 let schema_ref = format!("{name}@{version}");
1286 let authoring = std::path::Path::new(&stamped_path);
1287 if !authoring.is_dir() {
1288 out.push(WarningHint::SchemaAuthoringSourceMissing {
1289 schema_ref,
1290 stamped_path,
1291 mems,
1292 });
1293 continue;
1294 }
1295 let sealed = self
1296 .schemas
1297 .get(&mems[0])
1298 .expect("mems collected from self.schemas keys")
1299 .clone();
1300 match memstead_schema::load_schema_from_dir(authoring) {
1301 Err(e) => out.push(WarningHint::SchemaAuthoringSourceDiverged {
1302 schema_ref,
1303 stamped_path,
1304 mems,
1305 detail: format!("the authoring package no longer loads: {e}"),
1306 }),
1307 Ok(authored) => {
1308 if schema_parsed_fingerprint(&authored) != schema_parsed_fingerprint(&sealed) {
1309 out.push(WarningHint::SchemaAuthoringSourceDiverged {
1310 schema_ref,
1311 stamped_path,
1312 mems,
1313 detail: "the parsed authoring package differs from the sealed copy \
1314 the engine runs on"
1315 .to_string(),
1316 });
1317 }
1318 }
1319 }
1320 }
1321 out
1322 }
1323
1324 fn read_install_provenance(&self, root: &Path, name: &str, version: &str) -> Option<String> {
1331 let folder_stamp = root
1332 .join(".memstead")
1333 .join("schemas")
1334 .join(format!("{name}@{version}"))
1335 .join(memstead_schema::INSTALL_PROVENANCE_FILE);
1336 let bytes = if folder_stamp.is_file() {
1337 std::fs::read(&folder_stamp).ok()
1338 } else {
1339 let ops = self.git_branch_ops()?;
1340 let gitdir = self
1341 .mounts
1342 .iter()
1343 .find_map(|m| match &m.mount.storage {
1344 crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
1345 Some(gitdir.clone())
1346 }
1347 _ => None,
1348 })
1349 .or_else(|| {
1350 let g = root.join("mem-repo").join(".git");
1351 g.is_dir().then_some(g)
1352 })?;
1353 (ops.read_schema_file)(
1354 &gitdir,
1355 name,
1356 version,
1357 memstead_schema::INSTALL_PROVENANCE_FILE,
1358 )
1359 .ok()
1360 .flatten()
1361 }?;
1362 let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
1363 v.get("authoring_path")?.as_str().map(String::from)
1364 }
1365
1366 pub fn status(&self) -> crate::ops::Status {
1370 let mut types_in_use: Vec<String> = self
1371 .store
1372 .all_entities()
1373 .filter(|e| !e.stub && !e.entity_type.is_empty())
1374 .map(|e| e.entity_type.clone())
1375 .collect();
1376 types_in_use.sort();
1377 types_in_use.dedup();
1378
1379 let mut edge_types: HashMap<String, usize> = HashMap::new();
1380 for id in self.store.all_ids() {
1381 for edge in self.store.outgoing(id) {
1382 *edge_types.entry(edge.rel_type.clone()).or_insert(0) += 1;
1383 }
1384 }
1385
1386 crate::ops::Status {
1387 entity_count: self.store.all_entities().filter(|e| !e.stub).count(),
1388 edge_count: self.store.edge_count(),
1389 edge_types,
1390 community_count: self.communities().count,
1391 mem_count: self.mounts.len(),
1392 types_in_use,
1393 }
1394 }
1395
1396 pub fn context(&self, id: &EntityId) -> Option<ContextResult> {
1400 let entity = self.store.get(id)?;
1401 let community = self
1402 .communities()
1403 .entity_cluster_map
1404 .get(id.as_ref())
1405 .cloned();
1406 let mut neighbors = Vec::new();
1407 for edge in self.store.outgoing(id) {
1408 if let Some(target) = self.store.get(&edge.target) {
1409 neighbors.push(NeighborInfo {
1410 id: target.id.clone(),
1411 title: target.title.clone(),
1412 relationship: edge.rel_type.clone(),
1413 direction: Direction::Outgoing,
1414 });
1415 }
1416 }
1417 for edge in self.store.incoming(id) {
1418 if let Some(source) = self.store.get(&edge.from) {
1419 neighbors.push(NeighborInfo {
1420 id: source.id.clone(),
1421 title: source.title.clone(),
1422 relationship: edge.rel_type.clone(),
1423 direction: Direction::Incoming,
1424 });
1425 }
1426 }
1427 Some(ContextResult {
1428 entity_id: entity.id.clone(),
1429 community,
1430 neighbors,
1431 })
1432 }
1433
1434 #[cfg(not(target_arch = "wasm32"))]
1440 pub fn search_indexes(&self) -> &HashMap<String, MemIndex> {
1441 self.search_indexes_memo
1442 .get_or_init(|| build_all(&self.store, &self.schemas))
1443 }
1444
1445 pub fn invalidate_search_indexes(&mut self) {
1449 #[cfg(not(target_arch = "wasm32"))]
1450 {
1451 self.search_indexes_memo = OnceCell::new();
1452 }
1453 }
1454
1455 #[cfg(not(target_arch = "wasm32"))]
1457 pub fn list(&self, scope: &SearchScope) -> crate::ops::ListResult {
1458 let fallback = engine_fallback_type();
1459 crate::ops::search::list(&self.store, scope, fallback.as_ref(), &self.schemas)
1460 }
1461
1462 pub fn search(&self, scope: &SearchScope) -> Result<SearchResult, EngineError> {
1468 if let Some(mem) = scope.mem.as_deref()
1472 && self.quarantine_reason(mem).is_some()
1473 {
1474 return Err(self.unknown_mem_error(mem));
1475 }
1476 #[cfg(target_arch = "wasm32")]
1477 {
1478 let _ = scope;
1479 return Err(EngineError::SearchUnavailable);
1480 }
1481 #[cfg(not(target_arch = "wasm32"))]
1482 {
1483 let fallback = engine_fallback_type();
1484 Ok(crate::ops::search::search(
1485 &self.store,
1486 scope,
1487 fallback.as_ref(),
1488 self.search_indexes(),
1489 &self.schemas,
1490 ))
1491 }
1492 }
1493
1494 pub fn list_entities(&self, mem: &str) -> Result<Vec<PathBuf>, EngineError> {
1497 let m = self.find_mount(mem)?;
1498 m.backend.list_entities().map_err(EngineError::Backend)
1499 }
1500
1501 pub fn read_entity(&self, mem: &str, rel_path: &Path) -> Result<Option<Vec<u8>>, EngineError> {
1503 let m = self.find_mount(mem)?;
1504 m.backend
1505 .read_entity(rel_path)
1506 .map_err(EngineError::Backend)
1507 }
1508
1509 pub fn read_provenance(
1513 &self,
1514 mem: &str,
1515 cursor: Option<&str>,
1516 ) -> Result<Vec<Provenance>, EngineError> {
1517 let m = self.find_mount(mem)?;
1518 m.backend
1519 .read_provenance(cursor)
1520 .map_err(EngineError::Backend)
1521 }
1522
1523 pub fn capability(&self, mem: &str) -> Result<crate::workspace::MountCapability, EngineError> {
1528 let m = self.find_mount(mem)?;
1529 Ok(m.mount.capability)
1530 }
1531
1532 pub fn edge_is_from_readonly(&self, from: &EntityId) -> bool {
1547 match self.capability(from.mem()) {
1548 Ok(crate::workspace::MountCapability::ReadOnly) => true,
1549 Ok(crate::workspace::MountCapability::Write) | Err(_) => false,
1550 }
1551 }
1552
1553 pub fn cross_mem_link_allowed(&self, from_mem: &str, to_mem: &str) -> bool {
1587 use memstead_schema::workspace_config::CrossLinkValue;
1588 if from_mem == to_mem {
1589 return true;
1590 }
1591
1592 if let Some(value) = self.settings.cross_mem_links.get(from_mem) {
1594 match value {
1595 CrossLinkValue::Wildcard => return true,
1596 CrossLinkValue::List(targets) => {
1597 if targets.iter().any(|t| t == to_mem) {
1598 return true;
1599 }
1600 }
1604 }
1605 }
1606
1607 let rule_set = self.create_rule_set_memo.get_or_init(|| {
1609 crate::mem_management::CreateRuleSet::new(
1610 self.settings.mem_create_rules.clone(),
1611 )
1612 .unwrap_or_else(|err| {
1613 tracing::warn!(
1614 error = %err,
1615 "cross_mem_link_allowed: failed to compile mem_create_rules — synthesis disabled (resolver falls back to explicit-policy-only)"
1616 );
1617 crate::mem_management::CreateRuleSet::default()
1618 })
1619 });
1620
1621 let candidate = match self.mount(from_mem).and_then(|m| m.mem_path()) {
1630 Some(path) => format!("{path}/{from_mem}"),
1631 None => from_mem.to_string(),
1632 };
1633 if let Some(matched) = rule_set.first_match(std::path::Path::new(&candidate))
1634 && let Some(synth) = matched.default_cross_links.as_ref()
1635 {
1636 return match synth {
1637 CrossLinkValue::Wildcard => true,
1638 CrossLinkValue::List(targets) => targets.iter().any(|t| t == to_mem),
1639 };
1640 }
1641
1642 false
1643 }
1644
1645 pub(super) fn find_mount(&self, mem: &str) -> Result<&MountedBackend, EngineError> {
1646 self.mounts
1647 .iter()
1648 .find(|m| m.mount.mem == mem)
1649 .ok_or_else(|| self.unknown_mem_error(mem))
1650 }
1651}
1652
1653fn anchor_base_path(artifact: &str) -> &str {
1657 let cut = artifact.find(['@', '#']).unwrap_or(artifact.len());
1658 &artifact[..cut]
1659}
1660
1661#[derive(Debug, Clone, Default, serde::Serialize)]
1664pub struct MemAnchorVerification {
1665 pub mem: String,
1666 pub resolved: usize,
1669 pub drifted: usize,
1671 pub recheck: usize,
1674 pub unresolvable: usize,
1676 pub anchors: Vec<VerifiedAnchor>,
1677}
1678
1679#[derive(Debug, Clone, serde::Serialize)]
1681pub struct VerifiedAnchor {
1682 pub entity_id: String,
1683 pub artifact: String,
1684 pub grain: String,
1685 pub class: String,
1686 pub state: String,
1688 #[serde(skip_serializing_if = "Option::is_none")]
1689 pub observed_hash: Option<String>,
1690}
1691
1692#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1699pub struct ResolvedAnchor {
1700 #[serde(flatten)]
1703 pub anchor: crate::anchor::Anchor,
1704 #[serde(skip_serializing_if = "Option::is_none")]
1708 pub state: Option<crate::anchor::AnchorState>,
1709 #[serde(skip)]
1716 pub observed_hash: Option<String>,
1717}
1718
1719fn observe_path_anchor(
1735 root: &Path,
1736 anchor: &crate::anchor::Anchor,
1737) -> Option<(crate::anchor::AnchorState, Option<String>)> {
1738 use crate::anchor::AnchorGrain;
1739 match anchor.grain {
1740 AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => {}
1741 AnchorGrain::Url | AnchorGrain::Entity => return None,
1742 }
1743 let base = anchor_base_path(&anchor.artifact);
1744 let path = root.join(base);
1745 if !path.exists() {
1746 return Some((
1747 crate::anchor::resolve_anchor(anchor, &crate::anchor::ArtifactObservation::Absent),
1748 None,
1749 ));
1750 }
1751 let current_hash = if anchor.class.is_hash_bearing()
1752 && matches!(anchor.grain, AnchorGrain::File | AnchorGrain::Span)
1753 && path.is_file()
1754 {
1755 std::fs::read(&path)
1756 .ok()
1757 .map(|bytes| crate::anchor::prepared_content_hash(&bytes))
1758 } else {
1759 None
1760 };
1761 let observation = crate::anchor::ArtifactObservation::Present {
1762 current_hash: current_hash.clone(),
1763 };
1764 Some((
1765 crate::anchor::resolve_anchor(anchor, &observation),
1766 current_hash,
1767 ))
1768}
1769
1770fn schema_parsed_fingerprint(schema: &memstead_schema::Schema) -> String {
1777 let mut keys: Vec<&String> = schema.types.keys().collect();
1778 keys.sort();
1779 let types: Vec<String> = keys
1780 .iter()
1781 .map(|k| format!("{k}={:?}", schema.types[k.as_str()]))
1782 .collect();
1783 format!(
1784 "{:?}|{}|{}",
1785 schema.manifest,
1786 schema.version,
1787 types.join(";")
1788 )
1789}
1790
1791fn anchor_references_path(anchor: &crate::anchor::Anchor, path: &str) -> bool {
1792 let base = anchor_base_path(&anchor.artifact);
1793 if base == path {
1794 return true;
1795 }
1796 if anchor.grain == crate::anchor::AnchorGrain::Tree {
1797 let prefix = base.strip_suffix('/').unwrap_or(base);
1798 return path.starts_with(&format!("{prefix}/"));
1799 }
1800 false
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805 use std::path::Path;
1806
1807 use tempfile::TempDir;
1808
1809 use crate::backend::{BackendError, MemBackend};
1810 use crate::engine::test_helpers::*;
1811 use crate::engine::{Engine, EngineError, RelateEntityArgs};
1812 use crate::entity::EntityId;
1813 use crate::ops::{Direction, SearchScope, WarningHint};
1814 use crate::provenance::Provenance;
1815 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
1816
1817 use crate::vcs::CommitContext;
1818 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1819
1820 #[test]
1825 fn schema_origin_classifies_builtin_first_party_and_unknown_third_party() {
1826 use std::sync::Arc;
1827
1828 use crate::render::OriginClass;
1829
1830 let tmp = TempDir::new().unwrap();
1831 let engine = Engine::from_mounts(vec![(
1832 folder_mount("specs", tmp.path().to_path_buf()),
1833 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf())) as Box<dyn MemBackend>,
1834 )])
1835 .unwrap();
1836
1837 let builtin = engine.builtin_schemas()[0].clone();
1839 assert_eq!(
1840 engine.schema_origin(&builtin),
1841 OriginClass::FirstParty,
1842 "a built-in schema is first-party"
1843 );
1844
1845 let foreign = Arc::new(memstead_schema::Schema {
1849 manifest: builtin.manifest.clone(),
1850 version: semver::Version::new(99, 0, 0),
1851 types: builtin.types.clone(),
1852 });
1853 assert_eq!(
1854 engine.schema_origin(&foreign),
1855 OriginClass::ThirdParty,
1856 "a schema in neither catalogue classifies third-party (safe default)"
1857 );
1858 }
1859
1860 #[test]
1866 fn mem_origin_class_writable_first_party_readonly_third_party() {
1867 use crate::render::OriginClass;
1868
1869 let tmp = TempDir::new().unwrap();
1870 let writable_dir = tmp.path().join("writable");
1872 std::fs::create_dir_all(&writable_dir).unwrap();
1873 let writer = FilesystemMemWriter::new(writable_dir.clone());
1874
1875 let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
1877 let archive_path = build_archive(tmp.path(), "ext", &[("ext.md", body.as_bytes())]);
1878
1879 let engine = Engine::from_mounts(vec![
1880 (
1881 folder_mount("local", writable_dir),
1882 Box::new(writer) as Box<dyn MemBackend>,
1883 ),
1884 (
1885 archive_mount("external", archive_path.clone()),
1886 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1887 ),
1888 ])
1889 .unwrap();
1890
1891 assert_eq!(
1892 engine.mem_origin_class("local"),
1893 OriginClass::FirstParty,
1894 "a writable mount is first-party"
1895 );
1896 assert_eq!(
1897 engine.mem_origin_class("external"),
1898 OriginClass::ThirdParty,
1899 "a read-only mount is third-party"
1900 );
1901 assert_eq!(
1902 engine.mem_origin_class("no-such-mem"),
1903 OriginClass::ThirdParty,
1904 "an unknown mem is third-party (safe default)"
1905 );
1906 }
1907
1908 #[test]
1913 fn declared_origin_overrides_inference_per_mem() {
1914 use crate::render::OriginClass;
1915
1916 let tmp = TempDir::new().unwrap();
1917 let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
1918 let vouched_path = build_archive(tmp.path(), "vouched", &[("v.md", body.as_bytes())]);
1919 let other_path = build_archive(tmp.path(), "other", &[("o.md", body.as_bytes())]);
1920
1921 let mut engine = Engine::from_mounts(vec![
1922 (
1923 archive_mount("vouched", vouched_path.clone()),
1924 Box::new(ArchiveBackend::new(vouched_path)) as Box<dyn MemBackend>,
1925 ),
1926 (
1927 archive_mount("other", other_path.clone()),
1928 Box::new(ArchiveBackend::new(other_path)) as Box<dyn MemBackend>,
1929 ),
1930 ])
1931 .unwrap();
1932
1933 engine.declare_mem_origin("vouched", OriginClass::FirstParty);
1934
1935 assert_eq!(
1936 engine.mem_origin_class("vouched"),
1937 OriginClass::FirstParty,
1938 "the deployment's declaration wins over the read-only inference"
1939 );
1940 assert_eq!(
1941 engine.mem_origin_class("other"),
1942 OriginClass::ThirdParty,
1943 "an undeclared sibling mount keeps the safe default"
1944 );
1945 }
1946
1947 #[test]
1953 fn schema_origin_third_party_until_pinned_by_a_writable_mount() {
1954 use memstead_schema::SchemaRef;
1955
1956 use crate::render::OriginClass;
1957
1958 let manifest = r#"name: trust-test
1959version: 0.1.0
1960description: adopt-gate test schema
1961when_to_use: tests
1962types:
1963 - doc
1964relationships:
1965 mode: strict
1966 definitions:
1967 - name: _default
1968 description: fallback
1969 default_weight: 1.0
1970community:
1971 resolution: 1.0
1972 seed: 42
1973"#;
1974 let pin = SchemaRef::new("trust-test", semver::Version::new(0, 1, 0));
1975
1976 let mk_engine = |cap: MountCapability| -> Engine {
1977 let tmp = TempDir::new().unwrap();
1978 let schemas_dir = tmp.path().join("schemas");
1979 std::fs::create_dir_all(&schemas_dir).unwrap();
1980 write_schema_files_with_default_type(&schemas_dir, "trust-test", manifest, &["doc"]);
1981 let mem_dir = tmp.path().join("mem");
1982 std::fs::create_dir_all(&mem_dir).unwrap();
1983 let mount = Mount {
1984 mem: "v".to_string(),
1985 schema: Some(pin.clone()),
1986 storage: MountStorage::Folder {
1987 path: mem_dir.clone(),
1988 },
1989 capability: cap,
1990 lifecycle: MountLifecycle::Eager,
1991 cross_linkable: true,
1992 migration_target: None,
1993 };
1994 let backend = Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>;
1995 std::mem::forget(tmp);
1999 Engine::from_mounts_with_schemas_dir(vec![(mount, backend)], Some(&schemas_dir))
2000 .unwrap()
2001 };
2002
2003 let ro = mk_engine(MountCapability::ReadOnly);
2005 let schema = ro.schemas().get("v").expect("schema resolved").clone();
2006 assert_eq!(
2007 ro.schema_origin(&schema),
2008 OriginClass::ThirdParty,
2009 "a non-built-in schema pinned only by a read-only mount is third-party"
2010 );
2011
2012 let rw = mk_engine(MountCapability::Write);
2014 let schema = rw.schemas().get("v").expect("schema resolved").clone();
2015 assert_eq!(
2016 rw.schema_origin(&schema),
2017 OriginClass::FirstParty,
2018 "a writable mount pinning the schema adopts it → first-party"
2019 );
2020 }
2021
2022 #[test]
2029 fn archive_provenance_surfaces_per_entity_and_reports_absence() {
2030 use memstead_schema::History;
2031
2032 let tmp = TempDir::new().unwrap();
2033 let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
2034 let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
2035 let beta = b"---\ntype: spec\n---\n# Beta\n\n## Identity\n\nb\n\n## Purpose\n\np\n";
2036 let provenance = br#"{"format":1,"history":"summarised","entities":{"alpha":{"rationale":"why alpha exists","kind":"create","timestamp":"2026-06-24T00:00:00Z","actor":"agent"}}}"#;
2038 let archive = build_archive(
2039 tmp.path(),
2040 "seed",
2041 &[
2042 (".memstead/config.json", config),
2043 ("alpha.md", alpha),
2044 ("beta.md", beta),
2045 (".memstead/provenance.json", provenance),
2046 ],
2047 );
2048 let engine = Engine::from_mounts(vec![(
2049 archive_mount("seed", archive.clone()),
2050 Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
2051 )])
2052 .unwrap();
2053
2054 let prov = engine
2055 .archive_provenance_for("seed")
2056 .expect("provenance payload read from the archive");
2057 assert_eq!(
2058 prov.history,
2059 History::Summarised,
2060 "history-not-shipped is observable"
2061 );
2062 assert_eq!(
2063 prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
2064 Some("why alpha exists"),
2065 "noted entity surfaces its rationale"
2066 );
2067 assert!(
2068 prov.entity("beta").is_none(),
2069 "unnoted entity is absent (reported absent, not fabricated)"
2070 );
2071 }
2072
2073 #[test]
2077 fn archive_without_provenance_reports_absent() {
2078 let tmp = TempDir::new().unwrap();
2079 let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
2080 let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
2081 let archive = build_archive(
2082 tmp.path(),
2083 "seed",
2084 &[(".memstead/config.json", config), ("alpha.md", alpha)],
2085 );
2086 let engine = Engine::from_mounts(vec![(
2087 archive_mount("seed", archive.clone()),
2088 Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
2089 )])
2090 .unwrap();
2091 assert!(
2092 engine.archive_provenance_for("seed").is_none(),
2093 "an archive without a provenance payload reports provenance absent"
2094 );
2095 }
2096
2097 #[test]
2098 fn folder_mount_routes_reads_to_filesystem_backend() {
2099 let tmp = TempDir::new().unwrap();
2100 let mem_dir = tmp.path().to_path_buf();
2101 let writer = FilesystemMemWriter::new(mem_dir.clone());
2102 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"alpha")
2106 .unwrap();
2107 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
2108 .unwrap();
2109
2110 let engine = Engine::from_mounts(vec![(
2111 folder_mount("specs", mem_dir),
2112 Box::new(writer) as Box<dyn MemBackend>,
2113 )])
2114 .unwrap();
2115
2116 let mut paths: Vec<String> = engine
2117 .list_entities("specs")
2118 .unwrap()
2119 .into_iter()
2120 .map(|p| p.to_string_lossy().into_owned())
2121 .collect();
2122 paths.sort();
2123 assert_eq!(paths, vec!["a.md".to_string()]);
2124
2125 assert_eq!(
2126 engine.read_entity("specs", Path::new("a.md")).unwrap(),
2127 Some(b"alpha".to_vec())
2128 );
2129 }
2130
2131 #[test]
2132 fn heterogeneous_mounts_route_to_correct_backend() {
2133 let tmp = TempDir::new().unwrap();
2134
2135 let folder_dir = tmp.path().join("folder-mem");
2137 std::fs::create_dir_all(&folder_dir).unwrap();
2138 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
2139 <FilesystemMemWriter as MemWriter>::write_entity(
2140 &folder_writer,
2141 Path::new("local.md"),
2142 b"local",
2143 )
2144 .unwrap();
2145 <FilesystemMemWriter as MemWriter>::commit(
2146 &folder_writer,
2147 "seed",
2148 &CommitContext::internal(),
2149 )
2150 .unwrap();
2151
2152 let archive_path = build_archive(
2154 tmp.path(),
2155 "external",
2156 &[("ext.md", b"external"), ("dir/nested.md", b"nested")],
2157 );
2158
2159 let engine = Engine::from_mounts(vec![
2160 (
2161 folder_mount("local", folder_dir),
2162 Box::new(folder_writer) as Box<dyn MemBackend>,
2163 ),
2164 (
2165 archive_mount("external", archive_path.clone()),
2166 Box::new(ArchiveBackend::new(archive_path)),
2167 ),
2168 ])
2169 .unwrap();
2170
2171 assert_eq!(engine.mem_names(), vec!["local", "external"]);
2173 assert_eq!(
2174 engine.read_entity("local", Path::new("local.md")).unwrap(),
2175 Some(b"local".to_vec())
2176 );
2177 assert_eq!(
2178 engine.read_entity("external", Path::new("ext.md")).unwrap(),
2179 Some(b"external".to_vec())
2180 );
2181 assert_eq!(
2182 engine
2183 .read_entity("external", Path::new("dir/nested.md"))
2184 .unwrap(),
2185 Some(b"nested".to_vec())
2186 );
2187 assert_eq!(
2190 engine.read_entity("local", Path::new("ext.md")).unwrap(),
2191 None
2192 );
2193 assert_eq!(
2194 engine
2195 .read_entity("external", Path::new("local.md"))
2196 .unwrap(),
2197 None
2198 );
2199 }
2200
2201 #[test]
2202 fn edge_is_from_readonly_classifies_every_edge_by_source_mount_capability() {
2203 let tmp = TempDir::new().unwrap();
2209
2210 let folder_dir = tmp.path().join("local-mem");
2214 std::fs::create_dir_all(&folder_dir).unwrap();
2215 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
2216 let local_md = b"---\ntype: spec\n---\n# Note\n\n## Identity\n\nsee [[external:archived]] for prior context.\n\n## Relationships\n\n- **REFERENCES**: [[external:archived]]\n";
2217 <FilesystemMemWriter as MemWriter>::write_entity(
2218 &folder_writer,
2219 Path::new("note.md"),
2220 local_md,
2221 )
2222 .unwrap();
2223 <FilesystemMemWriter as MemWriter>::commit(
2224 &folder_writer,
2225 "seed",
2226 &CommitContext::internal(),
2227 )
2228 .unwrap();
2229
2230 let archive_md = b"---\ntype: spec\n---\n# Archived\n\n## Identity\n\nrefers back to [[local:note]] for the current revision.\n\n## Relationships\n\n- **REFERENCES**: [[local:note]]\n";
2234 let archive_path = build_archive(tmp.path(), "external", &[("archived.md", archive_md)]);
2235
2236 let engine = Engine::from_mounts(vec![
2237 (
2238 folder_mount("local", folder_dir),
2239 Box::new(folder_writer) as Box<dyn MemBackend>,
2240 ),
2241 (
2242 archive_mount("external", archive_path.clone()),
2243 Box::new(ArchiveBackend::new(archive_path)),
2244 ),
2245 ])
2246 .unwrap();
2247
2248 let local_id = EntityId::new("local", "note");
2250 let archived_id = EntityId::new("external", "archived");
2251 assert!(engine.get_entity(&local_id).is_some());
2252 assert!(engine.get_entity(&archived_id).is_some());
2253 assert!(matches!(
2254 engine.capability("local").unwrap(),
2255 MountCapability::Write
2256 ));
2257 assert!(matches!(
2258 engine.capability("external").unwrap(),
2259 MountCapability::ReadOnly
2260 ));
2261
2262 let mut seen_write_edge = false;
2268 let mut seen_readonly_edge = false;
2269 for from in engine.store().all_ids().cloned().collect::<Vec<_>>() {
2270 for _edge in engine.store().outgoing(&from) {
2271 let is_ro = engine.edge_is_from_readonly(&from);
2272 match engine.capability(from.mem()).unwrap() {
2273 MountCapability::Write => {
2274 assert!(
2275 !is_ro,
2276 "edge from write mem {} reported as ReadOnly",
2277 from.mem()
2278 );
2279 seen_write_edge = true;
2280 }
2281 MountCapability::ReadOnly => {
2282 assert!(
2283 is_ro,
2284 "edge from readonly mem {} reported as Write",
2285 from.mem()
2286 );
2287 seen_readonly_edge = true;
2288 }
2289 }
2290 }
2291 }
2292 assert!(
2293 seen_write_edge,
2294 "fixture must produce at least one edge from a write mem"
2295 );
2296 assert!(
2297 seen_readonly_edge,
2298 "fixture must produce at least one edge from a readonly mem"
2299 );
2300
2301 let phantom = EntityId::new("missing-mem", "phantom");
2304 assert!(
2305 !engine.edge_is_from_readonly(&phantom),
2306 "absent mount must not be reported as ReadOnly"
2307 );
2308 }
2309
2310 #[test]
2313 fn cross_mem_link_allowed_same_mem_always_true() {
2314 let tmp = TempDir::new().unwrap();
2317 let engine = build_demo_engine(&tmp);
2318 assert!(engine.cross_mem_link_allowed("specs", "specs"));
2319 assert!(engine.cross_mem_link_allowed("anywhere", "anywhere"));
2324 }
2325
2326 #[test]
2327 fn cross_mem_link_allowed_absent_denies_by_default() {
2328 let tmp = TempDir::new().unwrap();
2331 let engine = build_demo_engine(&tmp);
2332 assert!(!engine.cross_mem_link_allowed("specs", "engine"));
2333 assert!(!engine.cross_mem_link_allowed("missing", "anywhere"));
2334 }
2335
2336 #[test]
2337 fn cross_mem_link_allowed_wildcard_admits_any_target() {
2338 use memstead_schema::workspace_config::CrossLinkValue;
2339 let tmp = TempDir::new().unwrap();
2340 let mut engine = build_demo_engine(&tmp);
2341 let mut settings = crate::workspace::WorkspaceSettings::default();
2342 settings
2343 .cross_mem_links
2344 .insert("specs".to_string(), CrossLinkValue::Wildcard);
2345 engine.set_settings(settings);
2346 assert!(engine.cross_mem_link_allowed("specs", "engine"));
2347 assert!(engine.cross_mem_link_allowed("specs", "macos"));
2348 assert!(engine.cross_mem_link_allowed("specs", "any-other"));
2349 assert!(!engine.cross_mem_link_allowed("engine", "specs"));
2352 }
2353
2354 #[test]
2355 fn cross_mem_link_allowed_allowlist_enforces_membership() {
2356 use memstead_schema::workspace_config::CrossLinkValue;
2357 let tmp = TempDir::new().unwrap();
2358 let mut engine = build_demo_engine(&tmp);
2359 let mut settings = crate::workspace::WorkspaceSettings::default();
2360 settings.cross_mem_links.insert(
2361 "specs".to_string(),
2362 CrossLinkValue::List(vec!["engine".to_string(), "macos".to_string()]),
2363 );
2364 engine.set_settings(settings);
2365 assert!(engine.cross_mem_link_allowed("specs", "engine"));
2366 assert!(engine.cross_mem_link_allowed("specs", "macos"));
2367 assert!(!engine.cross_mem_link_allowed("specs", "external"));
2368 }
2369
2370 #[test]
2371 fn cross_mem_link_allowed_synthesises_from_matching_create_rule_wildcard() {
2372 use memstead_schema::workspace_config::CrossLinkValue;
2376 let tmp = TempDir::new().unwrap();
2377 let mut engine = build_demo_engine(&tmp);
2378 let mut settings = crate::workspace::WorkspaceSettings::default();
2379 settings
2380 .mem_create_rules
2381 .push(crate::workspace::CreateRuleSetting {
2382 pattern: "exec-*".to_string(),
2383 schemas: vec!["default".to_string()],
2384 default_cross_links: Some(CrossLinkValue::Wildcard),
2385 });
2386 engine.set_settings(settings);
2387 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
2390 assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
2391 assert!(!engine.cross_mem_link_allowed("orphan", "specs"));
2393 }
2394
2395 #[test]
2402 fn cross_mem_link_allowed_synthesises_for_hierarchical_mem() {
2403 use memstead_schema::workspace_config::CrossLinkValue;
2404 let tmp = TempDir::new().unwrap();
2405 let mem_dir = tmp.path().to_path_buf();
2406 let mount = Mount {
2411 mem: "project".into(),
2412 schema: Some(pin("default")),
2413 storage: MountStorage::GitBranch {
2414 gitdir: mem_dir.join(".git"),
2415 branch: "memstead/project".into(),
2416 },
2417 capability: MountCapability::Write,
2418 lifecycle: MountLifecycle::Eager,
2419 cross_linkable: true,
2420 migration_target: None,
2421 };
2422 let writer = FilesystemMemWriter::new(mem_dir.clone());
2423 let mut engine =
2424 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2425 let mut settings = crate::workspace::WorkspaceSettings::default();
2426 settings
2427 .mem_create_rules
2428 .push(crate::workspace::CreateRuleSetting {
2429 pattern: "memstead/*".to_string(),
2430 schemas: vec!["default".to_string()],
2431 default_cross_links: Some(CrossLinkValue::List(vec!["engine".to_string()])),
2432 });
2433 engine.set_settings(settings);
2434 assert!(
2435 engine.cross_mem_link_allowed("project", "engine"),
2436 "synthesis must match via the composed `memstead/project` candidate"
2437 );
2438 assert!(
2439 !engine.cross_mem_link_allowed("project", "macos"),
2440 "a target outside the rule's default_cross_links is still denied"
2441 );
2442 }
2443
2444 #[test]
2445 fn cross_mem_link_allowed_synthesises_from_matching_create_rule_list() {
2446 use memstead_schema::workspace_config::CrossLinkValue;
2449 let tmp = TempDir::new().unwrap();
2450 let mut engine = build_demo_engine(&tmp);
2451 let mut settings = crate::workspace::WorkspaceSettings::default();
2452 settings
2453 .mem_create_rules
2454 .push(crate::workspace::CreateRuleSetting {
2455 pattern: "exec-*".to_string(),
2456 schemas: vec!["default".to_string()],
2457 default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
2458 });
2459 engine.set_settings(settings);
2460 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
2461 assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
2463 }
2464
2465 #[test]
2466 fn cross_mem_link_allowed_explicit_policy_wins_over_synthesis() {
2467 use memstead_schema::workspace_config::CrossLinkValue;
2470 let tmp = TempDir::new().unwrap();
2471 let mut engine = build_demo_engine(&tmp);
2472 let mut settings = crate::workspace::WorkspaceSettings::default();
2473 settings
2474 .cross_mem_links
2475 .insert("exec-foo".to_string(), CrossLinkValue::Wildcard);
2476 engine.set_settings(settings);
2479 assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
2480 }
2481
2482 #[test]
2483 fn cross_mem_link_allowed_synthesis_unions_into_explicit_list() {
2484 use memstead_schema::workspace_config::CrossLinkValue;
2487 let tmp = TempDir::new().unwrap();
2488 let mut engine = build_demo_engine(&tmp);
2489 let mut settings = crate::workspace::WorkspaceSettings::default();
2490 settings.cross_mem_links.insert(
2491 "exec-foo".to_string(),
2492 CrossLinkValue::List(vec!["specs".to_string()]),
2493 );
2494 settings
2495 .mem_create_rules
2496 .push(crate::workspace::CreateRuleSetting {
2497 pattern: "exec-*".to_string(),
2498 schemas: vec!["default".to_string()],
2499 default_cross_links: Some(CrossLinkValue::List(vec!["macos".to_string()])),
2500 });
2501 engine.set_settings(settings);
2502 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
2504 assert!(engine.cross_mem_link_allowed("exec-foo", "macos"));
2506 assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
2508 }
2509
2510 #[test]
2511 fn cross_mem_link_allowed_set_settings_invalidates_compiled_rule_cache() {
2512 use memstead_schema::workspace_config::CrossLinkValue;
2515 let tmp = TempDir::new().unwrap();
2516 let mut engine = build_demo_engine(&tmp);
2517
2518 let mut s1 = crate::workspace::WorkspaceSettings::default();
2520 s1.mem_create_rules
2521 .push(crate::workspace::CreateRuleSetting {
2522 pattern: "exec-*".to_string(),
2523 schemas: vec!["default".to_string()],
2524 default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
2525 });
2526 engine.set_settings(s1);
2527 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
2528
2529 let mut s2 = crate::workspace::WorkspaceSettings::default();
2533 s2.mem_create_rules
2534 .push(crate::workspace::CreateRuleSetting {
2535 pattern: "exec-*".to_string(),
2536 schemas: vec!["default".to_string()],
2537 default_cross_links: None,
2538 });
2539 engine.set_settings(s2);
2540 assert!(!engine.cross_mem_link_allowed("exec-foo", "specs"));
2541 }
2542
2543 #[test]
2544 fn cross_mem_link_allowed_malformed_glob_falls_back_to_explicit_policy() {
2545 use memstead_schema::workspace_config::CrossLinkValue;
2549 let tmp = TempDir::new().unwrap();
2550 let mut engine = build_demo_engine(&tmp);
2551 let mut settings = crate::workspace::WorkspaceSettings::default();
2552 settings
2553 .mem_create_rules
2554 .push(crate::workspace::CreateRuleSetting {
2555 pattern: "[unclosed".to_string(),
2556 schemas: vec!["default".to_string()],
2557 default_cross_links: Some(CrossLinkValue::Wildcard),
2558 });
2559 settings
2561 .cross_mem_links
2562 .insert("specs".to_string(), CrossLinkValue::Wildcard);
2563 engine.set_settings(settings);
2564 assert!(engine.cross_mem_link_allowed("specs", "engine"));
2566 assert!(!engine.cross_mem_link_allowed("orphan", "anything"));
2569 }
2570
2571 #[test]
2572 fn cross_mem_link_allowed_empty_list_denies_all_cross_mem_targets() {
2573 use memstead_schema::workspace_config::CrossLinkValue;
2577 let tmp = TempDir::new().unwrap();
2578 let mut engine = build_demo_engine(&tmp);
2579 let mut settings = crate::workspace::WorkspaceSettings::default();
2580 settings
2581 .cross_mem_links
2582 .insert("specs".to_string(), CrossLinkValue::List(Vec::new()));
2583 engine.set_settings(settings);
2584 assert!(engine.cross_mem_link_allowed("specs", "specs"));
2586 assert!(!engine.cross_mem_link_allowed("specs", "engine"));
2588 assert!(!engine.cross_mem_link_allowed("specs", "anything"));
2589 }
2590
2591 #[test]
2592 fn from_mounts_load_warnings_merge_into_health_summary() {
2593 let tmp = TempDir::new().unwrap();
2594 let mem_dir = tmp.path().to_path_buf();
2595 let body = "---\ntype: spec\n---\n# Dup2\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
2596 std::fs::write(mem_dir.join("dup2.md"), body).unwrap();
2597
2598 let writer = FilesystemMemWriter::new(mem_dir.clone());
2599 let engine = Engine::from_mounts(vec![(
2600 folder_mount("specs", mem_dir),
2601 Box::new(writer) as Box<dyn MemBackend>,
2602 )])
2603 .unwrap();
2604
2605 let summary = engine.health();
2606 assert!(
2607 summary
2608 .warnings
2609 .iter()
2610 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
2611 "health() must merge load_warnings into summary.warnings: {:?}",
2612 summary.warnings,
2613 );
2614 }
2615
2616 #[test]
2617 fn workspace_root_accessor_is_none_for_engine_built_from_mounts() {
2618 let tmp = TempDir::new().unwrap();
2619 let mem_dir = tmp.path().to_path_buf();
2620 let writer = FilesystemMemWriter::new(mem_dir.clone());
2621 let mut mount = folder_mount("specs", mem_dir);
2624 mount.schema = Some("default@1.3.0".parse().unwrap());
2625 let engine =
2626 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2627 assert!(
2628 engine.workspace_root().is_none(),
2629 "from_mounts has no workspace path",
2630 );
2631 assert!(engine.load_warnings().is_empty());
2632 }
2633
2634 #[test]
2635 fn health_omits_outer_repo_warning_when_workspace_root_unset() {
2636 let tmp = TempDir::new().unwrap();
2637 let mem_dir = tmp.path().to_path_buf();
2638 let writer = FilesystemMemWriter::new(mem_dir.clone());
2639 let engine = Engine::from_mounts(vec![(
2640 folder_mount("specs", mem_dir),
2641 Box::new(writer) as Box<dyn MemBackend>,
2642 )])
2643 .unwrap();
2644 let health = engine.health();
2645 assert!(
2646 !health
2647 .warnings
2648 .iter()
2649 .any(|w| matches!(w, WarningHint::OuterRepoNotIgnoringMemRepo { .. })),
2650 "outer-repo check must skip when workspace_root is None",
2651 );
2652 }
2653
2654 #[test]
2655 fn writable_mem_names_filters_by_capability() {
2656 let tmp = TempDir::new().unwrap();
2657 let mem_dir = tmp.path().to_path_buf();
2658 let writer = FilesystemMemWriter::new(mem_dir.clone());
2659 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2660
2661 let engine = Engine::from_mounts(vec![
2662 (
2663 folder_mount("writable", mem_dir),
2664 Box::new(writer) as Box<dyn MemBackend>,
2665 ),
2666 (
2667 archive_mount("sealed", archive_path.clone()),
2668 Box::new(ArchiveBackend::new(archive_path)),
2669 ),
2670 ])
2671 .unwrap();
2672
2673 let names = engine.writable_mem_names();
2676 assert_eq!(names, vec!["writable"]);
2677 }
2678
2679 #[test]
2686 fn default_writable_mem_is_declaration_first_not_alphabetical() {
2687 let tmp = TempDir::new().unwrap();
2688 let test_dir = tmp.path().join("test");
2689 let other_dir = tmp.path().join("other");
2690 std::fs::create_dir_all(&test_dir).unwrap();
2691 std::fs::create_dir_all(&other_dir).unwrap();
2692
2693 let engine = Engine::from_mounts(vec![
2694 (
2695 folder_mount("test", test_dir.clone()),
2696 Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
2697 ),
2698 (
2699 folder_mount("other", other_dir.clone()),
2700 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
2701 ),
2702 ])
2703 .unwrap();
2704
2705 assert_eq!(
2706 engine.default_writable_mem(),
2707 Some("test"),
2708 "default must be the declaration-first writable mem, not the alphabetically-first",
2709 );
2710 }
2711
2712 #[test]
2717 fn default_writable_mem_follows_declaration_order() {
2718 let tmp = TempDir::new().unwrap();
2719 let other_dir = tmp.path().join("other");
2720 let test_dir = tmp.path().join("test");
2721 std::fs::create_dir_all(&other_dir).unwrap();
2722 std::fs::create_dir_all(&test_dir).unwrap();
2723
2724 let engine = Engine::from_mounts(vec![
2725 (
2726 folder_mount("other", other_dir.clone()),
2727 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
2728 ),
2729 (
2730 folder_mount("test", test_dir.clone()),
2731 Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
2732 ),
2733 ])
2734 .unwrap();
2735
2736 assert_eq!(engine.default_writable_mem(), Some("other"));
2737 }
2738
2739 #[test]
2741 fn default_writable_mem_none_without_writable_mount() {
2742 let tmp = TempDir::new().unwrap();
2743 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2744 let engine = Engine::from_mounts(vec![(
2745 archive_mount("sealed", archive_path.clone()),
2746 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2747 )])
2748 .unwrap();
2749 assert_eq!(engine.default_writable_mem(), None);
2750 }
2751
2752 #[test]
2753 fn folder_path_for_mem_returns_path_for_folder_mounts_only() {
2754 let tmp = TempDir::new().unwrap();
2755 let mem_dir = tmp.path().join("specs");
2756 std::fs::create_dir_all(&mem_dir).unwrap();
2757 let writer = FilesystemMemWriter::new(mem_dir.clone());
2758 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2759
2760 let engine = Engine::from_mounts(vec![
2761 (
2762 folder_mount("specs", mem_dir.clone()),
2763 Box::new(writer) as Box<dyn MemBackend>,
2764 ),
2765 (
2766 archive_mount("sealed", archive_path.clone()),
2767 Box::new(ArchiveBackend::new(archive_path)),
2768 ),
2769 ])
2770 .unwrap();
2771
2772 assert_eq!(engine.folder_path_for_mem("specs"), Some(mem_dir.as_path()),);
2774 assert_eq!(engine.folder_path_for_mem("sealed"), None);
2776 assert_eq!(engine.folder_path_for_mem("missing"), None);
2778 }
2779
2780 #[test]
2781 fn mount_accessor_returns_public_mount_shape() {
2782 let tmp = TempDir::new().unwrap();
2789 let mem_dir = tmp.path().to_path_buf();
2790 let writer = FilesystemMemWriter::new(mem_dir.clone());
2791 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2792
2793 let engine = Engine::from_mounts(vec![
2794 (
2795 folder_mount("writable", mem_dir.clone()),
2796 Box::new(writer) as Box<dyn MemBackend>,
2797 ),
2798 (
2799 archive_mount("sealed", archive_path.clone()),
2800 Box::new(ArchiveBackend::new(archive_path.clone())),
2801 ),
2802 ])
2803 .unwrap();
2804
2805 let folder = engine.mount("writable").expect("known mem");
2808 assert!(matches!(folder.storage, MountStorage::Folder { .. }));
2809 assert_eq!(folder.capability, MountCapability::Write);
2810
2811 let archive = engine.mount("sealed").expect("known mem");
2812 match &archive.storage {
2813 MountStorage::Archive { path } => assert_eq!(path, &archive_path),
2814 other => panic!("expected Archive storage, got {other:?}"),
2815 }
2816 assert_eq!(archive.capability, MountCapability::ReadOnly);
2817
2818 assert!(engine.mount("missing").is_none());
2820
2821 let mounts = engine.mounts();
2823 assert_eq!(mounts.len(), 2);
2824 assert_eq!(mounts[0].mem, "writable");
2825 assert_eq!(mounts[1].mem, "sealed");
2826 }
2827
2828 #[test]
2829 fn mem_router_writable_set_matches_writable_mount_capability() {
2830 let tmp = TempDir::new().unwrap();
2834 let mem_dir = tmp.path().join("specs");
2835 std::fs::create_dir_all(&mem_dir).unwrap();
2836 let writer = FilesystemMemWriter::new(mem_dir.clone());
2837 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2838
2839 let engine = Engine::from_mounts(vec![
2840 (
2841 folder_mount("specs", mem_dir.clone()),
2842 Box::new(writer) as Box<dyn MemBackend>,
2843 ),
2844 (
2845 archive_mount("ext", archive_path.clone()),
2846 Box::new(ArchiveBackend::new(archive_path)),
2847 ),
2848 ])
2849 .unwrap();
2850
2851 let router = engine.mem_router();
2852 assert!(router.is_writable("specs"));
2853 assert!(!router.is_writable("ext"));
2854 assert!(router.is_visible("specs"));
2855 assert!(router.is_visible("ext"));
2856 let writable: std::collections::HashSet<&String> = router.writable_mems().iter().collect();
2857 assert_eq!(writable.len(), 1);
2858 assert!(writable.contains(&"specs".to_string()));
2859 }
2860
2861 #[test]
2862 fn mem_router_origin_is_explicit_toml_for_workspace_mounts() {
2863 let tmp = TempDir::new().unwrap();
2869 let mem_dir = tmp.path().join("specs");
2870 std::fs::create_dir_all(&mem_dir).unwrap();
2871 let writer = FilesystemMemWriter::new(mem_dir.clone());
2872
2873 let engine = Engine::from_mounts(vec![(
2874 folder_mount("specs", mem_dir),
2875 Box::new(writer) as Box<dyn MemBackend>,
2876 )])
2877 .unwrap();
2878
2879 let origin = engine
2880 .mem_router()
2881 .origin_for_mem("specs")
2882 .expect("known mem");
2883 assert_eq!(origin.kind(), "explicit");
2884 }
2885
2886 #[test]
2887 fn mem_router_dir_for_writable_folder_mount_matches_storage_path() {
2888 let tmp = TempDir::new().unwrap();
2892 let mem_dir = tmp.path().join("specs");
2893 std::fs::create_dir_all(&mem_dir).unwrap();
2894 let writer = FilesystemMemWriter::new(mem_dir.clone());
2895
2896 let engine = Engine::from_mounts(vec![(
2897 folder_mount("specs", mem_dir.clone()),
2898 Box::new(writer) as Box<dyn MemBackend>,
2899 )])
2900 .unwrap();
2901
2902 assert_eq!(
2903 engine.mem_router().dir_for_mem("specs"),
2904 Some(mem_dir.as_path()),
2905 );
2906 assert_eq!(engine.mem_router().dir_for_mem("unknown"), None);
2907 }
2908
2909 #[test]
2910 fn mem_router_archive_path_for_read_only_archive_mount() {
2911 let tmp = TempDir::new().unwrap();
2915 let mem_dir = tmp.path().join("specs");
2916 std::fs::create_dir_all(&mem_dir).unwrap();
2917 let writer = FilesystemMemWriter::new(mem_dir.clone());
2918 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2919
2920 let engine = Engine::from_mounts(vec![
2921 (
2922 folder_mount("specs", mem_dir),
2923 Box::new(writer) as Box<dyn MemBackend>,
2924 ),
2925 (
2926 archive_mount("ext", archive_path.clone()),
2927 Box::new(ArchiveBackend::new(archive_path.clone())),
2928 ),
2929 ])
2930 .unwrap();
2931
2932 let router = engine.mem_router();
2933 assert_eq!(
2934 router.archive_path_for_mem("ext"),
2935 Some(archive_path.as_path()),
2936 );
2937 assert_eq!(router.archive_path_for_mem("specs"), None);
2939 }
2940
2941 #[test]
2942 fn read_mem_config_via_backend_trait_folder_reads_bytes() {
2943 let tmp = TempDir::new().unwrap();
2947 let mem_dir = tmp.path().to_path_buf();
2948 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2949 let body = br#"{
2950 "format": 1,
2951 "schema": "default@1.0.0",
2952 "writeGuidance": { "tone": "neutral" }
2953 }"#;
2954 std::fs::write(mem_dir.join(".memstead").join("config.json"), body).unwrap();
2955
2956 let writer = FilesystemMemWriter::new(mem_dir);
2957 let result = MemBackend::read_mem_config(&writer).unwrap();
2958 let bytes = result.expect("config bytes must surface");
2959 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2960 assert_eq!(parsed["schema"], "default@1.0.0");
2961 }
2962
2963 #[test]
2964 fn read_mem_config_via_backend_trait_folder_missing_returns_none() {
2965 let tmp = TempDir::new().unwrap();
2966 let mem_dir = tmp.path().to_path_buf();
2967 let writer = FilesystemMemWriter::new(mem_dir);
2968 let result = MemBackend::read_mem_config(&writer).unwrap();
2969 assert!(result.is_none());
2970 }
2971
2972 #[test]
2973 fn read_mem_config_via_backend_trait_archive_reads_bytes() {
2974 let tmp = TempDir::new().unwrap();
2977 let archive_path = tmp.path().join("seed.mem");
2978 let body = br#"{
2979 "format": 1,
2980 "schema": "default@1.0.0",
2981 "writeGuidance": { "tone": "archive" }
2982 }"#;
2983 {
2984 let file = std::fs::File::create(&archive_path).unwrap();
2985 let mut writer = zip::ZipWriter::new(file);
2986 writer
2987 .start_file(
2988 ".memstead/config.json",
2989 zip::write::SimpleFileOptions::default(),
2990 )
2991 .unwrap();
2992 use std::io::Write;
2993 writer.write_all(body).unwrap();
2994 writer.finish().unwrap();
2995 }
2996
2997 let backend = ArchiveBackend::new(archive_path);
2998 let result = MemBackend::read_mem_config(&backend).unwrap();
2999 let bytes = result.expect("config bytes must surface");
3000 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3001 assert_eq!(parsed["writeGuidance"]["tone"], "archive");
3002 }
3003
3004 #[test]
3005 fn mem_config_for_returns_none_when_no_config_file_present() {
3006 let tmp = TempDir::new().unwrap();
3009 let mem_dir = tmp.path().to_path_buf();
3010 let writer = FilesystemMemWriter::new(mem_dir.clone());
3011 let engine = Engine::from_mounts(vec![(
3012 folder_mount("specs", mem_dir),
3013 Box::new(writer) as Box<dyn MemBackend>,
3014 )])
3015 .unwrap();
3016 assert!(engine.mem_config_for("specs").is_none());
3017 }
3018
3019 #[test]
3020 fn mem_config_for_returns_some_when_config_file_present() {
3021 let tmp = TempDir::new().unwrap();
3026 let mem_dir = tmp.path().to_path_buf();
3027 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3028 let config_body = r#"{
3029 "format": 1,
3030 "schema": "default@1.0.0",
3031 "writeGuidance": {
3032 "tone": "neutral",
3033 "voice": "active"
3034 }
3035 }"#;
3036 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3037
3038 let writer = FilesystemMemWriter::new(mem_dir.clone());
3039 let engine = Engine::from_mounts(vec![(
3040 folder_mount("specs", mem_dir),
3041 Box::new(writer) as Box<dyn MemBackend>,
3042 )])
3043 .unwrap();
3044
3045 let cfg = engine
3046 .mem_config_for("specs")
3047 .expect("mem_config should load");
3048 assert_eq!(cfg.write_guidance.len(), 2);
3049 assert_eq!(
3050 cfg.write_guidance.get("tone").and_then(|v| v.as_str()),
3051 Some("neutral"),
3052 );
3053 assert_eq!(
3054 cfg.write_guidance.get("voice").and_then(|v| v.as_str()),
3055 Some("active"),
3056 );
3057 }
3058
3059 #[test]
3060 fn mem_config_for_unknown_mem_returns_none() {
3061 let tmp = TempDir::new().unwrap();
3063 let mem_dir = tmp.path().to_path_buf();
3064 let writer = FilesystemMemWriter::new(mem_dir.clone());
3065 let engine = Engine::from_mounts(vec![(
3066 folder_mount("specs", mem_dir),
3067 Box::new(writer) as Box<dyn MemBackend>,
3068 )])
3069 .unwrap();
3070 assert!(engine.mem_config_for("missing").is_none());
3071 }
3072
3073 #[test]
3074 fn mem_config_for_archive_mount_returns_none() {
3075 let tmp = TempDir::new().unwrap();
3078 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3079 let engine = Engine::from_mounts(vec![(
3080 archive_mount("ext", archive_path.clone()),
3081 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3082 )])
3083 .unwrap();
3084 assert!(engine.mem_config_for("ext").is_none());
3085 }
3086
3087 #[test]
3088 fn mem_configs_named_iterates_only_mounts_with_config() {
3089 let tmp = TempDir::new().unwrap();
3094 let with_config = tmp.path().join("specs");
3095 let without_config = tmp.path().join("memos");
3096 std::fs::create_dir_all(with_config.join(".memstead")).unwrap();
3097 std::fs::create_dir_all(&without_config).unwrap();
3098 let config_body = r#"{
3099 "format": 1,
3100 "schema": "default@1.0.0",
3101 "writeGuidance": { "tone": "neutral" }
3102 }"#;
3103 std::fs::write(
3104 with_config.join(".memstead").join("config.json"),
3105 config_body,
3106 )
3107 .unwrap();
3108
3109 let engine = Engine::from_mounts(vec![
3110 (
3111 folder_mount("specs", with_config.clone()),
3112 Box::new(FilesystemMemWriter::new(with_config)) as Box<dyn MemBackend>,
3113 ),
3114 (
3115 folder_mount("memos", without_config.clone()),
3116 Box::new(FilesystemMemWriter::new(without_config)) as Box<dyn MemBackend>,
3117 ),
3118 ])
3119 .unwrap();
3120
3121 let yielded: Vec<(&str, usize)> = engine
3122 .mem_configs_named()
3123 .map(|(name, cfg)| (name, cfg.write_guidance.len()))
3124 .collect();
3125 assert_eq!(yielded, vec![("specs", 1)]);
3126 }
3127
3128 #[test]
3129 fn schema_for_returns_some_for_known_mem_and_none_for_unknown() {
3130 let tmp = TempDir::new().unwrap();
3134 let mem_dir = tmp.path().to_path_buf();
3135 let writer = FilesystemMemWriter::new(mem_dir.clone());
3136 let engine = Engine::from_mounts(vec![(
3137 folder_mount("specs", mem_dir),
3138 Box::new(writer) as Box<dyn MemBackend>,
3139 )])
3140 .unwrap();
3141 assert!(engine.schema_for("specs").is_some());
3142 assert!(engine.schema_for("missing").is_none());
3143 }
3144
3145 #[test]
3146 fn gitdir_for_unknown_mem_returns_unknown_mem() {
3147 let tmp = TempDir::new().unwrap();
3148 let mem_dir = tmp.path().to_path_buf();
3149 let writer = FilesystemMemWriter::new(mem_dir.clone());
3150 let engine = Engine::from_mounts(vec![(
3151 folder_mount("specs", mem_dir),
3152 Box::new(writer) as Box<dyn MemBackend>,
3153 )])
3154 .unwrap();
3155 let err = engine.gitdir_for("missing").unwrap_err();
3156 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3157 }
3158
3159 #[test]
3160 fn gitdir_for_folder_mount_returns_no_gitdir_error() {
3161 let tmp = TempDir::new().unwrap();
3164 let mem_dir = tmp.path().to_path_buf();
3165 let writer = FilesystemMemWriter::new(mem_dir.clone());
3166 let engine = Engine::from_mounts(vec![(
3167 folder_mount("specs", mem_dir),
3168 Box::new(writer) as Box<dyn MemBackend>,
3169 )])
3170 .unwrap();
3171 let err = engine.gitdir_for("specs").unwrap_err();
3172 match err {
3173 EngineError::Mem(msg) => assert!(msg.contains("no resolved gitdir")),
3174 other => panic!("expected EngineError::Mem, got {other:?}"),
3175 }
3176 }
3177
3178 #[test]
3179 fn worktree_for_folder_mount_returns_storage_path() {
3180 let tmp = TempDir::new().unwrap();
3181 let mem_dir = tmp.path().to_path_buf();
3182 let writer = FilesystemMemWriter::new(mem_dir.clone());
3183 let engine = Engine::from_mounts(vec![(
3184 folder_mount("specs", mem_dir.clone()),
3185 Box::new(writer) as Box<dyn MemBackend>,
3186 )])
3187 .unwrap();
3188 let worktree = engine.worktree_for("specs").unwrap();
3189 assert_eq!(worktree, mem_dir);
3190 }
3191
3192 #[test]
3193 fn worktree_for_unknown_mem_returns_unknown_mem() {
3194 let tmp = TempDir::new().unwrap();
3195 let mem_dir = tmp.path().to_path_buf();
3196 let writer = FilesystemMemWriter::new(mem_dir.clone());
3197 let engine = Engine::from_mounts(vec![(
3198 folder_mount("specs", mem_dir),
3199 Box::new(writer) as Box<dyn MemBackend>,
3200 )])
3201 .unwrap();
3202 let err = engine.worktree_for("missing").unwrap_err();
3203 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3204 }
3205
3206 #[test]
3207 fn worktree_for_archive_mount_returns_archive_backed_error() {
3208 let tmp = TempDir::new().unwrap();
3209 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3210 let engine = Engine::from_mounts(vec![(
3211 archive_mount("ext", archive_path.clone()),
3212 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3213 )])
3214 .unwrap();
3215 let err = engine.worktree_for("ext").unwrap_err();
3216 match err {
3217 EngineError::Mem(msg) => assert!(msg.contains("archive-backed")),
3218 other => panic!("expected EngineError::Mem, got {other:?}"),
3219 }
3220 }
3221
3222 #[test]
3223 fn mem_head_sha_for_folder_mount_is_none() {
3224 let tmp = TempDir::new().unwrap();
3227 let mem_dir = tmp.path().to_path_buf();
3228 let writer = FilesystemMemWriter::new(mem_dir.clone());
3229 let engine = Engine::from_mounts(vec![(
3230 folder_mount("specs", mem_dir),
3231 Box::new(writer) as Box<dyn MemBackend>,
3232 )])
3233 .unwrap();
3234 let head = engine.mem_head_sha("specs").unwrap();
3235 assert_eq!(head, None);
3236 }
3237
3238 #[test]
3239 fn mem_head_sha_unknown_mem_returns_unknown_mem() {
3240 let tmp = TempDir::new().unwrap();
3241 let mem_dir = tmp.path().to_path_buf();
3242 let writer = FilesystemMemWriter::new(mem_dir.clone());
3243 let engine = Engine::from_mounts(vec![(
3244 folder_mount("specs", mem_dir),
3245 Box::new(writer) as Box<dyn MemBackend>,
3246 )])
3247 .unwrap();
3248 let err = engine.mem_head_sha("missing").unwrap_err();
3249 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3250 }
3251
3252 #[test]
3253 fn capability_surfaces_per_mount() {
3254 let tmp = TempDir::new().unwrap();
3255 let mem_dir = tmp.path().to_path_buf();
3256 let writer = FilesystemMemWriter::new(mem_dir.clone());
3257 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3258
3259 let engine = Engine::from_mounts(vec![
3260 (
3261 folder_mount("writable", mem_dir),
3262 Box::new(writer) as Box<dyn MemBackend>,
3263 ),
3264 (
3265 archive_mount("read-only", archive_path.clone()),
3266 Box::new(ArchiveBackend::new(archive_path)),
3267 ),
3268 ])
3269 .unwrap();
3270
3271 assert_eq!(
3272 engine.capability("writable").unwrap(),
3273 MountCapability::Write
3274 );
3275 assert_eq!(
3276 engine.capability("read-only").unwrap(),
3277 MountCapability::ReadOnly
3278 );
3279 assert!(matches!(
3280 engine.capability("missing"),
3281 Err(EngineError::UnknownMem(_))
3282 ));
3283 }
3284
3285 #[test]
3286 fn read_provenance_routes_through_backend() {
3287 let tmp = TempDir::new().unwrap();
3288 let mem_dir = tmp.path().to_path_buf();
3289 let writer = FilesystemMemWriter::new(mem_dir.clone());
3290
3291 let backend_handle: &dyn MemBackend = &writer;
3294 backend_handle
3295 .append_provenance(&Provenance::new(
3296 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
3297 crate::ProvenanceKind::Create,
3298 Some("v:e".into()),
3299 crate::vcs::Actor::Cli,
3300 None,
3301 Some("first".into()),
3302 ))
3303 .unwrap();
3304
3305 let engine = Engine::from_mounts(vec![(
3306 folder_mount("specs", mem_dir),
3307 Box::new(writer) as Box<dyn MemBackend>,
3308 )])
3309 .unwrap();
3310
3311 let records = engine.read_provenance("specs", None).unwrap();
3312 assert_eq!(records.len(), 1);
3313 assert_eq!(records[0].kind, crate::ProvenanceKind::Create);
3314 assert_eq!(records[0].entity.as_deref(), Some("v:e"));
3315 assert_eq!(records[0].note.as_deref(), Some("first"));
3316 }
3317
3318 #[test]
3319 fn archive_mount_returns_sealed_indirectly_through_backend_layer() {
3320 let tmp = TempDir::new().unwrap();
3327 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3328 let backend = ArchiveBackend::new(archive_path);
3329 match MemBackend::write_entity(&backend, Path::new("x.md"), b"x") {
3330 Err(BackendError::Sealed) => {}
3331 other => panic!("expected Sealed, got {other:?}"),
3332 }
3333 }
3334
3335 fn build_demo_engine(tmp: &TempDir) -> Engine {
3345 let mem_dir = tmp.path().to_path_buf();
3346 let writer = FilesystemMemWriter::new(mem_dir.clone());
3347 let mut engine = Engine::from_mounts(vec![(
3348 folder_mount("specs", mem_dir),
3349 Box::new(writer) as Box<dyn MemBackend>,
3350 )])
3351 .unwrap();
3352 let (actor, client) = cli_actor();
3353 let source = engine
3354 .create_entity(
3355 empty_create_args("specs", "Source One"),
3356 actor,
3357 Some(&client),
3358 None,
3359 )
3360 .unwrap();
3361 let target = engine
3362 .create_entity(
3363 empty_create_args("specs", "Target Two"),
3364 actor,
3365 Some(&client),
3366 None,
3367 )
3368 .unwrap();
3369 engine
3370 .create_entity(
3371 empty_create_args("specs", "Lonely Three"),
3372 actor,
3373 Some(&client),
3374 None,
3375 )
3376 .unwrap();
3377 engine
3378 .relate_entity(
3379 RelateEntityArgs {
3380 source: source.id.clone(),
3381 expected_hash: Some(source.content_hash.clone()),
3382 rel_type: "USES".to_string(),
3383 target: target.id.clone(),
3384 remove: false,
3385 description: None,
3386 dry_run: false,
3387 },
3388 actor,
3389 Some(&client),
3390 None,
3391 )
3392 .unwrap();
3393 engine
3394 }
3395
3396 #[test]
3397 fn status_reports_per_engine_counts() {
3398 let tmp = TempDir::new().unwrap();
3399 let engine = build_demo_engine(&tmp);
3400 let stats = engine.status();
3401 assert_eq!(stats.entity_count, 3);
3402 assert_eq!(stats.edge_count, 1);
3403 assert_eq!(stats.mem_count, 1);
3404 assert_eq!(stats.types_in_use, vec!["spec".to_string()]);
3405 assert_eq!(stats.edge_types.get("USES"), Some(&1));
3406 }
3407
3408 #[test]
3409 fn orphans_lists_unconnected_real_entities() {
3410 let tmp = TempDir::new().unwrap();
3411 let engine = build_demo_engine(&tmp);
3412 let orphans = engine.orphans();
3413 assert_eq!(orphans.len(), 1);
3414 assert_eq!(orphans[0].as_ref(), "specs--lonely-three");
3415 }
3416
3417 #[test]
3422 fn schema_breakdowns_attribute_to_mem_pin() {
3423 let tmp = TempDir::new().unwrap();
3424 let engine = build_demo_engine(&tmp);
3425
3426 let orphans = engine.orphans();
3427 let orphans_by_schema = engine.orphans_by_schema(&orphans);
3428 assert_eq!(
3429 orphans_by_schema.values().sum::<usize>(),
3430 orphans.len(),
3431 "per-schema orphan counts must sum to the raw total"
3432 );
3433 assert_eq!(orphans_by_schema.len(), 1, "one mem ⇒ one schema bucket");
3434 let (schema, count) = orphans_by_schema.iter().next().unwrap();
3435 assert!(!schema.is_empty(), "specs mem is pinned: {schema:?}");
3436 assert_eq!(*count, 1);
3437
3438 let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
3441 let communities_by_schema = engine.communities_by_schema(&mems);
3442 assert_eq!(communities_by_schema.len(), 1);
3443 assert_eq!(
3444 communities_by_schema.values().sum::<usize>(),
3445 engine.communities().count,
3446 );
3447 }
3448
3449 #[test]
3450 fn stubs_lists_unresolved_link_targets() {
3451 let tmp = TempDir::new().unwrap();
3452 let mem_dir = tmp.path().to_path_buf();
3453 let writer = FilesystemMemWriter::new(mem_dir.clone());
3454 let mut engine = Engine::from_mounts(vec![(
3455 folder_mount("specs", mem_dir),
3456 Box::new(writer) as Box<dyn MemBackend>,
3457 )])
3458 .unwrap();
3459 let (actor, client) = cli_actor();
3460 let source = engine
3461 .create_entity(
3462 empty_create_args("specs", "Holder"),
3463 actor,
3464 Some(&client),
3465 None,
3466 )
3467 .unwrap();
3468 engine
3471 .relate_entity(
3472 RelateEntityArgs {
3473 source: source.id.clone(),
3474 expected_hash: Some(source.content_hash.clone()),
3475 rel_type: "USES".to_string(),
3476 target: EntityId::new("specs", "ghost"),
3477 remove: false,
3478 description: None,
3479 dry_run: false,
3480 },
3481 actor,
3482 Some(&client),
3483 None,
3484 )
3485 .unwrap();
3486 let stubs = engine.stubs();
3487 assert!(
3488 stubs.iter().any(|(id, _)| id.as_ref() == "specs--ghost"),
3489 "expected ghost stub: {stubs:?}"
3490 );
3491 }
3492
3493 #[test]
3494 fn most_connected_orders_by_degree() {
3495 let tmp = TempDir::new().unwrap();
3496 let engine = build_demo_engine(&tmp);
3497 let top = engine.most_connected(5);
3498 assert_eq!(top.len(), 3);
3499 let zero_degree: Vec<_> = top
3501 .iter()
3502 .filter(|c| c.total == 0)
3503 .map(|c| c.id.as_ref().to_string())
3504 .collect();
3505 assert_eq!(zero_degree, vec!["specs--lonely-three".to_string()]);
3506 }
3507
3508 #[test]
3509 fn health_returns_per_engine_summary() {
3510 let tmp = TempDir::new().unwrap();
3511 let engine = build_demo_engine(&tmp);
3512 let health = engine.health();
3513 assert!(
3521 health
3522 .missing_fields
3523 .iter()
3524 .all(|r| r.id.as_ref() != "specs--source-one"),
3525 "post-strict-create fixture must not surface as missing-fields; got {:?}",
3526 health.missing_fields,
3527 );
3528 }
3529
3530 #[test]
3531 fn context_carries_neighbors_and_community() {
3532 let tmp = TempDir::new().unwrap();
3533 let engine = build_demo_engine(&tmp);
3534 let source_id = EntityId::new("specs", "source-one");
3535 let ctx = engine.context(&source_id).unwrap();
3536 assert_eq!(ctx.entity_id, source_id);
3537 assert_eq!(ctx.neighbors.len(), 1);
3538 assert_eq!(ctx.neighbors[0].relationship, "USES");
3539 assert!(matches!(ctx.neighbors[0].direction, Direction::Outgoing));
3540 }
3541
3542 #[test]
3543 fn communities_caches_louvain_until_invalidated() {
3544 let tmp = TempDir::new().unwrap();
3545 let mut engine = build_demo_engine(&tmp);
3546 let entities_before = engine.communities().entity_cluster_map.len();
3548 assert_eq!(
3550 engine.communities().entity_cluster_map.len(),
3551 entities_before
3552 );
3553 let (actor, client) = cli_actor();
3556 engine
3557 .create_entity(
3558 empty_create_args("specs", "Disturber"),
3559 actor,
3560 Some(&client),
3561 None,
3562 )
3563 .unwrap();
3564 let entities_after = engine.communities().entity_cluster_map.len();
3565 assert_eq!(
3566 entities_after,
3567 entities_before + 1,
3568 "create_entity should have invalidated community cache and added the new entity"
3569 );
3570 }
3571
3572 #[test]
3573 fn list_filters_by_metadata_only() {
3574 let tmp = TempDir::new().unwrap();
3575 let engine = build_demo_engine(&tmp);
3576 let scope = SearchScope {
3577 entity_type: Some("spec".to_string()),
3578 ..Default::default()
3579 };
3580 let result = engine.list(&scope);
3581 assert_eq!(result.hits.len(), 3);
3583 }
3584
3585 #[test]
3586 fn list_applies_schema_declared_filter_on_non_default_schema_mem() {
3587 let tmp = TempDir::new().unwrap();
3594 let mem_dir = tmp.path().to_path_buf();
3595 let writer = FilesystemMemWriter::new(mem_dir.clone());
3596 let mount = Mount {
3597 mem: "planning".to_string(),
3598 schema: Some(memstead_schema::SchemaRef::new(
3599 "planning",
3600 semver::Version::new(0, 1, 0),
3601 )),
3602 storage: MountStorage::Folder { path: mem_dir },
3603 capability: MountCapability::Write,
3604 lifecycle: MountLifecycle::Eager,
3605 cross_linkable: true,
3606 migration_target: None,
3607 };
3608 let mut engine =
3609 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3610 let (actor, client) = cli_actor();
3611
3612 for (title, status) in &[("Skip Postgres", "accepted"), ("Use SQLite", "proposed")] {
3617 let mut metadata = indexmap::IndexMap::new();
3618 metadata.insert("status".to_string(), status.to_string());
3619 metadata.insert("deciders".to_string(), "alice".to_string());
3620 metadata.insert("decided_on".to_string(), "2026-05-19".to_string());
3621 let args = crate::engine::CreateEntityArgs {
3622 anchors: Vec::new(),
3623 mem: "planning".to_string(),
3624 title: title.to_string(),
3625 entity_type: "decision".to_string(),
3626 sections: indexmap::IndexMap::from_iter([
3627 ("decision".to_string(), "We chose this.".to_string()),
3628 ("context".to_string(), "Single-user dev.".to_string()),
3629 ("consequences".to_string(), "Lose multi-writer.".to_string()),
3630 ]),
3631 metadata,
3632 relations: Vec::new(),
3633 dry_run: false,
3634 };
3635 engine
3636 .create_entity(args, actor, Some(&client), None)
3637 .unwrap();
3638 }
3639
3640 let scope = SearchScope {
3642 entity_type: Some("decision".to_string()),
3643 filters: std::collections::HashMap::from([(
3644 "status".to_string(),
3645 "accepted".to_string(),
3646 )]),
3647 ..Default::default()
3648 };
3649 let result = engine.list(&scope);
3650 assert_eq!(
3651 result.hits.len(),
3652 1,
3653 "filter on schema-declared field must select only matching entities"
3654 );
3655 assert_eq!(result.hits[0].title, "Skip Postgres");
3656 assert!(
3657 result.warnings.is_empty(),
3658 "no warning should fire when the filter is declared by the mem's pinned schema: {:?}",
3659 result.warnings
3660 );
3661 }
3662
3663 #[test]
3664 fn search_returns_results_against_built_index() {
3665 let tmp = TempDir::new().unwrap();
3666 let engine = build_demo_engine(&tmp);
3667 let scope = SearchScope {
3668 query: Some(crate::ops::Query {
3669 any: vec!["source".to_string()],
3670 ..Default::default()
3671 }),
3672 ..Default::default()
3673 };
3674 let result = engine.search(&scope).expect("native search returns Ok");
3675 assert!(result.total >= 1, "expected ≥1 hit for source: {result:?}");
3676 assert!(
3677 result
3678 .hits
3679 .iter()
3680 .any(|h| h.id.as_ref() == "specs--source-one"),
3681 "expected source-one in hits: {result:?}"
3682 );
3683 }
3684
3685 }