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::PipelineConfigs {
191 &self.pipeline_configs
192 }
193
194 pub fn pipeline_configs_json(&self) -> String {
208 let empty = || "{\"mediums\":[],\"facets\":[],\"bindings\":[]}".to_string();
209 let Some(root) = self.workspace_root() else {
210 return empty();
211 };
212 match crate::pipeline_store::load_pipeline_configs(root) {
213 Ok(configs) => serde_json::to_string(&configs).unwrap_or_else(|_| empty()),
214 Err(_) => empty(),
215 }
216 }
217
218 pub fn set_pipeline_configs(&mut self, configs: crate::pipeline_store::PipelineConfigs) {
223 self.pipeline_configs = configs;
224 }
225
226 pub fn note_missing_warning(&self, tool: &str, note: Option<&str>) -> Option<WarningHint> {
243 if !self.settings.mutations.require_notes.unwrap_or(false) {
244 return None;
245 }
246 let has_note = note.map(|n| !n.trim().is_empty()).unwrap_or(false);
247 if has_note {
248 return None;
249 }
250 Some(WarningHint::NoteMissing {
251 tool: tool.to_string(),
252 })
253 }
254
255 pub fn backend_factory(&self) -> BackendFactory {
261 self.backend_factory
262 }
263
264 pub fn git_branch_ops(&self) -> Option<super::GitBranchOps> {
271 self.git_branch_ops
272 }
273
274 pub fn get_entity(&self, id: &EntityId) -> Option<&Entity> {
279 self.store.get(id)
280 }
281
282 pub fn entity_anchors(&self, id: &EntityId) -> Vec<crate::anchor::Anchor> {
291 let Some(mount) = self.mounts.iter().find(|m| m.mount.mem == id.mem()) else {
292 return Vec::new();
293 };
294 let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
295 return Vec::new();
296 };
297 match crate::anchor::AnchorSidecar::from_bytes(&bytes) {
298 Ok(sc) => sc.get(id.as_ref()).to_vec(),
299 Err(_) => Vec::new(),
300 }
301 }
302
303 pub fn entity_anchors_resolved(&self, id: &EntityId) -> Vec<ResolvedAnchor> {
328 let anchors = self.entity_anchors(id);
329 let root = self.single_path_medium_root(id.mem());
330 anchors
331 .into_iter()
332 .map(|anchor| {
333 let state = root
334 .as_deref()
335 .and_then(|r| observe_path_anchor(r, &anchor));
336 ResolvedAnchor { anchor, state }
337 })
338 .collect()
339 }
340
341 fn single_path_medium_root(&self, mem: &str) -> Option<PathBuf> {
349 let workspace_root = self.workspace_root.as_deref()?;
350 let mut mediums = self
351 .pipeline_configs()
352 .mediums
353 .iter()
354 .filter(|r| r.mem == mem);
355 let first = mediums.next()?;
356 if mediums.next().is_some() {
357 return None; }
359 let caps = crate::binding::medium_capabilities(first.config.medium_type);
360 if caps.anchor_namespace != "path" {
361 return None; }
363 let pointer = first.config.pointer.trim();
364 Some(if pointer.is_empty() {
365 workspace_root.to_path_buf()
366 } else {
367 workspace_root.join(pointer)
368 })
369 }
370
371 pub fn anchors_referencing_artifact(
380 &self,
381 artifact_path: &str,
382 ) -> Vec<(EntityId, crate::anchor::Anchor)> {
383 let mut out = Vec::new();
384 for mount in &self.mounts {
385 let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
386 continue;
387 };
388 let Ok(sc) = crate::anchor::AnchorSidecar::from_bytes(&bytes) else {
389 continue;
390 };
391 for (eid, anchors) in &sc.entities {
392 for a in anchors {
393 if anchor_references_path(a, artifact_path) {
394 out.push((EntityId(eid.clone()), a.clone()));
395 }
396 }
397 }
398 }
399 out
400 }
401
402 pub fn mem_anchors_resolved(&self, mem: &str) -> Vec<(EntityId, ResolvedAnchor)> {
414 let Some(mount) = self.mounts.iter().find(|m| m.mount.mem == mem) else {
415 return Vec::new();
416 };
417 let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
418 return Vec::new();
419 };
420 let Ok(sc) = crate::anchor::AnchorSidecar::from_bytes(&bytes) else {
421 return Vec::new();
422 };
423 let root = self.single_path_medium_root(mem);
424 let mut out = Vec::new();
425 for (eid, anchors) in &sc.entities {
426 for anchor in anchors {
427 let state = root.as_deref().and_then(|r| observe_path_anchor(r, anchor));
428 out.push((
429 EntityId(eid.clone()),
430 ResolvedAnchor {
431 anchor: anchor.clone(),
432 state,
433 },
434 ));
435 }
436 }
437 out
438 }
439
440 pub fn mem_names(&self) -> Vec<&str> {
444 self.mounts.iter().map(|m| m.mount.mem.as_str()).collect()
445 }
446
447 pub fn mount(&self, mem: &str) -> Option<&crate::workspace::Mount> {
465 self.mounts
466 .iter()
467 .find(|m| m.mount.mem == mem)
468 .map(|m| &m.mount)
469 }
470
471 pub fn orphans_by_schema(
478 &self,
479 orphan_ids: &[EntityId],
480 ) -> std::collections::BTreeMap<String, usize> {
481 let mut by_schema = std::collections::BTreeMap::new();
482 for id in orphan_ids {
483 let schema = self
484 .store()
485 .get(id)
486 .and_then(|e| self.mount(&e.mem))
487 .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
488 .unwrap_or_default();
489 *by_schema.entry(schema).or_insert(0) += 1;
490 }
491 by_schema
492 }
493
494 pub fn communities_by_schema(
500 &self,
501 mems: &[String],
502 ) -> std::collections::BTreeMap<String, usize> {
503 let louvain = self.communities();
504 let mut buckets: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
505 std::collections::BTreeMap::new();
506 for name in mems {
507 let schema = self
508 .mount(name)
509 .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
510 .unwrap_or_default();
511 let clusters = crate::graph::community::clusters_in_mem(self.store(), louvain, name);
512 buckets.entry(schema).or_default().extend(clusters);
513 }
514 buckets
515 .into_iter()
516 .map(|(schema, set)| (schema, set.len()))
517 .collect()
518 }
519
520 pub fn mounts(&self) -> Vec<&crate::workspace::Mount> {
524 self.mounts.iter().map(|m| &m.mount).collect()
525 }
526
527 pub fn writable_mem_names(&self) -> Vec<&str> {
535 self.mounts
536 .iter()
537 .filter(|m| m.mount.capability == MountCapability::Write)
538 .map(|m| m.mount.mem.as_str())
539 .collect()
540 }
541
542 pub fn default_writable_mem(&self) -> Option<&str> {
560 self.mounts
561 .iter()
562 .find(|m| m.mount.capability == MountCapability::Write)
563 .map(|m| m.mount.mem.as_str())
564 }
565
566 pub fn folder_path_for_mem(&self, mem: &str) -> Option<&Path> {
574 match self.mount(mem).map(|m| &m.storage) {
575 Some(crate::workspace::MountStorage::Folder { path }) => Some(path.as_path()),
576 _ => None,
577 }
578 }
579
580 pub fn mem_router(&self) -> &MemRouterSnapshot {
590 &self.mem_router
591 }
592
593 pub fn gitdir_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
602 let m = self
603 .mount(mem_name)
604 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
605 match &m.storage {
606 MountStorage::GitBranch { gitdir, .. } => Ok(gitdir.clone()),
607 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
608 Err(EngineError::Mem(format!(
609 "mem '{mem_name}' has no resolved gitdir"
610 )))
611 }
612 }
613 }
614
615 pub fn worktree_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
630 let m = self
631 .mount(mem_name)
632 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
633 match &m.storage {
634 MountStorage::Folder { path } => Ok(path.clone()),
635 MountStorage::GitBranch { .. } => {
636 if let Some(root) = self.workspace_root.as_deref() {
637 let candidate = root.join(mem_name);
638 if candidate
639 .join(crate::mem::MEM_META_DIR)
640 .join("config.json")
641 .is_file()
642 {
643 return Ok(candidate.canonicalize().unwrap_or(candidate));
644 }
645 }
646 Err(EngineError::Mem(format!(
647 "mem '{mem_name}' has no working tree (mem-repo-backed)"
648 )))
649 }
650 MountStorage::Archive { .. } => Err(EngineError::Mem(format!(
651 "mem '{mem_name}' is archive-backed and has no worktree"
652 ))),
653 MountStorage::InMemory => Err(EngineError::Mem(format!(
654 "mem '{mem_name}' is in-memory and has no worktree"
655 ))),
656 }
657 }
658
659 pub fn mem_config_for(&self, mem: &str) -> Option<&memstead_schema::config::MemConfig> {
677 self.mounts
678 .iter()
679 .find(|m| m.mount.mem == mem)
680 .and_then(|m| m.mem_config.as_ref())
681 }
682
683 pub fn archive_provenance_for(&self, mem: &str) -> Option<&memstead_schema::ArchiveProvenance> {
690 self.mounts
691 .iter()
692 .find(|m| m.mount.mem == mem)
693 .and_then(|m| m.archive_provenance.as_ref())
694 }
695
696 pub fn mem_configs_named(
707 &self,
708 ) -> impl Iterator<Item = (&str, &memstead_schema::config::MemConfig)> {
709 self.mounts
710 .iter()
711 .filter_map(|m| m.mem_config.as_ref().map(|c| (m.mount.mem.as_str(), c)))
712 }
713
714 pub fn schema_for(&self, mem: &str) -> Option<std::sync::Arc<memstead_schema::Schema>> {
721 self.schemas.get(mem).cloned()
722 }
723
724 pub fn mem_head_sha(&self, mem_name: &str) -> Result<Option<String>, EngineError> {
735 let m = self
736 .mounts
737 .iter()
738 .find(|m| m.mount.mem == mem_name)
739 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
740 Ok(m.last_known_head.clone())
741 }
742
743 pub fn mem_drifted(&self, mem_name: &str) -> Result<bool, EngineError> {
758 let m = self
759 .mounts
760 .iter()
761 .find(|m| m.mount.mem == mem_name)
762 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
763 let live = m.backend.current_head().ok().flatten();
764 Ok(live != m.last_known_head)
765 }
766
767 pub fn workspace_root(&self) -> Option<&Path> {
772 self.workspace_root.as_deref()
773 }
774
775 pub fn load_warnings(&self) -> &[WarningHint] {
780 &self.load_warnings
781 }
782
783 pub fn communities(&self) -> &LouvainOutput {
812 self.community_memo.get_or_init(|| {
813 let schema = self
820 .schemas
821 .iter()
822 .min_by(|a, b| a.0.cmp(b.0))
823 .map(|(_, s)| s.clone())
824 .unwrap_or_else(Schema::builtin_default);
825 let manifest = &schema.manifest;
826 let resolution = manifest.community.resolution;
827 let seed = manifest.community.seed;
828 let schema_for_weights = schema.clone();
829 detect_communities(&self.store, resolution, seed, move |rel_type| {
830 schema_for_weights
831 .manifest
832 .relationships
833 .definitions
834 .iter()
835 .find(|d| d.name == rel_type)
836 .map(|d| d.default_weight as f64)
837 .unwrap_or(1.0)
838 })
839 })
840 }
841
842 pub fn invalidate_communities(&mut self) {
844 self.community_memo = OnceCell::new();
845 }
846
847 pub fn orphans(&self) -> Vec<EntityId> {
849 crate::graph::query::find_orphans(&self.store)
850 }
851
852 pub fn stubs(&self) -> Vec<(EntityId, Vec<EntityId>)> {
854 crate::graph::query::find_stubs(&self.store)
855 }
856
857 pub fn most_connected(&self, limit: usize) -> Vec<crate::graph::query::Connectivity> {
859 crate::graph::query::most_connected(&self.store, limit)
860 }
861
862 pub fn missing_required_outgoing(
866 &self,
867 mem_filter: Option<&str>,
868 ) -> Vec<crate::ops::health::MissingRequiredOutgoingReport> {
869 crate::ops::health::collect_missing_required_outgoing(
870 &self.store,
871 mem_filter,
872 &self.schemas,
873 )
874 }
875
876 pub fn conformance_findings(
882 &self,
883 mem: &str,
884 target_schema: Option<&memstead_schema::SchemaRef>,
885 ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
886 let pinned = self
887 .schemas
888 .get(mem)
889 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
890 let effective: Arc<Schema> = match target_schema {
891 None => pinned.clone(),
892 Some(target) => self.resolve_schema_by_ref(target).ok_or_else(|| {
893 let consulted: Vec<_> = self
894 .workspace_schemas
895 .iter()
896 .chain(self.builtin_schemas.iter())
897 .cloned()
898 .collect();
899 EngineError::SchemaNotFound {
900 mem: mem.to_string(),
901 pin: target.as_display(),
902 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
903 &target.name,
904 &target.version,
905 &consulted,
906 ),
907 }
908 })?,
909 };
910 Ok(crate::ops::integrity::conformance_findings(
911 &self.store,
912 mem,
913 &effective,
914 &self.schemas,
915 ))
916 }
917
918 pub(crate) fn resolve_schema_by_ref(
922 &self,
923 target: &memstead_schema::SchemaRef,
924 ) -> Option<Arc<Schema>> {
925 self.schemas
926 .values()
927 .chain(self.workspace_schemas.iter())
928 .chain(self.builtin_schemas.iter())
929 .find(|s| {
930 let (name, version) = s.id();
931 name == target.name && version == target.version
932 })
933 .cloned()
934 }
935
936 pub fn schema_pin(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
941 self.mounts
942 .iter()
943 .find(|m| m.mount.mem == mem)
944 .and_then(|m| m.mount.schema.clone())
945 }
946
947 pub fn migration_target(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
950 self.mounts
951 .iter()
952 .find(|m| m.mount.mem == mem)
953 .and_then(|m| m.mount.migration_target.clone())
954 }
955
956 pub fn consistency_findings(
960 &self,
961 mem: &str,
962 ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
963 if !self.schemas.contains_key(mem) {
964 return Err(EngineError::UnknownMem(mem.to_string()));
965 }
966 Ok(crate::ops::integrity::consistency_findings(
967 &self.store,
968 mem,
969 ))
970 }
971
972 pub fn health(&self) -> crate::ops::HealthSummary {
974 let fallback = engine_fallback_type();
975 let mut summary =
976 crate::ops::health::compute_health(&self.store, fallback.as_ref(), &self.schemas);
977 if !self.load_warnings.is_empty() {
984 let mut merged = self.load_warnings.clone();
985 merged.append(&mut summary.warnings);
986 summary.warnings = merged;
987 }
988 if let Some(root) = self.workspace_root.as_deref()
994 && let Some(outer) = crate::workspace_root::find_enclosing_git_repo(root)
995 && !crate::workspace_root::outer_repo_ignores_mem_repo(&outer, root)
996 {
997 summary
998 .warnings
999 .push(WarningHint::OuterRepoNotIgnoringMemRepo {
1000 outer_repo_root: outer.display().to_string(),
1001 workspace_root: root.display().to_string(),
1002 });
1003 }
1004 summary
1005 }
1006
1007 pub fn status(&self) -> crate::ops::Status {
1011 let mut types_in_use: Vec<String> = self
1012 .store
1013 .all_entities()
1014 .filter(|e| !e.stub && !e.entity_type.is_empty())
1015 .map(|e| e.entity_type.clone())
1016 .collect();
1017 types_in_use.sort();
1018 types_in_use.dedup();
1019
1020 let mut edge_types: HashMap<String, usize> = HashMap::new();
1021 for id in self.store.all_ids() {
1022 for edge in self.store.outgoing(id) {
1023 *edge_types.entry(edge.rel_type.clone()).or_insert(0) += 1;
1024 }
1025 }
1026
1027 crate::ops::Status {
1028 entity_count: self.store.all_entities().filter(|e| !e.stub).count(),
1029 edge_count: self.store.edge_count(),
1030 edge_types,
1031 community_count: self.communities().count,
1032 mem_count: self.mounts.len(),
1033 types_in_use,
1034 }
1035 }
1036
1037 pub fn context(&self, id: &EntityId) -> Option<ContextResult> {
1041 let entity = self.store.get(id)?;
1042 let community = self
1043 .communities()
1044 .entity_cluster_map
1045 .get(id.as_ref())
1046 .cloned();
1047 let mut neighbors = Vec::new();
1048 for edge in self.store.outgoing(id) {
1049 if let Some(target) = self.store.get(&edge.target) {
1050 neighbors.push(NeighborInfo {
1051 id: target.id.clone(),
1052 title: target.title.clone(),
1053 relationship: edge.rel_type.clone(),
1054 direction: Direction::Outgoing,
1055 });
1056 }
1057 }
1058 for edge in self.store.incoming(id) {
1059 if let Some(source) = self.store.get(&edge.from) {
1060 neighbors.push(NeighborInfo {
1061 id: source.id.clone(),
1062 title: source.title.clone(),
1063 relationship: edge.rel_type.clone(),
1064 direction: Direction::Incoming,
1065 });
1066 }
1067 }
1068 Some(ContextResult {
1069 entity_id: entity.id.clone(),
1070 community,
1071 neighbors,
1072 })
1073 }
1074
1075 #[cfg(not(target_arch = "wasm32"))]
1081 pub fn search_indexes(&self) -> &HashMap<String, MemIndex> {
1082 self.search_indexes_memo
1083 .get_or_init(|| build_all(&self.store, &self.schemas))
1084 }
1085
1086 pub fn invalidate_search_indexes(&mut self) {
1090 #[cfg(not(target_arch = "wasm32"))]
1091 {
1092 self.search_indexes_memo = OnceCell::new();
1093 }
1094 }
1095
1096 #[cfg(not(target_arch = "wasm32"))]
1098 pub fn list(&self, scope: &SearchScope) -> crate::ops::ListResult {
1099 let fallback = engine_fallback_type();
1100 crate::ops::search::list(&self.store, scope, fallback.as_ref(), &self.schemas)
1101 }
1102
1103 pub fn search(&self, scope: &SearchScope) -> Result<SearchResult, EngineError> {
1109 #[cfg(target_arch = "wasm32")]
1110 {
1111 let _ = scope;
1112 return Err(EngineError::SearchUnavailable);
1113 }
1114 #[cfg(not(target_arch = "wasm32"))]
1115 {
1116 let fallback = engine_fallback_type();
1117 Ok(crate::ops::search::search(
1118 &self.store,
1119 scope,
1120 fallback.as_ref(),
1121 self.search_indexes(),
1122 &self.schemas,
1123 ))
1124 }
1125 }
1126
1127 pub fn list_entities(&self, mem: &str) -> Result<Vec<PathBuf>, EngineError> {
1130 let m = self.find_mount(mem)?;
1131 m.backend.list_entities().map_err(EngineError::Backend)
1132 }
1133
1134 pub fn read_entity(&self, mem: &str, rel_path: &Path) -> Result<Option<Vec<u8>>, EngineError> {
1136 let m = self.find_mount(mem)?;
1137 m.backend
1138 .read_entity(rel_path)
1139 .map_err(EngineError::Backend)
1140 }
1141
1142 pub fn read_provenance(
1146 &self,
1147 mem: &str,
1148 cursor: Option<&str>,
1149 ) -> Result<Vec<Provenance>, EngineError> {
1150 let m = self.find_mount(mem)?;
1151 m.backend
1152 .read_provenance(cursor)
1153 .map_err(EngineError::Backend)
1154 }
1155
1156 pub fn capability(&self, mem: &str) -> Result<crate::workspace::MountCapability, EngineError> {
1161 let m = self.find_mount(mem)?;
1162 Ok(m.mount.capability)
1163 }
1164
1165 pub fn edge_is_from_readonly(&self, from: &EntityId) -> bool {
1180 match self.capability(from.mem()) {
1181 Ok(crate::workspace::MountCapability::ReadOnly) => true,
1182 Ok(crate::workspace::MountCapability::Write) | Err(_) => false,
1183 }
1184 }
1185
1186 pub fn cross_mem_link_allowed(&self, from_mem: &str, to_mem: &str) -> bool {
1220 use memstead_schema::workspace_config::CrossLinkValue;
1221 if from_mem == to_mem {
1222 return true;
1223 }
1224
1225 if let Some(value) = self.settings.cross_mem_links.get(from_mem) {
1227 match value {
1228 CrossLinkValue::Wildcard => return true,
1229 CrossLinkValue::List(targets) => {
1230 if targets.iter().any(|t| t == to_mem) {
1231 return true;
1232 }
1233 }
1237 }
1238 }
1239
1240 let rule_set = self.create_rule_set_memo.get_or_init(|| {
1242 crate::mem_management::CreateRuleSet::new(
1243 self.settings.mem_create_rules.clone(),
1244 )
1245 .unwrap_or_else(|err| {
1246 tracing::warn!(
1247 error = %err,
1248 "cross_mem_link_allowed: failed to compile mem_create_rules — synthesis disabled (resolver falls back to explicit-policy-only)"
1249 );
1250 crate::mem_management::CreateRuleSet::default()
1251 })
1252 });
1253
1254 let candidate = match self.mount(from_mem).and_then(|m| m.mem_path()) {
1263 Some(path) => format!("{path}/{from_mem}"),
1264 None => from_mem.to_string(),
1265 };
1266 if let Some(matched) = rule_set.first_match(std::path::Path::new(&candidate))
1267 && let Some(synth) = matched.default_cross_links.as_ref()
1268 {
1269 return match synth {
1270 CrossLinkValue::Wildcard => true,
1271 CrossLinkValue::List(targets) => targets.iter().any(|t| t == to_mem),
1272 };
1273 }
1274
1275 false
1276 }
1277
1278 pub(super) fn find_mount(&self, mem: &str) -> Result<&MountedBackend, EngineError> {
1279 self.mounts
1280 .iter()
1281 .find(|m| m.mount.mem == mem)
1282 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))
1283 }
1284}
1285
1286fn anchor_base_path(artifact: &str) -> &str {
1290 let cut = artifact.find(['@', '#']).unwrap_or(artifact.len());
1291 &artifact[..cut]
1292}
1293
1294#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1301pub struct ResolvedAnchor {
1302 #[serde(flatten)]
1305 pub anchor: crate::anchor::Anchor,
1306 #[serde(skip_serializing_if = "Option::is_none")]
1310 pub state: Option<crate::anchor::AnchorState>,
1311}
1312
1313fn observe_path_anchor(
1320 root: &Path,
1321 anchor: &crate::anchor::Anchor,
1322) -> Option<crate::anchor::AnchorState> {
1323 use crate::anchor::AnchorGrain;
1324 match anchor.grain {
1325 AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => {}
1326 AnchorGrain::Url | AnchorGrain::Entity => return None,
1327 }
1328 let base = anchor_base_path(&anchor.artifact);
1329 let observation = if root.join(base).exists() {
1330 crate::anchor::ArtifactObservation::Present { current_hash: None }
1331 } else {
1332 crate::anchor::ArtifactObservation::Absent
1333 };
1334 Some(crate::anchor::resolve_anchor(anchor, &observation))
1335}
1336
1337fn anchor_references_path(anchor: &crate::anchor::Anchor, path: &str) -> bool {
1338 let base = anchor_base_path(&anchor.artifact);
1339 if base == path {
1340 return true;
1341 }
1342 if anchor.grain == crate::anchor::AnchorGrain::Tree {
1343 let prefix = base.strip_suffix('/').unwrap_or(base);
1344 return path.starts_with(&format!("{prefix}/"));
1345 }
1346 false
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use std::path::Path;
1352
1353 use tempfile::TempDir;
1354
1355 use crate::backend::{BackendError, MemBackend};
1356 use crate::engine::test_helpers::*;
1357 use crate::engine::{Engine, EngineError, RelateEntityArgs};
1358 use crate::entity::EntityId;
1359 use crate::ops::{Direction, SearchScope, WarningHint};
1360 use crate::provenance::Provenance;
1361 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
1362
1363 use crate::vcs::CommitContext;
1364 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1365
1366 #[test]
1371 fn schema_origin_classifies_builtin_first_party_and_unknown_third_party() {
1372 use std::sync::Arc;
1373
1374 use crate::render::OriginClass;
1375
1376 let tmp = TempDir::new().unwrap();
1377 let engine = Engine::from_mounts(vec![(
1378 folder_mount("specs", tmp.path().to_path_buf()),
1379 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf())) as Box<dyn MemBackend>,
1380 )])
1381 .unwrap();
1382
1383 let builtin = engine.builtin_schemas()[0].clone();
1385 assert_eq!(
1386 engine.schema_origin(&builtin),
1387 OriginClass::FirstParty,
1388 "a built-in schema is first-party"
1389 );
1390
1391 let foreign = Arc::new(memstead_schema::Schema {
1395 manifest: builtin.manifest.clone(),
1396 version: semver::Version::new(99, 0, 0),
1397 types: builtin.types.clone(),
1398 });
1399 assert_eq!(
1400 engine.schema_origin(&foreign),
1401 OriginClass::ThirdParty,
1402 "a schema in neither catalogue classifies third-party (safe default)"
1403 );
1404 }
1405
1406 #[test]
1412 fn mem_origin_class_writable_first_party_readonly_third_party() {
1413 use crate::render::OriginClass;
1414
1415 let tmp = TempDir::new().unwrap();
1416 let writable_dir = tmp.path().join("writable");
1418 std::fs::create_dir_all(&writable_dir).unwrap();
1419 let writer = FilesystemMemWriter::new(writable_dir.clone());
1420
1421 let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
1423 let archive_path = build_archive(tmp.path(), "ext", &[("ext.md", body.as_bytes())]);
1424
1425 let engine = Engine::from_mounts(vec![
1426 (
1427 folder_mount("local", writable_dir),
1428 Box::new(writer) as Box<dyn MemBackend>,
1429 ),
1430 (
1431 archive_mount("external", archive_path.clone()),
1432 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1433 ),
1434 ])
1435 .unwrap();
1436
1437 assert_eq!(
1438 engine.mem_origin_class("local"),
1439 OriginClass::FirstParty,
1440 "a writable mount is first-party"
1441 );
1442 assert_eq!(
1443 engine.mem_origin_class("external"),
1444 OriginClass::ThirdParty,
1445 "a read-only mount is third-party"
1446 );
1447 assert_eq!(
1448 engine.mem_origin_class("no-such-mem"),
1449 OriginClass::ThirdParty,
1450 "an unknown mem is third-party (safe default)"
1451 );
1452 }
1453
1454 #[test]
1459 fn declared_origin_overrides_inference_per_mem() {
1460 use crate::render::OriginClass;
1461
1462 let tmp = TempDir::new().unwrap();
1463 let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
1464 let vouched_path = build_archive(tmp.path(), "vouched", &[("v.md", body.as_bytes())]);
1465 let other_path = build_archive(tmp.path(), "other", &[("o.md", body.as_bytes())]);
1466
1467 let mut engine = Engine::from_mounts(vec![
1468 (
1469 archive_mount("vouched", vouched_path.clone()),
1470 Box::new(ArchiveBackend::new(vouched_path)) as Box<dyn MemBackend>,
1471 ),
1472 (
1473 archive_mount("other", other_path.clone()),
1474 Box::new(ArchiveBackend::new(other_path)) as Box<dyn MemBackend>,
1475 ),
1476 ])
1477 .unwrap();
1478
1479 engine.declare_mem_origin("vouched", OriginClass::FirstParty);
1480
1481 assert_eq!(
1482 engine.mem_origin_class("vouched"),
1483 OriginClass::FirstParty,
1484 "the deployment's declaration wins over the read-only inference"
1485 );
1486 assert_eq!(
1487 engine.mem_origin_class("other"),
1488 OriginClass::ThirdParty,
1489 "an undeclared sibling mount keeps the safe default"
1490 );
1491 }
1492
1493 #[test]
1499 fn schema_origin_third_party_until_pinned_by_a_writable_mount() {
1500 use memstead_schema::SchemaRef;
1501
1502 use crate::render::OriginClass;
1503
1504 let manifest = r#"name: trust-test
1505version: 0.1.0
1506description: adopt-gate test schema
1507when_to_use: tests
1508types:
1509 - doc
1510relationships:
1511 mode: strict
1512 definitions:
1513 - name: _default
1514 description: fallback
1515 default_weight: 1.0
1516community:
1517 resolution: 1.0
1518 seed: 42
1519"#;
1520 let pin = SchemaRef::new("trust-test", semver::Version::new(0, 1, 0));
1521
1522 let mk_engine = |cap: MountCapability| -> Engine {
1523 let tmp = TempDir::new().unwrap();
1524 let schemas_dir = tmp.path().join("schemas");
1525 std::fs::create_dir_all(&schemas_dir).unwrap();
1526 write_schema_files_with_default_type(&schemas_dir, "trust-test", manifest, &["doc"]);
1527 let mem_dir = tmp.path().join("mem");
1528 std::fs::create_dir_all(&mem_dir).unwrap();
1529 let mount = Mount {
1530 mem: "v".to_string(),
1531 schema: Some(pin.clone()),
1532 storage: MountStorage::Folder {
1533 path: mem_dir.clone(),
1534 },
1535 capability: cap,
1536 lifecycle: MountLifecycle::Eager,
1537 cross_linkable: true,
1538 migration_target: None,
1539 };
1540 let backend = Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>;
1541 std::mem::forget(tmp);
1545 Engine::from_mounts_with_schemas_dir(vec![(mount, backend)], Some(&schemas_dir))
1546 .unwrap()
1547 };
1548
1549 let ro = mk_engine(MountCapability::ReadOnly);
1551 let schema = ro.schemas().get("v").expect("schema resolved").clone();
1552 assert_eq!(
1553 ro.schema_origin(&schema),
1554 OriginClass::ThirdParty,
1555 "a non-built-in schema pinned only by a read-only mount is third-party"
1556 );
1557
1558 let rw = mk_engine(MountCapability::Write);
1560 let schema = rw.schemas().get("v").expect("schema resolved").clone();
1561 assert_eq!(
1562 rw.schema_origin(&schema),
1563 OriginClass::FirstParty,
1564 "a writable mount pinning the schema adopts it → first-party"
1565 );
1566 }
1567
1568 #[test]
1575 fn archive_provenance_surfaces_per_entity_and_reports_absence() {
1576 use memstead_schema::History;
1577
1578 let tmp = TempDir::new().unwrap();
1579 let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
1580 let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
1581 let beta = b"---\ntype: spec\n---\n# Beta\n\n## Identity\n\nb\n\n## Purpose\n\np\n";
1582 let provenance = br#"{"format":1,"history":"summarised","entities":{"alpha":{"rationale":"why alpha exists","kind":"create","timestamp":"2026-06-24T00:00:00Z","actor":"agent"}}}"#;
1584 let archive = build_archive(
1585 tmp.path(),
1586 "seed",
1587 &[
1588 (".memstead/config.json", config),
1589 ("alpha.md", alpha),
1590 ("beta.md", beta),
1591 (".memstead/provenance.json", provenance),
1592 ],
1593 );
1594 let engine = Engine::from_mounts(vec![(
1595 archive_mount("seed", archive.clone()),
1596 Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
1597 )])
1598 .unwrap();
1599
1600 let prov = engine
1601 .archive_provenance_for("seed")
1602 .expect("provenance payload read from the archive");
1603 assert_eq!(
1604 prov.history,
1605 History::Summarised,
1606 "history-not-shipped is observable"
1607 );
1608 assert_eq!(
1609 prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
1610 Some("why alpha exists"),
1611 "noted entity surfaces its rationale"
1612 );
1613 assert!(
1614 prov.entity("beta").is_none(),
1615 "unnoted entity is absent (reported absent, not fabricated)"
1616 );
1617 }
1618
1619 #[test]
1623 fn archive_without_provenance_reports_absent() {
1624 let tmp = TempDir::new().unwrap();
1625 let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
1626 let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
1627 let archive = build_archive(
1628 tmp.path(),
1629 "seed",
1630 &[(".memstead/config.json", config), ("alpha.md", alpha)],
1631 );
1632 let engine = Engine::from_mounts(vec![(
1633 archive_mount("seed", archive.clone()),
1634 Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
1635 )])
1636 .unwrap();
1637 assert!(
1638 engine.archive_provenance_for("seed").is_none(),
1639 "an archive without a provenance payload reports provenance absent"
1640 );
1641 }
1642
1643 #[test]
1644 fn folder_mount_routes_reads_to_filesystem_backend() {
1645 let tmp = TempDir::new().unwrap();
1646 let mem_dir = tmp.path().to_path_buf();
1647 let writer = FilesystemMemWriter::new(mem_dir.clone());
1648 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"alpha")
1652 .unwrap();
1653 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1654 .unwrap();
1655
1656 let engine = Engine::from_mounts(vec![(
1657 folder_mount("specs", mem_dir),
1658 Box::new(writer) as Box<dyn MemBackend>,
1659 )])
1660 .unwrap();
1661
1662 let mut paths: Vec<String> = engine
1663 .list_entities("specs")
1664 .unwrap()
1665 .into_iter()
1666 .map(|p| p.to_string_lossy().into_owned())
1667 .collect();
1668 paths.sort();
1669 assert_eq!(paths, vec!["a.md".to_string()]);
1670
1671 assert_eq!(
1672 engine.read_entity("specs", Path::new("a.md")).unwrap(),
1673 Some(b"alpha".to_vec())
1674 );
1675 }
1676
1677 #[test]
1678 fn heterogeneous_mounts_route_to_correct_backend() {
1679 let tmp = TempDir::new().unwrap();
1680
1681 let folder_dir = tmp.path().join("folder-mem");
1683 std::fs::create_dir_all(&folder_dir).unwrap();
1684 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1685 <FilesystemMemWriter as MemWriter>::write_entity(
1686 &folder_writer,
1687 Path::new("local.md"),
1688 b"local",
1689 )
1690 .unwrap();
1691 <FilesystemMemWriter as MemWriter>::commit(
1692 &folder_writer,
1693 "seed",
1694 &CommitContext::internal(),
1695 )
1696 .unwrap();
1697
1698 let archive_path = build_archive(
1700 tmp.path(),
1701 "external",
1702 &[("ext.md", b"external"), ("dir/nested.md", b"nested")],
1703 );
1704
1705 let engine = Engine::from_mounts(vec![
1706 (
1707 folder_mount("local", folder_dir),
1708 Box::new(folder_writer) as Box<dyn MemBackend>,
1709 ),
1710 (
1711 archive_mount("external", archive_path.clone()),
1712 Box::new(ArchiveBackend::new(archive_path)),
1713 ),
1714 ])
1715 .unwrap();
1716
1717 assert_eq!(engine.mem_names(), vec!["local", "external"]);
1719 assert_eq!(
1720 engine.read_entity("local", Path::new("local.md")).unwrap(),
1721 Some(b"local".to_vec())
1722 );
1723 assert_eq!(
1724 engine.read_entity("external", Path::new("ext.md")).unwrap(),
1725 Some(b"external".to_vec())
1726 );
1727 assert_eq!(
1728 engine
1729 .read_entity("external", Path::new("dir/nested.md"))
1730 .unwrap(),
1731 Some(b"nested".to_vec())
1732 );
1733 assert_eq!(
1736 engine.read_entity("local", Path::new("ext.md")).unwrap(),
1737 None
1738 );
1739 assert_eq!(
1740 engine
1741 .read_entity("external", Path::new("local.md"))
1742 .unwrap(),
1743 None
1744 );
1745 }
1746
1747 #[test]
1748 fn edge_is_from_readonly_classifies_every_edge_by_source_mount_capability() {
1749 let tmp = TempDir::new().unwrap();
1755
1756 let folder_dir = tmp.path().join("local-mem");
1760 std::fs::create_dir_all(&folder_dir).unwrap();
1761 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1762 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";
1763 <FilesystemMemWriter as MemWriter>::write_entity(
1764 &folder_writer,
1765 Path::new("note.md"),
1766 local_md,
1767 )
1768 .unwrap();
1769 <FilesystemMemWriter as MemWriter>::commit(
1770 &folder_writer,
1771 "seed",
1772 &CommitContext::internal(),
1773 )
1774 .unwrap();
1775
1776 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";
1780 let archive_path = build_archive(tmp.path(), "external", &[("archived.md", archive_md)]);
1781
1782 let engine = Engine::from_mounts(vec![
1783 (
1784 folder_mount("local", folder_dir),
1785 Box::new(folder_writer) as Box<dyn MemBackend>,
1786 ),
1787 (
1788 archive_mount("external", archive_path.clone()),
1789 Box::new(ArchiveBackend::new(archive_path)),
1790 ),
1791 ])
1792 .unwrap();
1793
1794 let local_id = EntityId::new("local", "note");
1796 let archived_id = EntityId::new("external", "archived");
1797 assert!(engine.get_entity(&local_id).is_some());
1798 assert!(engine.get_entity(&archived_id).is_some());
1799 assert!(matches!(
1800 engine.capability("local").unwrap(),
1801 MountCapability::Write
1802 ));
1803 assert!(matches!(
1804 engine.capability("external").unwrap(),
1805 MountCapability::ReadOnly
1806 ));
1807
1808 let mut seen_write_edge = false;
1814 let mut seen_readonly_edge = false;
1815 for from in engine.store().all_ids().cloned().collect::<Vec<_>>() {
1816 for _edge in engine.store().outgoing(&from) {
1817 let is_ro = engine.edge_is_from_readonly(&from);
1818 match engine.capability(from.mem()).unwrap() {
1819 MountCapability::Write => {
1820 assert!(
1821 !is_ro,
1822 "edge from write mem {} reported as ReadOnly",
1823 from.mem()
1824 );
1825 seen_write_edge = true;
1826 }
1827 MountCapability::ReadOnly => {
1828 assert!(
1829 is_ro,
1830 "edge from readonly mem {} reported as Write",
1831 from.mem()
1832 );
1833 seen_readonly_edge = true;
1834 }
1835 }
1836 }
1837 }
1838 assert!(
1839 seen_write_edge,
1840 "fixture must produce at least one edge from a write mem"
1841 );
1842 assert!(
1843 seen_readonly_edge,
1844 "fixture must produce at least one edge from a readonly mem"
1845 );
1846
1847 let phantom = EntityId::new("missing-mem", "phantom");
1850 assert!(
1851 !engine.edge_is_from_readonly(&phantom),
1852 "absent mount must not be reported as ReadOnly"
1853 );
1854 }
1855
1856 #[test]
1859 fn cross_mem_link_allowed_same_mem_always_true() {
1860 let tmp = TempDir::new().unwrap();
1863 let engine = build_demo_engine(&tmp);
1864 assert!(engine.cross_mem_link_allowed("specs", "specs"));
1865 assert!(engine.cross_mem_link_allowed("anywhere", "anywhere"));
1870 }
1871
1872 #[test]
1873 fn cross_mem_link_allowed_absent_denies_by_default() {
1874 let tmp = TempDir::new().unwrap();
1877 let engine = build_demo_engine(&tmp);
1878 assert!(!engine.cross_mem_link_allowed("specs", "engine"));
1879 assert!(!engine.cross_mem_link_allowed("missing", "anywhere"));
1880 }
1881
1882 #[test]
1883 fn cross_mem_link_allowed_wildcard_admits_any_target() {
1884 use memstead_schema::workspace_config::CrossLinkValue;
1885 let tmp = TempDir::new().unwrap();
1886 let mut engine = build_demo_engine(&tmp);
1887 let mut settings = crate::workspace::WorkspaceSettings::default();
1888 settings
1889 .cross_mem_links
1890 .insert("specs".to_string(), CrossLinkValue::Wildcard);
1891 engine.set_settings(settings);
1892 assert!(engine.cross_mem_link_allowed("specs", "engine"));
1893 assert!(engine.cross_mem_link_allowed("specs", "macos"));
1894 assert!(engine.cross_mem_link_allowed("specs", "any-other"));
1895 assert!(!engine.cross_mem_link_allowed("engine", "specs"));
1898 }
1899
1900 #[test]
1901 fn cross_mem_link_allowed_allowlist_enforces_membership() {
1902 use memstead_schema::workspace_config::CrossLinkValue;
1903 let tmp = TempDir::new().unwrap();
1904 let mut engine = build_demo_engine(&tmp);
1905 let mut settings = crate::workspace::WorkspaceSettings::default();
1906 settings.cross_mem_links.insert(
1907 "specs".to_string(),
1908 CrossLinkValue::List(vec!["engine".to_string(), "macos".to_string()]),
1909 );
1910 engine.set_settings(settings);
1911 assert!(engine.cross_mem_link_allowed("specs", "engine"));
1912 assert!(engine.cross_mem_link_allowed("specs", "macos"));
1913 assert!(!engine.cross_mem_link_allowed("specs", "external"));
1914 }
1915
1916 #[test]
1917 fn cross_mem_link_allowed_synthesises_from_matching_create_rule_wildcard() {
1918 use memstead_schema::workspace_config::CrossLinkValue;
1922 let tmp = TempDir::new().unwrap();
1923 let mut engine = build_demo_engine(&tmp);
1924 let mut settings = crate::workspace::WorkspaceSettings::default();
1925 settings
1926 .mem_create_rules
1927 .push(crate::workspace::CreateRuleSetting {
1928 pattern: "exec-*".to_string(),
1929 schemas: vec!["default".to_string()],
1930 default_cross_links: Some(CrossLinkValue::Wildcard),
1931 });
1932 engine.set_settings(settings);
1933 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
1936 assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
1937 assert!(!engine.cross_mem_link_allowed("orphan", "specs"));
1939 }
1940
1941 #[test]
1948 fn cross_mem_link_allowed_synthesises_for_hierarchical_mem() {
1949 use memstead_schema::workspace_config::CrossLinkValue;
1950 let tmp = TempDir::new().unwrap();
1951 let mem_dir = tmp.path().to_path_buf();
1952 let mount = Mount {
1957 mem: "project".into(),
1958 schema: Some(pin("default")),
1959 storage: MountStorage::GitBranch {
1960 gitdir: mem_dir.join(".git"),
1961 branch: "memstead/project".into(),
1962 },
1963 capability: MountCapability::Write,
1964 lifecycle: MountLifecycle::Eager,
1965 cross_linkable: true,
1966 migration_target: None,
1967 };
1968 let writer = FilesystemMemWriter::new(mem_dir.clone());
1969 let mut engine =
1970 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1971 let mut settings = crate::workspace::WorkspaceSettings::default();
1972 settings
1973 .mem_create_rules
1974 .push(crate::workspace::CreateRuleSetting {
1975 pattern: "memstead/*".to_string(),
1976 schemas: vec!["default".to_string()],
1977 default_cross_links: Some(CrossLinkValue::List(vec!["engine".to_string()])),
1978 });
1979 engine.set_settings(settings);
1980 assert!(
1981 engine.cross_mem_link_allowed("project", "engine"),
1982 "synthesis must match via the composed `memstead/project` candidate"
1983 );
1984 assert!(
1985 !engine.cross_mem_link_allowed("project", "macos"),
1986 "a target outside the rule's default_cross_links is still denied"
1987 );
1988 }
1989
1990 #[test]
1991 fn cross_mem_link_allowed_synthesises_from_matching_create_rule_list() {
1992 use memstead_schema::workspace_config::CrossLinkValue;
1995 let tmp = TempDir::new().unwrap();
1996 let mut engine = build_demo_engine(&tmp);
1997 let mut settings = crate::workspace::WorkspaceSettings::default();
1998 settings
1999 .mem_create_rules
2000 .push(crate::workspace::CreateRuleSetting {
2001 pattern: "exec-*".to_string(),
2002 schemas: vec!["default".to_string()],
2003 default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
2004 });
2005 engine.set_settings(settings);
2006 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
2007 assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
2009 }
2010
2011 #[test]
2012 fn cross_mem_link_allowed_explicit_policy_wins_over_synthesis() {
2013 use memstead_schema::workspace_config::CrossLinkValue;
2016 let tmp = TempDir::new().unwrap();
2017 let mut engine = build_demo_engine(&tmp);
2018 let mut settings = crate::workspace::WorkspaceSettings::default();
2019 settings
2020 .cross_mem_links
2021 .insert("exec-foo".to_string(), CrossLinkValue::Wildcard);
2022 engine.set_settings(settings);
2025 assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
2026 }
2027
2028 #[test]
2029 fn cross_mem_link_allowed_synthesis_unions_into_explicit_list() {
2030 use memstead_schema::workspace_config::CrossLinkValue;
2033 let tmp = TempDir::new().unwrap();
2034 let mut engine = build_demo_engine(&tmp);
2035 let mut settings = crate::workspace::WorkspaceSettings::default();
2036 settings.cross_mem_links.insert(
2037 "exec-foo".to_string(),
2038 CrossLinkValue::List(vec!["specs".to_string()]),
2039 );
2040 settings
2041 .mem_create_rules
2042 .push(crate::workspace::CreateRuleSetting {
2043 pattern: "exec-*".to_string(),
2044 schemas: vec!["default".to_string()],
2045 default_cross_links: Some(CrossLinkValue::List(vec!["macos".to_string()])),
2046 });
2047 engine.set_settings(settings);
2048 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
2050 assert!(engine.cross_mem_link_allowed("exec-foo", "macos"));
2052 assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
2054 }
2055
2056 #[test]
2057 fn cross_mem_link_allowed_set_settings_invalidates_compiled_rule_cache() {
2058 use memstead_schema::workspace_config::CrossLinkValue;
2061 let tmp = TempDir::new().unwrap();
2062 let mut engine = build_demo_engine(&tmp);
2063
2064 let mut s1 = crate::workspace::WorkspaceSettings::default();
2066 s1.mem_create_rules
2067 .push(crate::workspace::CreateRuleSetting {
2068 pattern: "exec-*".to_string(),
2069 schemas: vec!["default".to_string()],
2070 default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
2071 });
2072 engine.set_settings(s1);
2073 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
2074
2075 let mut s2 = crate::workspace::WorkspaceSettings::default();
2079 s2.mem_create_rules
2080 .push(crate::workspace::CreateRuleSetting {
2081 pattern: "exec-*".to_string(),
2082 schemas: vec!["default".to_string()],
2083 default_cross_links: None,
2084 });
2085 engine.set_settings(s2);
2086 assert!(!engine.cross_mem_link_allowed("exec-foo", "specs"));
2087 }
2088
2089 #[test]
2090 fn cross_mem_link_allowed_malformed_glob_falls_back_to_explicit_policy() {
2091 use memstead_schema::workspace_config::CrossLinkValue;
2095 let tmp = TempDir::new().unwrap();
2096 let mut engine = build_demo_engine(&tmp);
2097 let mut settings = crate::workspace::WorkspaceSettings::default();
2098 settings
2099 .mem_create_rules
2100 .push(crate::workspace::CreateRuleSetting {
2101 pattern: "[unclosed".to_string(),
2102 schemas: vec!["default".to_string()],
2103 default_cross_links: Some(CrossLinkValue::Wildcard),
2104 });
2105 settings
2107 .cross_mem_links
2108 .insert("specs".to_string(), CrossLinkValue::Wildcard);
2109 engine.set_settings(settings);
2110 assert!(engine.cross_mem_link_allowed("specs", "engine"));
2112 assert!(!engine.cross_mem_link_allowed("orphan", "anything"));
2115 }
2116
2117 #[test]
2118 fn cross_mem_link_allowed_empty_list_denies_all_cross_mem_targets() {
2119 use memstead_schema::workspace_config::CrossLinkValue;
2123 let tmp = TempDir::new().unwrap();
2124 let mut engine = build_demo_engine(&tmp);
2125 let mut settings = crate::workspace::WorkspaceSettings::default();
2126 settings
2127 .cross_mem_links
2128 .insert("specs".to_string(), CrossLinkValue::List(Vec::new()));
2129 engine.set_settings(settings);
2130 assert!(engine.cross_mem_link_allowed("specs", "specs"));
2132 assert!(!engine.cross_mem_link_allowed("specs", "engine"));
2134 assert!(!engine.cross_mem_link_allowed("specs", "anything"));
2135 }
2136
2137 #[test]
2138 fn from_mounts_load_warnings_merge_into_health_summary() {
2139 let tmp = TempDir::new().unwrap();
2140 let mem_dir = tmp.path().to_path_buf();
2141 let body = "---\ntype: spec\n---\n# Dup2\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
2142 std::fs::write(mem_dir.join("dup2.md"), body).unwrap();
2143
2144 let writer = FilesystemMemWriter::new(mem_dir.clone());
2145 let engine = Engine::from_mounts(vec![(
2146 folder_mount("specs", mem_dir),
2147 Box::new(writer) as Box<dyn MemBackend>,
2148 )])
2149 .unwrap();
2150
2151 let summary = engine.health();
2152 assert!(
2153 summary
2154 .warnings
2155 .iter()
2156 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
2157 "health() must merge load_warnings into summary.warnings: {:?}",
2158 summary.warnings,
2159 );
2160 }
2161
2162 #[test]
2163 fn workspace_root_accessor_is_none_for_engine_built_from_mounts() {
2164 let tmp = TempDir::new().unwrap();
2165 let mem_dir = tmp.path().to_path_buf();
2166 let writer = FilesystemMemWriter::new(mem_dir.clone());
2167 let engine = Engine::from_mounts(vec![(
2168 folder_mount("specs", mem_dir),
2169 Box::new(writer) as Box<dyn MemBackend>,
2170 )])
2171 .unwrap();
2172 assert!(
2173 engine.workspace_root().is_none(),
2174 "from_mounts has no workspace path",
2175 );
2176 assert!(engine.load_warnings().is_empty());
2177 }
2178
2179 #[test]
2180 fn health_omits_outer_repo_warning_when_workspace_root_unset() {
2181 let tmp = TempDir::new().unwrap();
2182 let mem_dir = tmp.path().to_path_buf();
2183 let writer = FilesystemMemWriter::new(mem_dir.clone());
2184 let engine = Engine::from_mounts(vec![(
2185 folder_mount("specs", mem_dir),
2186 Box::new(writer) as Box<dyn MemBackend>,
2187 )])
2188 .unwrap();
2189 let health = engine.health();
2190 assert!(
2191 !health
2192 .warnings
2193 .iter()
2194 .any(|w| matches!(w, WarningHint::OuterRepoNotIgnoringMemRepo { .. })),
2195 "outer-repo check must skip when workspace_root is None",
2196 );
2197 }
2198
2199 #[test]
2200 fn writable_mem_names_filters_by_capability() {
2201 let tmp = TempDir::new().unwrap();
2202 let mem_dir = tmp.path().to_path_buf();
2203 let writer = FilesystemMemWriter::new(mem_dir.clone());
2204 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2205
2206 let engine = Engine::from_mounts(vec![
2207 (
2208 folder_mount("writable", mem_dir),
2209 Box::new(writer) as Box<dyn MemBackend>,
2210 ),
2211 (
2212 archive_mount("sealed", archive_path.clone()),
2213 Box::new(ArchiveBackend::new(archive_path)),
2214 ),
2215 ])
2216 .unwrap();
2217
2218 let names = engine.writable_mem_names();
2221 assert_eq!(names, vec!["writable"]);
2222 }
2223
2224 #[test]
2231 fn default_writable_mem_is_declaration_first_not_alphabetical() {
2232 let tmp = TempDir::new().unwrap();
2233 let test_dir = tmp.path().join("test");
2234 let other_dir = tmp.path().join("other");
2235 std::fs::create_dir_all(&test_dir).unwrap();
2236 std::fs::create_dir_all(&other_dir).unwrap();
2237
2238 let engine = Engine::from_mounts(vec![
2239 (
2240 folder_mount("test", test_dir.clone()),
2241 Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
2242 ),
2243 (
2244 folder_mount("other", other_dir.clone()),
2245 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
2246 ),
2247 ])
2248 .unwrap();
2249
2250 assert_eq!(
2251 engine.default_writable_mem(),
2252 Some("test"),
2253 "default must be the declaration-first writable mem, not the alphabetically-first",
2254 );
2255 }
2256
2257 #[test]
2262 fn default_writable_mem_follows_declaration_order() {
2263 let tmp = TempDir::new().unwrap();
2264 let other_dir = tmp.path().join("other");
2265 let test_dir = tmp.path().join("test");
2266 std::fs::create_dir_all(&other_dir).unwrap();
2267 std::fs::create_dir_all(&test_dir).unwrap();
2268
2269 let engine = Engine::from_mounts(vec![
2270 (
2271 folder_mount("other", other_dir.clone()),
2272 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
2273 ),
2274 (
2275 folder_mount("test", test_dir.clone()),
2276 Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
2277 ),
2278 ])
2279 .unwrap();
2280
2281 assert_eq!(engine.default_writable_mem(), Some("other"));
2282 }
2283
2284 #[test]
2286 fn default_writable_mem_none_without_writable_mount() {
2287 let tmp = TempDir::new().unwrap();
2288 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2289 let engine = Engine::from_mounts(vec![(
2290 archive_mount("sealed", archive_path.clone()),
2291 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2292 )])
2293 .unwrap();
2294 assert_eq!(engine.default_writable_mem(), None);
2295 }
2296
2297 #[test]
2298 fn folder_path_for_mem_returns_path_for_folder_mounts_only() {
2299 let tmp = TempDir::new().unwrap();
2300 let mem_dir = tmp.path().join("specs");
2301 std::fs::create_dir_all(&mem_dir).unwrap();
2302 let writer = FilesystemMemWriter::new(mem_dir.clone());
2303 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2304
2305 let engine = Engine::from_mounts(vec![
2306 (
2307 folder_mount("specs", mem_dir.clone()),
2308 Box::new(writer) as Box<dyn MemBackend>,
2309 ),
2310 (
2311 archive_mount("sealed", archive_path.clone()),
2312 Box::new(ArchiveBackend::new(archive_path)),
2313 ),
2314 ])
2315 .unwrap();
2316
2317 assert_eq!(engine.folder_path_for_mem("specs"), Some(mem_dir.as_path()),);
2319 assert_eq!(engine.folder_path_for_mem("sealed"), None);
2321 assert_eq!(engine.folder_path_for_mem("missing"), None);
2323 }
2324
2325 #[test]
2326 fn mount_accessor_returns_public_mount_shape() {
2327 let tmp = TempDir::new().unwrap();
2334 let mem_dir = tmp.path().to_path_buf();
2335 let writer = FilesystemMemWriter::new(mem_dir.clone());
2336 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2337
2338 let engine = Engine::from_mounts(vec![
2339 (
2340 folder_mount("writable", mem_dir.clone()),
2341 Box::new(writer) as Box<dyn MemBackend>,
2342 ),
2343 (
2344 archive_mount("sealed", archive_path.clone()),
2345 Box::new(ArchiveBackend::new(archive_path.clone())),
2346 ),
2347 ])
2348 .unwrap();
2349
2350 let folder = engine.mount("writable").expect("known mem");
2353 assert!(matches!(folder.storage, MountStorage::Folder { .. }));
2354 assert_eq!(folder.capability, MountCapability::Write);
2355
2356 let archive = engine.mount("sealed").expect("known mem");
2357 match &archive.storage {
2358 MountStorage::Archive { path } => assert_eq!(path, &archive_path),
2359 other => panic!("expected Archive storage, got {other:?}"),
2360 }
2361 assert_eq!(archive.capability, MountCapability::ReadOnly);
2362
2363 assert!(engine.mount("missing").is_none());
2365
2366 let mounts = engine.mounts();
2368 assert_eq!(mounts.len(), 2);
2369 assert_eq!(mounts[0].mem, "writable");
2370 assert_eq!(mounts[1].mem, "sealed");
2371 }
2372
2373 #[test]
2374 fn mem_router_writable_set_matches_writable_mount_capability() {
2375 let tmp = TempDir::new().unwrap();
2379 let mem_dir = tmp.path().join("specs");
2380 std::fs::create_dir_all(&mem_dir).unwrap();
2381 let writer = FilesystemMemWriter::new(mem_dir.clone());
2382 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2383
2384 let engine = Engine::from_mounts(vec![
2385 (
2386 folder_mount("specs", mem_dir.clone()),
2387 Box::new(writer) as Box<dyn MemBackend>,
2388 ),
2389 (
2390 archive_mount("ext", archive_path.clone()),
2391 Box::new(ArchiveBackend::new(archive_path)),
2392 ),
2393 ])
2394 .unwrap();
2395
2396 let router = engine.mem_router();
2397 assert!(router.is_writable("specs"));
2398 assert!(!router.is_writable("ext"));
2399 assert!(router.is_visible("specs"));
2400 assert!(router.is_visible("ext"));
2401 let writable: std::collections::HashSet<&String> = router.writable_mems().iter().collect();
2402 assert_eq!(writable.len(), 1);
2403 assert!(writable.contains(&"specs".to_string()));
2404 }
2405
2406 #[test]
2407 fn mem_router_origin_is_explicit_toml_for_workspace_mounts() {
2408 let tmp = TempDir::new().unwrap();
2414 let mem_dir = tmp.path().join("specs");
2415 std::fs::create_dir_all(&mem_dir).unwrap();
2416 let writer = FilesystemMemWriter::new(mem_dir.clone());
2417
2418 let engine = Engine::from_mounts(vec![(
2419 folder_mount("specs", mem_dir),
2420 Box::new(writer) as Box<dyn MemBackend>,
2421 )])
2422 .unwrap();
2423
2424 let origin = engine
2425 .mem_router()
2426 .origin_for_mem("specs")
2427 .expect("known mem");
2428 assert_eq!(origin.kind(), "explicit");
2429 }
2430
2431 #[test]
2432 fn mem_router_dir_for_writable_folder_mount_matches_storage_path() {
2433 let tmp = TempDir::new().unwrap();
2437 let mem_dir = tmp.path().join("specs");
2438 std::fs::create_dir_all(&mem_dir).unwrap();
2439 let writer = FilesystemMemWriter::new(mem_dir.clone());
2440
2441 let engine = Engine::from_mounts(vec![(
2442 folder_mount("specs", mem_dir.clone()),
2443 Box::new(writer) as Box<dyn MemBackend>,
2444 )])
2445 .unwrap();
2446
2447 assert_eq!(
2448 engine.mem_router().dir_for_mem("specs"),
2449 Some(mem_dir.as_path()),
2450 );
2451 assert_eq!(engine.mem_router().dir_for_mem("unknown"), None);
2452 }
2453
2454 #[test]
2455 fn mem_router_archive_path_for_read_only_archive_mount() {
2456 let tmp = TempDir::new().unwrap();
2460 let mem_dir = tmp.path().join("specs");
2461 std::fs::create_dir_all(&mem_dir).unwrap();
2462 let writer = FilesystemMemWriter::new(mem_dir.clone());
2463 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2464
2465 let engine = Engine::from_mounts(vec![
2466 (
2467 folder_mount("specs", mem_dir),
2468 Box::new(writer) as Box<dyn MemBackend>,
2469 ),
2470 (
2471 archive_mount("ext", archive_path.clone()),
2472 Box::new(ArchiveBackend::new(archive_path.clone())),
2473 ),
2474 ])
2475 .unwrap();
2476
2477 let router = engine.mem_router();
2478 assert_eq!(
2479 router.archive_path_for_mem("ext"),
2480 Some(archive_path.as_path()),
2481 );
2482 assert_eq!(router.archive_path_for_mem("specs"), None);
2484 }
2485
2486 #[test]
2487 fn read_mem_config_via_backend_trait_folder_reads_bytes() {
2488 let tmp = TempDir::new().unwrap();
2492 let mem_dir = tmp.path().to_path_buf();
2493 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2494 let body = br#"{
2495 "format": 1,
2496 "schema": "default@1.0.0",
2497 "writeGuidance": { "tone": "neutral" }
2498 }"#;
2499 std::fs::write(mem_dir.join(".memstead").join("config.json"), body).unwrap();
2500
2501 let writer = FilesystemMemWriter::new(mem_dir);
2502 let result = MemBackend::read_mem_config(&writer).unwrap();
2503 let bytes = result.expect("config bytes must surface");
2504 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2505 assert_eq!(parsed["schema"], "default@1.0.0");
2506 }
2507
2508 #[test]
2509 fn read_mem_config_via_backend_trait_folder_missing_returns_none() {
2510 let tmp = TempDir::new().unwrap();
2511 let mem_dir = tmp.path().to_path_buf();
2512 let writer = FilesystemMemWriter::new(mem_dir);
2513 let result = MemBackend::read_mem_config(&writer).unwrap();
2514 assert!(result.is_none());
2515 }
2516
2517 #[test]
2518 fn read_mem_config_via_backend_trait_archive_reads_bytes() {
2519 let tmp = TempDir::new().unwrap();
2522 let archive_path = tmp.path().join("seed.mem");
2523 let body = br#"{
2524 "format": 1,
2525 "schema": "default@1.0.0",
2526 "writeGuidance": { "tone": "archive" }
2527 }"#;
2528 {
2529 let file = std::fs::File::create(&archive_path).unwrap();
2530 let mut writer = zip::ZipWriter::new(file);
2531 writer
2532 .start_file(
2533 ".memstead/config.json",
2534 zip::write::SimpleFileOptions::default(),
2535 )
2536 .unwrap();
2537 use std::io::Write;
2538 writer.write_all(body).unwrap();
2539 writer.finish().unwrap();
2540 }
2541
2542 let backend = ArchiveBackend::new(archive_path);
2543 let result = MemBackend::read_mem_config(&backend).unwrap();
2544 let bytes = result.expect("config bytes must surface");
2545 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2546 assert_eq!(parsed["writeGuidance"]["tone"], "archive");
2547 }
2548
2549 #[test]
2550 fn mem_config_for_returns_none_when_no_config_file_present() {
2551 let tmp = TempDir::new().unwrap();
2554 let mem_dir = tmp.path().to_path_buf();
2555 let writer = FilesystemMemWriter::new(mem_dir.clone());
2556 let engine = Engine::from_mounts(vec![(
2557 folder_mount("specs", mem_dir),
2558 Box::new(writer) as Box<dyn MemBackend>,
2559 )])
2560 .unwrap();
2561 assert!(engine.mem_config_for("specs").is_none());
2562 }
2563
2564 #[test]
2565 fn mem_config_for_returns_some_when_config_file_present() {
2566 let tmp = TempDir::new().unwrap();
2571 let mem_dir = tmp.path().to_path_buf();
2572 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2573 let config_body = r#"{
2574 "format": 1,
2575 "schema": "default@1.0.0",
2576 "writeGuidance": {
2577 "tone": "neutral",
2578 "voice": "active"
2579 }
2580 }"#;
2581 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
2582
2583 let writer = FilesystemMemWriter::new(mem_dir.clone());
2584 let engine = Engine::from_mounts(vec![(
2585 folder_mount("specs", mem_dir),
2586 Box::new(writer) as Box<dyn MemBackend>,
2587 )])
2588 .unwrap();
2589
2590 let cfg = engine
2591 .mem_config_for("specs")
2592 .expect("mem_config should load");
2593 assert_eq!(cfg.write_guidance.len(), 2);
2594 assert_eq!(
2595 cfg.write_guidance.get("tone").and_then(|v| v.as_str()),
2596 Some("neutral"),
2597 );
2598 assert_eq!(
2599 cfg.write_guidance.get("voice").and_then(|v| v.as_str()),
2600 Some("active"),
2601 );
2602 }
2603
2604 #[test]
2605 fn mem_config_for_unknown_mem_returns_none() {
2606 let tmp = TempDir::new().unwrap();
2608 let mem_dir = tmp.path().to_path_buf();
2609 let writer = FilesystemMemWriter::new(mem_dir.clone());
2610 let engine = Engine::from_mounts(vec![(
2611 folder_mount("specs", mem_dir),
2612 Box::new(writer) as Box<dyn MemBackend>,
2613 )])
2614 .unwrap();
2615 assert!(engine.mem_config_for("missing").is_none());
2616 }
2617
2618 #[test]
2619 fn mem_config_for_archive_mount_returns_none() {
2620 let tmp = TempDir::new().unwrap();
2623 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2624 let engine = Engine::from_mounts(vec![(
2625 archive_mount("ext", archive_path.clone()),
2626 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2627 )])
2628 .unwrap();
2629 assert!(engine.mem_config_for("ext").is_none());
2630 }
2631
2632 #[test]
2633 fn mem_configs_named_iterates_only_mounts_with_config() {
2634 let tmp = TempDir::new().unwrap();
2639 let with_config = tmp.path().join("specs");
2640 let without_config = tmp.path().join("memos");
2641 std::fs::create_dir_all(with_config.join(".memstead")).unwrap();
2642 std::fs::create_dir_all(&without_config).unwrap();
2643 let config_body = r#"{
2644 "format": 1,
2645 "schema": "default@1.0.0",
2646 "writeGuidance": { "tone": "neutral" }
2647 }"#;
2648 std::fs::write(
2649 with_config.join(".memstead").join("config.json"),
2650 config_body,
2651 )
2652 .unwrap();
2653
2654 let engine = Engine::from_mounts(vec![
2655 (
2656 folder_mount("specs", with_config.clone()),
2657 Box::new(FilesystemMemWriter::new(with_config)) as Box<dyn MemBackend>,
2658 ),
2659 (
2660 folder_mount("memos", without_config.clone()),
2661 Box::new(FilesystemMemWriter::new(without_config)) as Box<dyn MemBackend>,
2662 ),
2663 ])
2664 .unwrap();
2665
2666 let yielded: Vec<(&str, usize)> = engine
2667 .mem_configs_named()
2668 .map(|(name, cfg)| (name, cfg.write_guidance.len()))
2669 .collect();
2670 assert_eq!(yielded, vec![("specs", 1)]);
2671 }
2672
2673 #[test]
2674 fn schema_for_returns_some_for_known_mem_and_none_for_unknown() {
2675 let tmp = TempDir::new().unwrap();
2679 let mem_dir = tmp.path().to_path_buf();
2680 let writer = FilesystemMemWriter::new(mem_dir.clone());
2681 let engine = Engine::from_mounts(vec![(
2682 folder_mount("specs", mem_dir),
2683 Box::new(writer) as Box<dyn MemBackend>,
2684 )])
2685 .unwrap();
2686 assert!(engine.schema_for("specs").is_some());
2687 assert!(engine.schema_for("missing").is_none());
2688 }
2689
2690 #[test]
2691 fn gitdir_for_unknown_mem_returns_unknown_mem() {
2692 let tmp = TempDir::new().unwrap();
2693 let mem_dir = tmp.path().to_path_buf();
2694 let writer = FilesystemMemWriter::new(mem_dir.clone());
2695 let engine = Engine::from_mounts(vec![(
2696 folder_mount("specs", mem_dir),
2697 Box::new(writer) as Box<dyn MemBackend>,
2698 )])
2699 .unwrap();
2700 let err = engine.gitdir_for("missing").unwrap_err();
2701 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
2702 }
2703
2704 #[test]
2705 fn gitdir_for_folder_mount_returns_no_gitdir_error() {
2706 let tmp = TempDir::new().unwrap();
2709 let mem_dir = tmp.path().to_path_buf();
2710 let writer = FilesystemMemWriter::new(mem_dir.clone());
2711 let engine = Engine::from_mounts(vec![(
2712 folder_mount("specs", mem_dir),
2713 Box::new(writer) as Box<dyn MemBackend>,
2714 )])
2715 .unwrap();
2716 let err = engine.gitdir_for("specs").unwrap_err();
2717 match err {
2718 EngineError::Mem(msg) => assert!(msg.contains("no resolved gitdir")),
2719 other => panic!("expected EngineError::Mem, got {other:?}"),
2720 }
2721 }
2722
2723 #[test]
2724 fn worktree_for_folder_mount_returns_storage_path() {
2725 let tmp = TempDir::new().unwrap();
2726 let mem_dir = tmp.path().to_path_buf();
2727 let writer = FilesystemMemWriter::new(mem_dir.clone());
2728 let engine = Engine::from_mounts(vec![(
2729 folder_mount("specs", mem_dir.clone()),
2730 Box::new(writer) as Box<dyn MemBackend>,
2731 )])
2732 .unwrap();
2733 let worktree = engine.worktree_for("specs").unwrap();
2734 assert_eq!(worktree, mem_dir);
2735 }
2736
2737 #[test]
2738 fn worktree_for_unknown_mem_returns_unknown_mem() {
2739 let tmp = TempDir::new().unwrap();
2740 let mem_dir = tmp.path().to_path_buf();
2741 let writer = FilesystemMemWriter::new(mem_dir.clone());
2742 let engine = Engine::from_mounts(vec![(
2743 folder_mount("specs", mem_dir),
2744 Box::new(writer) as Box<dyn MemBackend>,
2745 )])
2746 .unwrap();
2747 let err = engine.worktree_for("missing").unwrap_err();
2748 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
2749 }
2750
2751 #[test]
2752 fn worktree_for_archive_mount_returns_archive_backed_error() {
2753 let tmp = TempDir::new().unwrap();
2754 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2755 let engine = Engine::from_mounts(vec![(
2756 archive_mount("ext", archive_path.clone()),
2757 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2758 )])
2759 .unwrap();
2760 let err = engine.worktree_for("ext").unwrap_err();
2761 match err {
2762 EngineError::Mem(msg) => assert!(msg.contains("archive-backed")),
2763 other => panic!("expected EngineError::Mem, got {other:?}"),
2764 }
2765 }
2766
2767 #[test]
2768 fn mem_head_sha_for_folder_mount_is_none() {
2769 let tmp = TempDir::new().unwrap();
2772 let mem_dir = tmp.path().to_path_buf();
2773 let writer = FilesystemMemWriter::new(mem_dir.clone());
2774 let engine = Engine::from_mounts(vec![(
2775 folder_mount("specs", mem_dir),
2776 Box::new(writer) as Box<dyn MemBackend>,
2777 )])
2778 .unwrap();
2779 let head = engine.mem_head_sha("specs").unwrap();
2780 assert_eq!(head, None);
2781 }
2782
2783 #[test]
2784 fn mem_head_sha_unknown_mem_returns_unknown_mem() {
2785 let tmp = TempDir::new().unwrap();
2786 let mem_dir = tmp.path().to_path_buf();
2787 let writer = FilesystemMemWriter::new(mem_dir.clone());
2788 let engine = Engine::from_mounts(vec![(
2789 folder_mount("specs", mem_dir),
2790 Box::new(writer) as Box<dyn MemBackend>,
2791 )])
2792 .unwrap();
2793 let err = engine.mem_head_sha("missing").unwrap_err();
2794 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
2795 }
2796
2797 #[test]
2798 fn capability_surfaces_per_mount() {
2799 let tmp = TempDir::new().unwrap();
2800 let mem_dir = tmp.path().to_path_buf();
2801 let writer = FilesystemMemWriter::new(mem_dir.clone());
2802 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2803
2804 let engine = Engine::from_mounts(vec![
2805 (
2806 folder_mount("writable", mem_dir),
2807 Box::new(writer) as Box<dyn MemBackend>,
2808 ),
2809 (
2810 archive_mount("read-only", archive_path.clone()),
2811 Box::new(ArchiveBackend::new(archive_path)),
2812 ),
2813 ])
2814 .unwrap();
2815
2816 assert_eq!(
2817 engine.capability("writable").unwrap(),
2818 MountCapability::Write
2819 );
2820 assert_eq!(
2821 engine.capability("read-only").unwrap(),
2822 MountCapability::ReadOnly
2823 );
2824 assert!(matches!(
2825 engine.capability("missing"),
2826 Err(EngineError::UnknownMem(_))
2827 ));
2828 }
2829
2830 #[test]
2831 fn read_provenance_routes_through_backend() {
2832 let tmp = TempDir::new().unwrap();
2833 let mem_dir = tmp.path().to_path_buf();
2834 let writer = FilesystemMemWriter::new(mem_dir.clone());
2835
2836 let backend_handle: &dyn MemBackend = &writer;
2839 backend_handle
2840 .append_provenance(&Provenance::new(
2841 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
2842 crate::ProvenanceKind::Create,
2843 Some("v:e".into()),
2844 crate::vcs::Actor::Cli,
2845 None,
2846 Some("first".into()),
2847 ))
2848 .unwrap();
2849
2850 let engine = Engine::from_mounts(vec![(
2851 folder_mount("specs", mem_dir),
2852 Box::new(writer) as Box<dyn MemBackend>,
2853 )])
2854 .unwrap();
2855
2856 let records = engine.read_provenance("specs", None).unwrap();
2857 assert_eq!(records.len(), 1);
2858 assert_eq!(records[0].kind, crate::ProvenanceKind::Create);
2859 assert_eq!(records[0].entity.as_deref(), Some("v:e"));
2860 assert_eq!(records[0].note.as_deref(), Some("first"));
2861 }
2862
2863 #[test]
2864 fn archive_mount_returns_sealed_indirectly_through_backend_layer() {
2865 let tmp = TempDir::new().unwrap();
2872 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2873 let backend = ArchiveBackend::new(archive_path);
2874 match MemBackend::write_entity(&backend, Path::new("x.md"), b"x") {
2875 Err(BackendError::Sealed) => {}
2876 other => panic!("expected Sealed, got {other:?}"),
2877 }
2878 }
2879
2880 fn build_demo_engine(tmp: &TempDir) -> Engine {
2890 let mem_dir = tmp.path().to_path_buf();
2891 let writer = FilesystemMemWriter::new(mem_dir.clone());
2892 let mut engine = Engine::from_mounts(vec![(
2893 folder_mount("specs", mem_dir),
2894 Box::new(writer) as Box<dyn MemBackend>,
2895 )])
2896 .unwrap();
2897 let (actor, client) = cli_actor();
2898 let source = engine
2899 .create_entity(
2900 empty_create_args("specs", "Source One"),
2901 actor,
2902 Some(&client),
2903 None,
2904 )
2905 .unwrap();
2906 let target = engine
2907 .create_entity(
2908 empty_create_args("specs", "Target Two"),
2909 actor,
2910 Some(&client),
2911 None,
2912 )
2913 .unwrap();
2914 engine
2915 .create_entity(
2916 empty_create_args("specs", "Lonely Three"),
2917 actor,
2918 Some(&client),
2919 None,
2920 )
2921 .unwrap();
2922 engine
2923 .relate_entity(
2924 RelateEntityArgs {
2925 source: source.id.clone(),
2926 expected_hash: Some(source.content_hash.clone()),
2927 rel_type: "USES".to_string(),
2928 target: target.id.clone(),
2929 remove: false,
2930 description: None,
2931 },
2932 actor,
2933 Some(&client),
2934 None,
2935 )
2936 .unwrap();
2937 engine
2938 }
2939
2940 #[test]
2941 fn status_reports_per_engine_counts() {
2942 let tmp = TempDir::new().unwrap();
2943 let engine = build_demo_engine(&tmp);
2944 let stats = engine.status();
2945 assert_eq!(stats.entity_count, 3);
2946 assert_eq!(stats.edge_count, 1);
2947 assert_eq!(stats.mem_count, 1);
2948 assert_eq!(stats.types_in_use, vec!["spec".to_string()]);
2949 assert_eq!(stats.edge_types.get("USES"), Some(&1));
2950 }
2951
2952 #[test]
2953 fn orphans_lists_unconnected_real_entities() {
2954 let tmp = TempDir::new().unwrap();
2955 let engine = build_demo_engine(&tmp);
2956 let orphans = engine.orphans();
2957 assert_eq!(orphans.len(), 1);
2958 assert_eq!(orphans[0].as_ref(), "specs--lonely-three");
2959 }
2960
2961 #[test]
2966 fn schema_breakdowns_attribute_to_mem_pin() {
2967 let tmp = TempDir::new().unwrap();
2968 let engine = build_demo_engine(&tmp);
2969
2970 let orphans = engine.orphans();
2971 let orphans_by_schema = engine.orphans_by_schema(&orphans);
2972 assert_eq!(
2973 orphans_by_schema.values().sum::<usize>(),
2974 orphans.len(),
2975 "per-schema orphan counts must sum to the raw total"
2976 );
2977 assert_eq!(orphans_by_schema.len(), 1, "one mem ⇒ one schema bucket");
2978 let (schema, count) = orphans_by_schema.iter().next().unwrap();
2979 assert!(!schema.is_empty(), "specs mem is pinned: {schema:?}");
2980 assert_eq!(*count, 1);
2981
2982 let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
2985 let communities_by_schema = engine.communities_by_schema(&mems);
2986 assert_eq!(communities_by_schema.len(), 1);
2987 assert_eq!(
2988 communities_by_schema.values().sum::<usize>(),
2989 engine.communities().count,
2990 );
2991 }
2992
2993 #[test]
2994 fn stubs_lists_unresolved_link_targets() {
2995 let tmp = TempDir::new().unwrap();
2996 let mem_dir = tmp.path().to_path_buf();
2997 let writer = FilesystemMemWriter::new(mem_dir.clone());
2998 let mut engine = Engine::from_mounts(vec![(
2999 folder_mount("specs", mem_dir),
3000 Box::new(writer) as Box<dyn MemBackend>,
3001 )])
3002 .unwrap();
3003 let (actor, client) = cli_actor();
3004 let source = engine
3005 .create_entity(
3006 empty_create_args("specs", "Holder"),
3007 actor,
3008 Some(&client),
3009 None,
3010 )
3011 .unwrap();
3012 engine
3015 .relate_entity(
3016 RelateEntityArgs {
3017 source: source.id.clone(),
3018 expected_hash: Some(source.content_hash.clone()),
3019 rel_type: "USES".to_string(),
3020 target: EntityId::new("specs", "ghost"),
3021 remove: false,
3022 description: None,
3023 },
3024 actor,
3025 Some(&client),
3026 None,
3027 )
3028 .unwrap();
3029 let stubs = engine.stubs();
3030 assert!(
3031 stubs.iter().any(|(id, _)| id.as_ref() == "specs--ghost"),
3032 "expected ghost stub: {stubs:?}"
3033 );
3034 }
3035
3036 #[test]
3037 fn most_connected_orders_by_degree() {
3038 let tmp = TempDir::new().unwrap();
3039 let engine = build_demo_engine(&tmp);
3040 let top = engine.most_connected(5);
3041 assert_eq!(top.len(), 3);
3042 let zero_degree: Vec<_> = top
3044 .iter()
3045 .filter(|c| c.total == 0)
3046 .map(|c| c.id.as_ref().to_string())
3047 .collect();
3048 assert_eq!(zero_degree, vec!["specs--lonely-three".to_string()]);
3049 }
3050
3051 #[test]
3052 fn health_returns_per_engine_summary() {
3053 let tmp = TempDir::new().unwrap();
3054 let engine = build_demo_engine(&tmp);
3055 let health = engine.health();
3056 assert!(
3064 health
3065 .missing_fields
3066 .iter()
3067 .all(|r| r.id.as_ref() != "specs--source-one"),
3068 "post-strict-create fixture must not surface as missing-fields; got {:?}",
3069 health.missing_fields,
3070 );
3071 }
3072
3073 #[test]
3074 fn context_carries_neighbors_and_community() {
3075 let tmp = TempDir::new().unwrap();
3076 let engine = build_demo_engine(&tmp);
3077 let source_id = EntityId::new("specs", "source-one");
3078 let ctx = engine.context(&source_id).unwrap();
3079 assert_eq!(ctx.entity_id, source_id);
3080 assert_eq!(ctx.neighbors.len(), 1);
3081 assert_eq!(ctx.neighbors[0].relationship, "USES");
3082 assert!(matches!(ctx.neighbors[0].direction, Direction::Outgoing));
3083 }
3084
3085 #[test]
3086 fn communities_caches_louvain_until_invalidated() {
3087 let tmp = TempDir::new().unwrap();
3088 let mut engine = build_demo_engine(&tmp);
3089 let entities_before = engine.communities().entity_cluster_map.len();
3091 assert_eq!(
3093 engine.communities().entity_cluster_map.len(),
3094 entities_before
3095 );
3096 let (actor, client) = cli_actor();
3099 engine
3100 .create_entity(
3101 empty_create_args("specs", "Disturber"),
3102 actor,
3103 Some(&client),
3104 None,
3105 )
3106 .unwrap();
3107 let entities_after = engine.communities().entity_cluster_map.len();
3108 assert_eq!(
3109 entities_after,
3110 entities_before + 1,
3111 "create_entity should have invalidated community cache and added the new entity"
3112 );
3113 }
3114
3115 #[test]
3116 fn list_filters_by_metadata_only() {
3117 let tmp = TempDir::new().unwrap();
3118 let engine = build_demo_engine(&tmp);
3119 let scope = SearchScope {
3120 entity_type: Some("spec".to_string()),
3121 ..Default::default()
3122 };
3123 let result = engine.list(&scope);
3124 assert_eq!(result.hits.len(), 3);
3126 }
3127
3128 #[test]
3129 fn list_applies_schema_declared_filter_on_non_default_schema_mem() {
3130 let tmp = TempDir::new().unwrap();
3137 let mem_dir = tmp.path().to_path_buf();
3138 let writer = FilesystemMemWriter::new(mem_dir.clone());
3139 let mount = Mount {
3140 mem: "planning".to_string(),
3141 schema: Some(memstead_schema::SchemaRef::new(
3142 "planning",
3143 semver::Version::new(0, 1, 0),
3144 )),
3145 storage: MountStorage::Folder { path: mem_dir },
3146 capability: MountCapability::Write,
3147 lifecycle: MountLifecycle::Eager,
3148 cross_linkable: true,
3149 migration_target: None,
3150 };
3151 let mut engine =
3152 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3153 let (actor, client) = cli_actor();
3154
3155 for (title, status) in &[("Skip Postgres", "accepted"), ("Use SQLite", "proposed")] {
3160 let mut metadata = indexmap::IndexMap::new();
3161 metadata.insert("status".to_string(), status.to_string());
3162 metadata.insert("deciders".to_string(), "alice".to_string());
3163 metadata.insert("decided_on".to_string(), "2026-05-19".to_string());
3164 let args = crate::engine::CreateEntityArgs {
3165 anchors: Vec::new(),
3166 mem: "planning".to_string(),
3167 title: title.to_string(),
3168 entity_type: "decision".to_string(),
3169 sections: indexmap::IndexMap::from_iter([
3170 ("decision".to_string(), "We chose this.".to_string()),
3171 ("context".to_string(), "Single-user dev.".to_string()),
3172 ("consequences".to_string(), "Lose multi-writer.".to_string()),
3173 ]),
3174 metadata,
3175 relations: Vec::new(),
3176 dry_run: false,
3177 };
3178 engine
3179 .create_entity(args, actor, Some(&client), None)
3180 .unwrap();
3181 }
3182
3183 let scope = SearchScope {
3185 entity_type: Some("decision".to_string()),
3186 filters: std::collections::HashMap::from([(
3187 "status".to_string(),
3188 "accepted".to_string(),
3189 )]),
3190 ..Default::default()
3191 };
3192 let result = engine.list(&scope);
3193 assert_eq!(
3194 result.hits.len(),
3195 1,
3196 "filter on schema-declared field must select only matching entities"
3197 );
3198 assert_eq!(result.hits[0].title, "Skip Postgres");
3199 assert!(
3200 result.warnings.is_empty(),
3201 "no warning should fire when the filter is declared by the mem's pinned schema: {:?}",
3202 result.warnings
3203 );
3204 }
3205
3206 #[test]
3207 fn search_returns_results_against_built_index() {
3208 let tmp = TempDir::new().unwrap();
3209 let engine = build_demo_engine(&tmp);
3210 let scope = SearchScope {
3211 query: Some(crate::ops::Query {
3212 any: vec!["source".to_string()],
3213 ..Default::default()
3214 }),
3215 ..Default::default()
3216 };
3217 let result = engine.search(&scope).expect("native search returns Ok");
3218 assert!(result.total >= 1, "expected ≥1 hit for source: {result:?}");
3219 assert!(
3220 result
3221 .hits
3222 .iter()
3223 .any(|h| h.id.as_ref() == "specs--source-one"),
3224 "expected source-one in hits: {result:?}"
3225 );
3226 }
3227
3228 }