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 {
137 if self.mem_router().is_writable(mem) {
138 crate::render::OriginClass::FirstParty
139 } else {
140 crate::render::OriginClass::ThirdParty
141 }
142 }
143
144 pub fn load_errors(&self) -> &[(PathBuf, String)] {
148 &self.load_errors
149 }
150
151 pub fn settings(&self) -> &WorkspaceSettings {
158 &self.settings
159 }
160
161 pub fn pipeline_configs(&self) -> &crate::pipeline_store::PipelineConfigs {
168 &self.pipeline_configs
169 }
170
171 pub fn pipeline_configs_json(&self) -> String {
180 serde_json::to_string(&self.pipeline_configs).unwrap_or_else(|_| {
181 "{\"mediums\":[],\"facets\":[],\"projections\":[],\"ingests\":[]}".to_string()
182 })
183 }
184
185 pub fn set_pipeline_configs(&mut self, configs: crate::pipeline_store::PipelineConfigs) {
190 self.pipeline_configs = configs;
191 }
192
193 pub fn note_missing_warning(&self, tool: &str, note: Option<&str>) -> Option<WarningHint> {
210 if !self.settings.mutations.require_notes.unwrap_or(false) {
211 return None;
212 }
213 let has_note = note.map(|n| !n.trim().is_empty()).unwrap_or(false);
214 if has_note {
215 return None;
216 }
217 Some(WarningHint::NoteMissing {
218 tool: tool.to_string(),
219 })
220 }
221
222 pub fn backend_factory(&self) -> BackendFactory {
228 self.backend_factory
229 }
230
231 pub fn git_branch_ops(&self) -> Option<super::GitBranchOps> {
238 self.git_branch_ops
239 }
240
241 pub fn get_entity(&self, id: &EntityId) -> Option<&Entity> {
246 self.store.get(id)
247 }
248
249 pub fn mem_names(&self) -> Vec<&str> {
253 self.mounts.iter().map(|m| m.mount.mem.as_str()).collect()
254 }
255
256 pub fn mount(&self, mem: &str) -> Option<&crate::workspace::Mount> {
274 self.mounts
275 .iter()
276 .find(|m| m.mount.mem == mem)
277 .map(|m| &m.mount)
278 }
279
280 pub fn orphans_by_schema(
287 &self,
288 orphan_ids: &[EntityId],
289 ) -> std::collections::BTreeMap<String, usize> {
290 let mut by_schema = std::collections::BTreeMap::new();
291 for id in orphan_ids {
292 let schema = self
293 .store()
294 .get(id)
295 .and_then(|e| self.mount(&e.mem))
296 .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
297 .unwrap_or_default();
298 *by_schema.entry(schema).or_insert(0) += 1;
299 }
300 by_schema
301 }
302
303 pub fn communities_by_schema(
309 &self,
310 mems: &[String],
311 ) -> std::collections::BTreeMap<String, usize> {
312 let louvain = self.communities();
313 let mut buckets: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
314 std::collections::BTreeMap::new();
315 for name in mems {
316 let schema = self
317 .mount(name)
318 .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
319 .unwrap_or_default();
320 let clusters = crate::graph::community::clusters_in_mem(self.store(), louvain, name);
321 buckets.entry(schema).or_default().extend(clusters);
322 }
323 buckets
324 .into_iter()
325 .map(|(schema, set)| (schema, set.len()))
326 .collect()
327 }
328
329 pub fn mounts(&self) -> Vec<&crate::workspace::Mount> {
333 self.mounts.iter().map(|m| &m.mount).collect()
334 }
335
336 pub fn writable_mem_names(&self) -> Vec<&str> {
344 self.mounts
345 .iter()
346 .filter(|m| m.mount.capability == MountCapability::Write)
347 .map(|m| m.mount.mem.as_str())
348 .collect()
349 }
350
351 pub fn default_writable_mem(&self) -> Option<&str> {
369 self.mounts
370 .iter()
371 .find(|m| m.mount.capability == MountCapability::Write)
372 .map(|m| m.mount.mem.as_str())
373 }
374
375 pub fn folder_path_for_mem(&self, mem: &str) -> Option<&Path> {
383 match self.mount(mem).map(|m| &m.storage) {
384 Some(crate::workspace::MountStorage::Folder { path }) => Some(path.as_path()),
385 _ => None,
386 }
387 }
388
389 pub fn mem_router(&self) -> &MemRouterSnapshot {
399 &self.mem_router
400 }
401
402 pub fn gitdir_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
411 let m = self
412 .mount(mem_name)
413 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
414 match &m.storage {
415 MountStorage::GitBranch { gitdir, .. } => Ok(gitdir.clone()),
416 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
417 Err(EngineError::Mem(format!(
418 "mem '{mem_name}' has no resolved gitdir"
419 )))
420 }
421 }
422 }
423
424 pub fn worktree_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
439 let m = self
440 .mount(mem_name)
441 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
442 match &m.storage {
443 MountStorage::Folder { path } => Ok(path.clone()),
444 MountStorage::GitBranch { .. } => {
445 if let Some(root) = self.workspace_root.as_deref() {
446 let candidate = root.join(mem_name);
447 if candidate
448 .join(crate::mem::MEM_META_DIR)
449 .join("config.json")
450 .is_file()
451 {
452 return Ok(candidate.canonicalize().unwrap_or(candidate));
453 }
454 }
455 Err(EngineError::Mem(format!(
456 "mem '{mem_name}' has no working tree (mem-repo-backed)"
457 )))
458 }
459 MountStorage::Archive { .. } => Err(EngineError::Mem(format!(
460 "mem '{mem_name}' is archive-backed and has no worktree"
461 ))),
462 MountStorage::InMemory => Err(EngineError::Mem(format!(
463 "mem '{mem_name}' is in-memory and has no worktree"
464 ))),
465 }
466 }
467
468 pub fn mem_config_for(&self, mem: &str) -> Option<&memstead_schema::config::MemConfig> {
486 self.mounts
487 .iter()
488 .find(|m| m.mount.mem == mem)
489 .and_then(|m| m.mem_config.as_ref())
490 }
491
492 pub fn archive_provenance_for(&self, mem: &str) -> Option<&memstead_schema::ArchiveProvenance> {
499 self.mounts
500 .iter()
501 .find(|m| m.mount.mem == mem)
502 .and_then(|m| m.archive_provenance.as_ref())
503 }
504
505 pub fn mem_configs_named(
516 &self,
517 ) -> impl Iterator<Item = (&str, &memstead_schema::config::MemConfig)> {
518 self.mounts
519 .iter()
520 .filter_map(|m| m.mem_config.as_ref().map(|c| (m.mount.mem.as_str(), c)))
521 }
522
523 pub fn schema_for(&self, mem: &str) -> Option<std::sync::Arc<memstead_schema::Schema>> {
530 self.schemas.get(mem).cloned()
531 }
532
533 pub fn mem_head_sha(&self, mem_name: &str) -> Result<Option<String>, EngineError> {
544 let m = self
545 .mounts
546 .iter()
547 .find(|m| m.mount.mem == mem_name)
548 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
549 Ok(m.last_known_head.clone())
550 }
551
552 pub fn mem_drifted(&self, mem_name: &str) -> Result<bool, EngineError> {
567 let m = self
568 .mounts
569 .iter()
570 .find(|m| m.mount.mem == mem_name)
571 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
572 let live = m.backend.current_head().ok().flatten();
573 Ok(live != m.last_known_head)
574 }
575
576 pub fn workspace_root(&self) -> Option<&Path> {
581 self.workspace_root.as_deref()
582 }
583
584 pub fn load_warnings(&self) -> &[WarningHint] {
589 &self.load_warnings
590 }
591
592 pub fn communities(&self) -> &LouvainOutput {
621 self.community_memo.get_or_init(|| {
622 let schema = self
629 .schemas
630 .iter()
631 .min_by(|a, b| a.0.cmp(b.0))
632 .map(|(_, s)| s.clone())
633 .unwrap_or_else(Schema::builtin_default);
634 let manifest = &schema.manifest;
635 let resolution = manifest.community.resolution;
636 let seed = manifest.community.seed;
637 let schema_for_weights = schema.clone();
638 detect_communities(&self.store, resolution, seed, move |rel_type| {
639 schema_for_weights
640 .manifest
641 .relationships
642 .definitions
643 .iter()
644 .find(|d| d.name == rel_type)
645 .map(|d| d.default_weight as f64)
646 .unwrap_or(1.0)
647 })
648 })
649 }
650
651 pub fn invalidate_communities(&mut self) {
653 self.community_memo = OnceCell::new();
654 }
655
656 pub fn orphans(&self) -> Vec<EntityId> {
658 crate::graph::query::find_orphans(&self.store)
659 }
660
661 pub fn stubs(&self) -> Vec<(EntityId, Vec<EntityId>)> {
663 crate::graph::query::find_stubs(&self.store)
664 }
665
666 pub fn most_connected(&self, limit: usize) -> Vec<crate::graph::query::Connectivity> {
668 crate::graph::query::most_connected(&self.store, limit)
669 }
670
671 pub fn missing_required_outgoing(
675 &self,
676 mem_filter: Option<&str>,
677 ) -> Vec<crate::ops::health::MissingRequiredOutgoingReport> {
678 crate::ops::health::collect_missing_required_outgoing(
679 &self.store,
680 mem_filter,
681 &self.schemas,
682 )
683 }
684
685 pub fn conformance_findings(
691 &self,
692 mem: &str,
693 target_schema: Option<&memstead_schema::SchemaRef>,
694 ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
695 let pinned = self
696 .schemas
697 .get(mem)
698 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
699 let effective: Arc<Schema> = match target_schema {
700 None => pinned.clone(),
701 Some(target) => self.resolve_schema_by_ref(target).ok_or_else(|| {
702 let consulted: Vec<_> = self
703 .workspace_schemas
704 .iter()
705 .chain(self.builtin_schemas.iter())
706 .cloned()
707 .collect();
708 EngineError::SchemaNotFound {
709 mem: mem.to_string(),
710 pin: target.as_display(),
711 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
712 &target.name,
713 &target.version,
714 &consulted,
715 ),
716 }
717 })?,
718 };
719 Ok(crate::ops::integrity::conformance_findings(
720 &self.store,
721 mem,
722 &effective,
723 &self.schemas,
724 ))
725 }
726
727 pub(crate) fn resolve_schema_by_ref(
731 &self,
732 target: &memstead_schema::SchemaRef,
733 ) -> Option<Arc<Schema>> {
734 self.schemas
735 .values()
736 .chain(self.workspace_schemas.iter())
737 .chain(self.builtin_schemas.iter())
738 .find(|s| {
739 let (name, version) = s.id();
740 name == target.name && version == target.version
741 })
742 .cloned()
743 }
744
745 pub fn schema_pin(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
750 self.mounts
751 .iter()
752 .find(|m| m.mount.mem == mem)
753 .and_then(|m| m.mount.schema.clone())
754 }
755
756 pub fn migration_target(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
759 self.mounts
760 .iter()
761 .find(|m| m.mount.mem == mem)
762 .and_then(|m| m.mount.migration_target.clone())
763 }
764
765 pub fn consistency_findings(
769 &self,
770 mem: &str,
771 ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
772 if !self.schemas.contains_key(mem) {
773 return Err(EngineError::UnknownMem(mem.to_string()));
774 }
775 Ok(crate::ops::integrity::consistency_findings(
776 &self.store,
777 mem,
778 ))
779 }
780
781 pub fn health(&self) -> crate::ops::HealthSummary {
783 let fallback = engine_fallback_type();
784 let mut summary =
785 crate::ops::health::compute_health(&self.store, fallback.as_ref(), &self.schemas);
786 if !self.load_warnings.is_empty() {
793 let mut merged = self.load_warnings.clone();
794 merged.append(&mut summary.warnings);
795 summary.warnings = merged;
796 }
797 if let Some(root) = self.workspace_root.as_deref()
803 && let Some(outer) = crate::workspace_root::find_enclosing_git_repo(root)
804 && !crate::workspace_root::outer_repo_ignores_mem_repo(&outer, root)
805 {
806 summary
807 .warnings
808 .push(WarningHint::OuterRepoNotIgnoringMemRepo {
809 outer_repo_root: outer.display().to_string(),
810 workspace_root: root.display().to_string(),
811 });
812 }
813 summary
814 }
815
816 pub fn stats(&self) -> crate::ops::Stats {
818 let mut types_in_use: Vec<String> = self
819 .store
820 .all_entities()
821 .filter(|e| !e.stub && !e.entity_type.is_empty())
822 .map(|e| e.entity_type.clone())
823 .collect();
824 types_in_use.sort();
825 types_in_use.dedup();
826
827 let mut edge_types: HashMap<String, usize> = HashMap::new();
828 for id in self.store.all_ids() {
829 for edge in self.store.outgoing(id) {
830 *edge_types.entry(edge.rel_type.clone()).or_insert(0) += 1;
831 }
832 }
833
834 crate::ops::Stats {
835 entity_count: self.store.all_entities().filter(|e| !e.stub).count(),
836 edge_count: self.store.edge_count(),
837 edge_types,
838 community_count: self.communities().count,
839 mem_count: self.mounts.len(),
840 types_in_use,
841 }
842 }
843
844 pub fn context(&self, id: &EntityId) -> Option<ContextResult> {
848 let entity = self.store.get(id)?;
849 let community = self
850 .communities()
851 .entity_cluster_map
852 .get(id.as_ref())
853 .cloned();
854 let mut neighbors = Vec::new();
855 for edge in self.store.outgoing(id) {
856 if let Some(target) = self.store.get(&edge.target) {
857 neighbors.push(NeighborInfo {
858 id: target.id.clone(),
859 title: target.title.clone(),
860 relationship: edge.rel_type.clone(),
861 direction: Direction::Outgoing,
862 });
863 }
864 }
865 for edge in self.store.incoming(id) {
866 if let Some(source) = self.store.get(&edge.from) {
867 neighbors.push(NeighborInfo {
868 id: source.id.clone(),
869 title: source.title.clone(),
870 relationship: edge.rel_type.clone(),
871 direction: Direction::Incoming,
872 });
873 }
874 }
875 Some(ContextResult {
876 entity_id: entity.id.clone(),
877 community,
878 neighbors,
879 })
880 }
881
882 #[cfg(not(target_arch = "wasm32"))]
888 pub fn search_indexes(&self) -> &HashMap<String, MemIndex> {
889 self.search_indexes_memo
890 .get_or_init(|| build_all(&self.store, &self.schemas))
891 }
892
893 pub fn invalidate_search_indexes(&mut self) {
897 #[cfg(not(target_arch = "wasm32"))]
898 {
899 self.search_indexes_memo = OnceCell::new();
900 }
901 }
902
903 #[cfg(not(target_arch = "wasm32"))]
905 pub fn list(&self, scope: &SearchScope) -> crate::ops::ListResult {
906 let fallback = engine_fallback_type();
907 crate::ops::search::list(&self.store, scope, fallback.as_ref(), &self.schemas)
908 }
909
910 pub fn search(&self, scope: &SearchScope) -> Result<SearchResult, EngineError> {
916 #[cfg(target_arch = "wasm32")]
917 {
918 let _ = scope;
919 return Err(EngineError::SearchUnavailable);
920 }
921 #[cfg(not(target_arch = "wasm32"))]
922 {
923 let fallback = engine_fallback_type();
924 Ok(crate::ops::search::search(
925 &self.store,
926 scope,
927 fallback.as_ref(),
928 self.search_indexes(),
929 &self.schemas,
930 ))
931 }
932 }
933
934 pub fn list_entities(&self, mem: &str) -> Result<Vec<PathBuf>, EngineError> {
937 let m = self.find_mount(mem)?;
938 m.backend.list_entities().map_err(EngineError::Backend)
939 }
940
941 pub fn read_entity(&self, mem: &str, rel_path: &Path) -> Result<Option<Vec<u8>>, EngineError> {
943 let m = self.find_mount(mem)?;
944 m.backend
945 .read_entity(rel_path)
946 .map_err(EngineError::Backend)
947 }
948
949 pub fn read_provenance(
953 &self,
954 mem: &str,
955 cursor: Option<&str>,
956 ) -> Result<Vec<Provenance>, EngineError> {
957 let m = self.find_mount(mem)?;
958 m.backend
959 .read_provenance(cursor)
960 .map_err(EngineError::Backend)
961 }
962
963 pub fn capability(&self, mem: &str) -> Result<crate::workspace::MountCapability, EngineError> {
968 let m = self.find_mount(mem)?;
969 Ok(m.mount.capability)
970 }
971
972 pub fn edge_is_from_readonly(&self, from: &EntityId) -> bool {
987 match self.capability(from.mem()) {
988 Ok(crate::workspace::MountCapability::ReadOnly) => true,
989 Ok(crate::workspace::MountCapability::Write) | Err(_) => false,
990 }
991 }
992
993 pub fn cross_mem_link_allowed(&self, from_mem: &str, to_mem: &str) -> bool {
1027 use memstead_schema::workspace_config::CrossLinkValue;
1028 if from_mem == to_mem {
1029 return true;
1030 }
1031
1032 if let Some(value) = self.settings.cross_mem_links.get(from_mem) {
1034 match value {
1035 CrossLinkValue::Wildcard => return true,
1036 CrossLinkValue::List(targets) => {
1037 if targets.iter().any(|t| t == to_mem) {
1038 return true;
1039 }
1040 }
1044 }
1045 }
1046
1047 let rule_set = self.create_rule_set_memo.get_or_init(|| {
1049 crate::mem_management::CreateRuleSet::new(
1050 self.settings.mem_create_rules.clone(),
1051 )
1052 .unwrap_or_else(|err| {
1053 tracing::warn!(
1054 error = %err,
1055 "cross_mem_link_allowed: failed to compile mem_create_rules — synthesis disabled (resolver falls back to explicit-policy-only)"
1056 );
1057 crate::mem_management::CreateRuleSet::default()
1058 })
1059 });
1060
1061 let candidate = match self.mount(from_mem).and_then(|m| m.mem_path()) {
1070 Some(path) => format!("{path}/{from_mem}"),
1071 None => from_mem.to_string(),
1072 };
1073 if let Some(matched) = rule_set.first_match(std::path::Path::new(&candidate))
1074 && let Some(synth) = matched.default_cross_links.as_ref()
1075 {
1076 return match synth {
1077 CrossLinkValue::Wildcard => true,
1078 CrossLinkValue::List(targets) => targets.iter().any(|t| t == to_mem),
1079 };
1080 }
1081
1082 false
1083 }
1084
1085 pub(super) fn find_mount(&self, mem: &str) -> Result<&MountedBackend, EngineError> {
1086 self.mounts
1087 .iter()
1088 .find(|m| m.mount.mem == mem)
1089 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use std::path::Path;
1096
1097 use tempfile::TempDir;
1098
1099 use crate::backend::{BackendError, MemBackend};
1100 use crate::engine::test_helpers::*;
1101 use crate::engine::{Engine, EngineError, RelateEntityArgs};
1102 use crate::entity::EntityId;
1103 use crate::ops::{Direction, SearchScope, WarningHint};
1104 use crate::provenance::Provenance;
1105 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
1106
1107 use crate::vcs::CommitContext;
1108 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1109
1110 #[test]
1115 fn schema_origin_classifies_builtin_first_party_and_unknown_third_party() {
1116 use std::sync::Arc;
1117
1118 use crate::render::OriginClass;
1119
1120 let tmp = TempDir::new().unwrap();
1121 let engine = Engine::from_mounts(vec![(
1122 folder_mount("specs", tmp.path().to_path_buf()),
1123 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf())) as Box<dyn MemBackend>,
1124 )])
1125 .unwrap();
1126
1127 let builtin = engine.builtin_schemas()[0].clone();
1129 assert_eq!(
1130 engine.schema_origin(&builtin),
1131 OriginClass::FirstParty,
1132 "a built-in schema is first-party"
1133 );
1134
1135 let foreign = Arc::new(memstead_schema::Schema {
1139 manifest: builtin.manifest.clone(),
1140 version: semver::Version::new(99, 0, 0),
1141 types: builtin.types.clone(),
1142 });
1143 assert_eq!(
1144 engine.schema_origin(&foreign),
1145 OriginClass::ThirdParty,
1146 "a schema in neither catalogue classifies third-party (safe default)"
1147 );
1148 }
1149
1150 #[test]
1156 fn mem_origin_class_writable_first_party_readonly_third_party() {
1157 use crate::render::OriginClass;
1158
1159 let tmp = TempDir::new().unwrap();
1160 let writable_dir = tmp.path().join("writable");
1162 std::fs::create_dir_all(&writable_dir).unwrap();
1163 let writer = FilesystemMemWriter::new(writable_dir.clone());
1164
1165 let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
1167 let archive_path = build_archive(tmp.path(), "ext", &[("ext.md", body.as_bytes())]);
1168
1169 let engine = Engine::from_mounts(vec![
1170 (
1171 folder_mount("local", writable_dir),
1172 Box::new(writer) as Box<dyn MemBackend>,
1173 ),
1174 (
1175 archive_mount("external", archive_path.clone()),
1176 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1177 ),
1178 ])
1179 .unwrap();
1180
1181 assert_eq!(
1182 engine.mem_origin_class("local"),
1183 OriginClass::FirstParty,
1184 "a writable mount is first-party"
1185 );
1186 assert_eq!(
1187 engine.mem_origin_class("external"),
1188 OriginClass::ThirdParty,
1189 "a read-only mount is third-party"
1190 );
1191 assert_eq!(
1192 engine.mem_origin_class("no-such-mem"),
1193 OriginClass::ThirdParty,
1194 "an unknown mem is third-party (safe default)"
1195 );
1196 }
1197
1198 #[test]
1204 fn schema_origin_third_party_until_pinned_by_a_writable_mount() {
1205 use memstead_schema::SchemaRef;
1206
1207 use crate::render::OriginClass;
1208
1209 let manifest = r#"name: trust-test
1210version: 0.1.0
1211description: adopt-gate test schema
1212when_to_use: tests
1213types:
1214 - doc
1215relationships:
1216 mode: strict
1217 definitions:
1218 - name: _default
1219 description: fallback
1220 default_weight: 1.0
1221community:
1222 resolution: 1.0
1223 seed: 42
1224"#;
1225 let pin = SchemaRef::new("trust-test", semver::Version::new(0, 1, 0));
1226
1227 let mk_engine = |cap: MountCapability| -> Engine {
1228 let tmp = TempDir::new().unwrap();
1229 let schemas_dir = tmp.path().join("schemas");
1230 std::fs::create_dir_all(&schemas_dir).unwrap();
1231 write_schema_files_with_default_type(&schemas_dir, "trust-test", manifest, &["doc"]);
1232 let mem_dir = tmp.path().join("mem");
1233 std::fs::create_dir_all(&mem_dir).unwrap();
1234 let mount = Mount {
1235 mem: "v".to_string(),
1236 schema: Some(pin.clone()),
1237 storage: MountStorage::Folder {
1238 path: mem_dir.clone(),
1239 },
1240 capability: cap,
1241 lifecycle: MountLifecycle::Eager,
1242 cross_linkable: true,
1243 migration_target: None,
1244 };
1245 let backend = Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>;
1246 std::mem::forget(tmp);
1250 Engine::from_mounts_with_schemas_dir(vec![(mount, backend)], Some(&schemas_dir))
1251 .unwrap()
1252 };
1253
1254 let ro = mk_engine(MountCapability::ReadOnly);
1256 let schema = ro.schemas().get("v").expect("schema resolved").clone();
1257 assert_eq!(
1258 ro.schema_origin(&schema),
1259 OriginClass::ThirdParty,
1260 "a non-built-in schema pinned only by a read-only mount is third-party"
1261 );
1262
1263 let rw = mk_engine(MountCapability::Write);
1265 let schema = rw.schemas().get("v").expect("schema resolved").clone();
1266 assert_eq!(
1267 rw.schema_origin(&schema),
1268 OriginClass::FirstParty,
1269 "a writable mount pinning the schema adopts it → first-party"
1270 );
1271 }
1272
1273 #[test]
1280 fn archive_provenance_surfaces_per_entity_and_reports_absence() {
1281 use memstead_schema::History;
1282
1283 let tmp = TempDir::new().unwrap();
1284 let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
1285 let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
1286 let beta = b"---\ntype: spec\n---\n# Beta\n\n## Identity\n\nb\n\n## Purpose\n\np\n";
1287 let provenance = br#"{"format":1,"history":"summarised","entities":{"alpha":{"rationale":"why alpha exists","kind":"create","timestamp":"2026-06-24T00:00:00Z","actor":"agent"}}}"#;
1289 let archive = build_archive(
1290 tmp.path(),
1291 "seed",
1292 &[
1293 (".memstead/config.json", config),
1294 ("alpha.md", alpha),
1295 ("beta.md", beta),
1296 (".memstead/provenance.json", provenance),
1297 ],
1298 );
1299 let engine = Engine::from_mounts(vec![(
1300 archive_mount("seed", archive.clone()),
1301 Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
1302 )])
1303 .unwrap();
1304
1305 let prov = engine
1306 .archive_provenance_for("seed")
1307 .expect("provenance payload read from the archive");
1308 assert_eq!(
1309 prov.history,
1310 History::Summarised,
1311 "history-not-shipped is observable"
1312 );
1313 assert_eq!(
1314 prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
1315 Some("why alpha exists"),
1316 "noted entity surfaces its rationale"
1317 );
1318 assert!(
1319 prov.entity("beta").is_none(),
1320 "unnoted entity is absent (reported absent, not fabricated)"
1321 );
1322 }
1323
1324 #[test]
1328 fn archive_without_provenance_reports_absent() {
1329 let tmp = TempDir::new().unwrap();
1330 let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
1331 let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
1332 let archive = build_archive(
1333 tmp.path(),
1334 "seed",
1335 &[(".memstead/config.json", config), ("alpha.md", alpha)],
1336 );
1337 let engine = Engine::from_mounts(vec![(
1338 archive_mount("seed", archive.clone()),
1339 Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
1340 )])
1341 .unwrap();
1342 assert!(
1343 engine.archive_provenance_for("seed").is_none(),
1344 "an archive without a provenance payload reports provenance absent"
1345 );
1346 }
1347
1348 #[test]
1349 fn folder_mount_routes_reads_to_filesystem_backend() {
1350 let tmp = TempDir::new().unwrap();
1351 let mem_dir = tmp.path().to_path_buf();
1352 let writer = FilesystemMemWriter::new(mem_dir.clone());
1353 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"alpha")
1357 .unwrap();
1358 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1359 .unwrap();
1360
1361 let engine = Engine::from_mounts(vec![(
1362 folder_mount("specs", mem_dir),
1363 Box::new(writer) as Box<dyn MemBackend>,
1364 )])
1365 .unwrap();
1366
1367 let mut paths: Vec<String> = engine
1368 .list_entities("specs")
1369 .unwrap()
1370 .into_iter()
1371 .map(|p| p.to_string_lossy().into_owned())
1372 .collect();
1373 paths.sort();
1374 assert_eq!(paths, vec!["a.md".to_string()]);
1375
1376 assert_eq!(
1377 engine.read_entity("specs", Path::new("a.md")).unwrap(),
1378 Some(b"alpha".to_vec())
1379 );
1380 }
1381
1382 #[test]
1383 fn heterogeneous_mounts_route_to_correct_backend() {
1384 let tmp = TempDir::new().unwrap();
1385
1386 let folder_dir = tmp.path().join("folder-mem");
1388 std::fs::create_dir_all(&folder_dir).unwrap();
1389 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1390 <FilesystemMemWriter as MemWriter>::write_entity(
1391 &folder_writer,
1392 Path::new("local.md"),
1393 b"local",
1394 )
1395 .unwrap();
1396 <FilesystemMemWriter as MemWriter>::commit(
1397 &folder_writer,
1398 "seed",
1399 &CommitContext::internal(),
1400 )
1401 .unwrap();
1402
1403 let archive_path = build_archive(
1405 tmp.path(),
1406 "external",
1407 &[("ext.md", b"external"), ("dir/nested.md", b"nested")],
1408 );
1409
1410 let engine = Engine::from_mounts(vec![
1411 (
1412 folder_mount("local", folder_dir),
1413 Box::new(folder_writer) as Box<dyn MemBackend>,
1414 ),
1415 (
1416 archive_mount("external", archive_path.clone()),
1417 Box::new(ArchiveBackend::new(archive_path)),
1418 ),
1419 ])
1420 .unwrap();
1421
1422 assert_eq!(engine.mem_names(), vec!["local", "external"]);
1424 assert_eq!(
1425 engine.read_entity("local", Path::new("local.md")).unwrap(),
1426 Some(b"local".to_vec())
1427 );
1428 assert_eq!(
1429 engine.read_entity("external", Path::new("ext.md")).unwrap(),
1430 Some(b"external".to_vec())
1431 );
1432 assert_eq!(
1433 engine
1434 .read_entity("external", Path::new("dir/nested.md"))
1435 .unwrap(),
1436 Some(b"nested".to_vec())
1437 );
1438 assert_eq!(
1441 engine.read_entity("local", Path::new("ext.md")).unwrap(),
1442 None
1443 );
1444 assert_eq!(
1445 engine
1446 .read_entity("external", Path::new("local.md"))
1447 .unwrap(),
1448 None
1449 );
1450 }
1451
1452 #[test]
1453 fn edge_is_from_readonly_classifies_every_edge_by_source_mount_capability() {
1454 let tmp = TempDir::new().unwrap();
1460
1461 let folder_dir = tmp.path().join("local-mem");
1465 std::fs::create_dir_all(&folder_dir).unwrap();
1466 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1467 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";
1468 <FilesystemMemWriter as MemWriter>::write_entity(
1469 &folder_writer,
1470 Path::new("note.md"),
1471 local_md,
1472 )
1473 .unwrap();
1474 <FilesystemMemWriter as MemWriter>::commit(
1475 &folder_writer,
1476 "seed",
1477 &CommitContext::internal(),
1478 )
1479 .unwrap();
1480
1481 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";
1485 let archive_path = build_archive(tmp.path(), "external", &[("archived.md", archive_md)]);
1486
1487 let engine = Engine::from_mounts(vec![
1488 (
1489 folder_mount("local", folder_dir),
1490 Box::new(folder_writer) as Box<dyn MemBackend>,
1491 ),
1492 (
1493 archive_mount("external", archive_path.clone()),
1494 Box::new(ArchiveBackend::new(archive_path)),
1495 ),
1496 ])
1497 .unwrap();
1498
1499 let local_id = EntityId::new("local", "note");
1501 let archived_id = EntityId::new("external", "archived");
1502 assert!(engine.get_entity(&local_id).is_some());
1503 assert!(engine.get_entity(&archived_id).is_some());
1504 assert!(matches!(
1505 engine.capability("local").unwrap(),
1506 MountCapability::Write
1507 ));
1508 assert!(matches!(
1509 engine.capability("external").unwrap(),
1510 MountCapability::ReadOnly
1511 ));
1512
1513 let mut seen_write_edge = false;
1519 let mut seen_readonly_edge = false;
1520 for from in engine.store().all_ids().cloned().collect::<Vec<_>>() {
1521 for _edge in engine.store().outgoing(&from) {
1522 let is_ro = engine.edge_is_from_readonly(&from);
1523 match engine.capability(from.mem()).unwrap() {
1524 MountCapability::Write => {
1525 assert!(
1526 !is_ro,
1527 "edge from write mem {} reported as ReadOnly",
1528 from.mem()
1529 );
1530 seen_write_edge = true;
1531 }
1532 MountCapability::ReadOnly => {
1533 assert!(
1534 is_ro,
1535 "edge from readonly mem {} reported as Write",
1536 from.mem()
1537 );
1538 seen_readonly_edge = true;
1539 }
1540 }
1541 }
1542 }
1543 assert!(
1544 seen_write_edge,
1545 "fixture must produce at least one edge from a write mem"
1546 );
1547 assert!(
1548 seen_readonly_edge,
1549 "fixture must produce at least one edge from a readonly mem"
1550 );
1551
1552 let phantom = EntityId::new("missing-mem", "phantom");
1555 assert!(
1556 !engine.edge_is_from_readonly(&phantom),
1557 "absent mount must not be reported as ReadOnly"
1558 );
1559 }
1560
1561 #[test]
1564 fn cross_mem_link_allowed_same_mem_always_true() {
1565 let tmp = TempDir::new().unwrap();
1568 let engine = build_demo_engine(&tmp);
1569 assert!(engine.cross_mem_link_allowed("specs", "specs"));
1570 assert!(engine.cross_mem_link_allowed("anywhere", "anywhere"));
1575 }
1576
1577 #[test]
1578 fn cross_mem_link_allowed_absent_denies_by_default() {
1579 let tmp = TempDir::new().unwrap();
1582 let engine = build_demo_engine(&tmp);
1583 assert!(!engine.cross_mem_link_allowed("specs", "engine"));
1584 assert!(!engine.cross_mem_link_allowed("missing", "anywhere"));
1585 }
1586
1587 #[test]
1588 fn cross_mem_link_allowed_wildcard_admits_any_target() {
1589 use memstead_schema::workspace_config::CrossLinkValue;
1590 let tmp = TempDir::new().unwrap();
1591 let mut engine = build_demo_engine(&tmp);
1592 let mut settings = crate::workspace::WorkspaceSettings::default();
1593 settings
1594 .cross_mem_links
1595 .insert("specs".to_string(), CrossLinkValue::Wildcard);
1596 engine.set_settings(settings);
1597 assert!(engine.cross_mem_link_allowed("specs", "engine"));
1598 assert!(engine.cross_mem_link_allowed("specs", "macos"));
1599 assert!(engine.cross_mem_link_allowed("specs", "any-other"));
1600 assert!(!engine.cross_mem_link_allowed("engine", "specs"));
1603 }
1604
1605 #[test]
1606 fn cross_mem_link_allowed_allowlist_enforces_membership() {
1607 use memstead_schema::workspace_config::CrossLinkValue;
1608 let tmp = TempDir::new().unwrap();
1609 let mut engine = build_demo_engine(&tmp);
1610 let mut settings = crate::workspace::WorkspaceSettings::default();
1611 settings.cross_mem_links.insert(
1612 "specs".to_string(),
1613 CrossLinkValue::List(vec!["engine".to_string(), "macos".to_string()]),
1614 );
1615 engine.set_settings(settings);
1616 assert!(engine.cross_mem_link_allowed("specs", "engine"));
1617 assert!(engine.cross_mem_link_allowed("specs", "macos"));
1618 assert!(!engine.cross_mem_link_allowed("specs", "external"));
1619 }
1620
1621 #[test]
1622 fn cross_mem_link_allowed_synthesises_from_matching_create_rule_wildcard() {
1623 use memstead_schema::workspace_config::CrossLinkValue;
1627 let tmp = TempDir::new().unwrap();
1628 let mut engine = build_demo_engine(&tmp);
1629 let mut settings = crate::workspace::WorkspaceSettings::default();
1630 settings
1631 .mem_create_rules
1632 .push(crate::workspace::CreateRuleSetting {
1633 pattern: "exec-*".to_string(),
1634 schemas: vec!["default".to_string()],
1635 default_cross_links: Some(CrossLinkValue::Wildcard),
1636 });
1637 engine.set_settings(settings);
1638 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
1641 assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
1642 assert!(!engine.cross_mem_link_allowed("orphan", "specs"));
1644 }
1645
1646 #[test]
1653 fn cross_mem_link_allowed_synthesises_for_hierarchical_mem() {
1654 use memstead_schema::workspace_config::CrossLinkValue;
1655 let tmp = TempDir::new().unwrap();
1656 let mem_dir = tmp.path().to_path_buf();
1657 let mount = Mount {
1662 mem: "project".into(),
1663 schema: Some(pin("default")),
1664 storage: MountStorage::GitBranch {
1665 gitdir: mem_dir.join(".git"),
1666 branch: "memstead/project".into(),
1667 },
1668 capability: MountCapability::Write,
1669 lifecycle: MountLifecycle::Eager,
1670 cross_linkable: true,
1671 migration_target: None,
1672 };
1673 let writer = FilesystemMemWriter::new(mem_dir.clone());
1674 let mut engine =
1675 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1676 let mut settings = crate::workspace::WorkspaceSettings::default();
1677 settings
1678 .mem_create_rules
1679 .push(crate::workspace::CreateRuleSetting {
1680 pattern: "memstead/*".to_string(),
1681 schemas: vec!["default".to_string()],
1682 default_cross_links: Some(CrossLinkValue::List(vec!["engine".to_string()])),
1683 });
1684 engine.set_settings(settings);
1685 assert!(
1686 engine.cross_mem_link_allowed("project", "engine"),
1687 "synthesis must match via the composed `memstead/project` candidate"
1688 );
1689 assert!(
1690 !engine.cross_mem_link_allowed("project", "macos"),
1691 "a target outside the rule's default_cross_links is still denied"
1692 );
1693 }
1694
1695 #[test]
1696 fn cross_mem_link_allowed_synthesises_from_matching_create_rule_list() {
1697 use memstead_schema::workspace_config::CrossLinkValue;
1700 let tmp = TempDir::new().unwrap();
1701 let mut engine = build_demo_engine(&tmp);
1702 let mut settings = crate::workspace::WorkspaceSettings::default();
1703 settings
1704 .mem_create_rules
1705 .push(crate::workspace::CreateRuleSetting {
1706 pattern: "exec-*".to_string(),
1707 schemas: vec!["default".to_string()],
1708 default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
1709 });
1710 engine.set_settings(settings);
1711 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
1712 assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
1714 }
1715
1716 #[test]
1717 fn cross_mem_link_allowed_explicit_policy_wins_over_synthesis() {
1718 use memstead_schema::workspace_config::CrossLinkValue;
1721 let tmp = TempDir::new().unwrap();
1722 let mut engine = build_demo_engine(&tmp);
1723 let mut settings = crate::workspace::WorkspaceSettings::default();
1724 settings
1725 .cross_mem_links
1726 .insert("exec-foo".to_string(), CrossLinkValue::Wildcard);
1727 engine.set_settings(settings);
1730 assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
1731 }
1732
1733 #[test]
1734 fn cross_mem_link_allowed_synthesis_unions_into_explicit_list() {
1735 use memstead_schema::workspace_config::CrossLinkValue;
1738 let tmp = TempDir::new().unwrap();
1739 let mut engine = build_demo_engine(&tmp);
1740 let mut settings = crate::workspace::WorkspaceSettings::default();
1741 settings.cross_mem_links.insert(
1742 "exec-foo".to_string(),
1743 CrossLinkValue::List(vec!["specs".to_string()]),
1744 );
1745 settings
1746 .mem_create_rules
1747 .push(crate::workspace::CreateRuleSetting {
1748 pattern: "exec-*".to_string(),
1749 schemas: vec!["default".to_string()],
1750 default_cross_links: Some(CrossLinkValue::List(vec!["macos".to_string()])),
1751 });
1752 engine.set_settings(settings);
1753 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
1755 assert!(engine.cross_mem_link_allowed("exec-foo", "macos"));
1757 assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
1759 }
1760
1761 #[test]
1762 fn cross_mem_link_allowed_set_settings_invalidates_compiled_rule_cache() {
1763 use memstead_schema::workspace_config::CrossLinkValue;
1766 let tmp = TempDir::new().unwrap();
1767 let mut engine = build_demo_engine(&tmp);
1768
1769 let mut s1 = crate::workspace::WorkspaceSettings::default();
1771 s1.mem_create_rules
1772 .push(crate::workspace::CreateRuleSetting {
1773 pattern: "exec-*".to_string(),
1774 schemas: vec!["default".to_string()],
1775 default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
1776 });
1777 engine.set_settings(s1);
1778 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
1779
1780 let mut s2 = crate::workspace::WorkspaceSettings::default();
1784 s2.mem_create_rules
1785 .push(crate::workspace::CreateRuleSetting {
1786 pattern: "exec-*".to_string(),
1787 schemas: vec!["default".to_string()],
1788 default_cross_links: None,
1789 });
1790 engine.set_settings(s2);
1791 assert!(!engine.cross_mem_link_allowed("exec-foo", "specs"));
1792 }
1793
1794 #[test]
1795 fn cross_mem_link_allowed_malformed_glob_falls_back_to_explicit_policy() {
1796 use memstead_schema::workspace_config::CrossLinkValue;
1800 let tmp = TempDir::new().unwrap();
1801 let mut engine = build_demo_engine(&tmp);
1802 let mut settings = crate::workspace::WorkspaceSettings::default();
1803 settings
1804 .mem_create_rules
1805 .push(crate::workspace::CreateRuleSetting {
1806 pattern: "[unclosed".to_string(),
1807 schemas: vec!["default".to_string()],
1808 default_cross_links: Some(CrossLinkValue::Wildcard),
1809 });
1810 settings
1812 .cross_mem_links
1813 .insert("specs".to_string(), CrossLinkValue::Wildcard);
1814 engine.set_settings(settings);
1815 assert!(engine.cross_mem_link_allowed("specs", "engine"));
1817 assert!(!engine.cross_mem_link_allowed("orphan", "anything"));
1820 }
1821
1822 #[test]
1823 fn cross_mem_link_allowed_empty_list_denies_all_cross_mem_targets() {
1824 use memstead_schema::workspace_config::CrossLinkValue;
1828 let tmp = TempDir::new().unwrap();
1829 let mut engine = build_demo_engine(&tmp);
1830 let mut settings = crate::workspace::WorkspaceSettings::default();
1831 settings
1832 .cross_mem_links
1833 .insert("specs".to_string(), CrossLinkValue::List(Vec::new()));
1834 engine.set_settings(settings);
1835 assert!(engine.cross_mem_link_allowed("specs", "specs"));
1837 assert!(!engine.cross_mem_link_allowed("specs", "engine"));
1839 assert!(!engine.cross_mem_link_allowed("specs", "anything"));
1840 }
1841
1842 #[test]
1843 fn from_mounts_load_warnings_merge_into_health_summary() {
1844 let tmp = TempDir::new().unwrap();
1845 let mem_dir = tmp.path().to_path_buf();
1846 let body = "---\ntype: spec\n---\n# Dup2\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
1847 std::fs::write(mem_dir.join("dup2.md"), body).unwrap();
1848
1849 let writer = FilesystemMemWriter::new(mem_dir.clone());
1850 let engine = Engine::from_mounts(vec![(
1851 folder_mount("specs", mem_dir),
1852 Box::new(writer) as Box<dyn MemBackend>,
1853 )])
1854 .unwrap();
1855
1856 let summary = engine.health();
1857 assert!(
1858 summary
1859 .warnings
1860 .iter()
1861 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1862 "health() must merge load_warnings into summary.warnings: {:?}",
1863 summary.warnings,
1864 );
1865 }
1866
1867 #[test]
1868 fn workspace_root_accessor_is_none_for_engine_built_from_mounts() {
1869 let tmp = TempDir::new().unwrap();
1870 let mem_dir = tmp.path().to_path_buf();
1871 let writer = FilesystemMemWriter::new(mem_dir.clone());
1872 let engine = Engine::from_mounts(vec![(
1873 folder_mount("specs", mem_dir),
1874 Box::new(writer) as Box<dyn MemBackend>,
1875 )])
1876 .unwrap();
1877 assert!(
1878 engine.workspace_root().is_none(),
1879 "from_mounts has no workspace path",
1880 );
1881 assert!(engine.load_warnings().is_empty());
1882 }
1883
1884 #[test]
1885 fn health_omits_outer_repo_warning_when_workspace_root_unset() {
1886 let tmp = TempDir::new().unwrap();
1887 let mem_dir = tmp.path().to_path_buf();
1888 let writer = FilesystemMemWriter::new(mem_dir.clone());
1889 let engine = Engine::from_mounts(vec![(
1890 folder_mount("specs", mem_dir),
1891 Box::new(writer) as Box<dyn MemBackend>,
1892 )])
1893 .unwrap();
1894 let health = engine.health();
1895 assert!(
1896 !health
1897 .warnings
1898 .iter()
1899 .any(|w| matches!(w, WarningHint::OuterRepoNotIgnoringMemRepo { .. })),
1900 "outer-repo check must skip when workspace_root is None",
1901 );
1902 }
1903
1904 #[test]
1905 fn writable_mem_names_filters_by_capability() {
1906 let tmp = TempDir::new().unwrap();
1907 let mem_dir = tmp.path().to_path_buf();
1908 let writer = FilesystemMemWriter::new(mem_dir.clone());
1909 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
1910
1911 let engine = Engine::from_mounts(vec![
1912 (
1913 folder_mount("writable", mem_dir),
1914 Box::new(writer) as Box<dyn MemBackend>,
1915 ),
1916 (
1917 archive_mount("sealed", archive_path.clone()),
1918 Box::new(ArchiveBackend::new(archive_path)),
1919 ),
1920 ])
1921 .unwrap();
1922
1923 let names = engine.writable_mem_names();
1926 assert_eq!(names, vec!["writable"]);
1927 }
1928
1929 #[test]
1936 fn default_writable_mem_is_declaration_first_not_alphabetical() {
1937 let tmp = TempDir::new().unwrap();
1938 let test_dir = tmp.path().join("test");
1939 let other_dir = tmp.path().join("other");
1940 std::fs::create_dir_all(&test_dir).unwrap();
1941 std::fs::create_dir_all(&other_dir).unwrap();
1942
1943 let engine = Engine::from_mounts(vec![
1944 (
1945 folder_mount("test", test_dir.clone()),
1946 Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
1947 ),
1948 (
1949 folder_mount("other", other_dir.clone()),
1950 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
1951 ),
1952 ])
1953 .unwrap();
1954
1955 assert_eq!(
1956 engine.default_writable_mem(),
1957 Some("test"),
1958 "default must be the declaration-first writable mem, not the alphabetically-first",
1959 );
1960 }
1961
1962 #[test]
1967 fn default_writable_mem_follows_declaration_order() {
1968 let tmp = TempDir::new().unwrap();
1969 let other_dir = tmp.path().join("other");
1970 let test_dir = tmp.path().join("test");
1971 std::fs::create_dir_all(&other_dir).unwrap();
1972 std::fs::create_dir_all(&test_dir).unwrap();
1973
1974 let engine = Engine::from_mounts(vec![
1975 (
1976 folder_mount("other", other_dir.clone()),
1977 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
1978 ),
1979 (
1980 folder_mount("test", test_dir.clone()),
1981 Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
1982 ),
1983 ])
1984 .unwrap();
1985
1986 assert_eq!(engine.default_writable_mem(), Some("other"));
1987 }
1988
1989 #[test]
1991 fn default_writable_mem_none_without_writable_mount() {
1992 let tmp = TempDir::new().unwrap();
1993 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
1994 let engine = Engine::from_mounts(vec![(
1995 archive_mount("sealed", archive_path.clone()),
1996 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1997 )])
1998 .unwrap();
1999 assert_eq!(engine.default_writable_mem(), None);
2000 }
2001
2002 #[test]
2003 fn folder_path_for_mem_returns_path_for_folder_mounts_only() {
2004 let tmp = TempDir::new().unwrap();
2005 let mem_dir = tmp.path().join("specs");
2006 std::fs::create_dir_all(&mem_dir).unwrap();
2007 let writer = FilesystemMemWriter::new(mem_dir.clone());
2008 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2009
2010 let engine = Engine::from_mounts(vec![
2011 (
2012 folder_mount("specs", mem_dir.clone()),
2013 Box::new(writer) as Box<dyn MemBackend>,
2014 ),
2015 (
2016 archive_mount("sealed", archive_path.clone()),
2017 Box::new(ArchiveBackend::new(archive_path)),
2018 ),
2019 ])
2020 .unwrap();
2021
2022 assert_eq!(engine.folder_path_for_mem("specs"), Some(mem_dir.as_path()),);
2024 assert_eq!(engine.folder_path_for_mem("sealed"), None);
2026 assert_eq!(engine.folder_path_for_mem("missing"), None);
2028 }
2029
2030 #[test]
2031 fn mount_accessor_returns_public_mount_shape() {
2032 let tmp = TempDir::new().unwrap();
2039 let mem_dir = tmp.path().to_path_buf();
2040 let writer = FilesystemMemWriter::new(mem_dir.clone());
2041 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2042
2043 let engine = Engine::from_mounts(vec![
2044 (
2045 folder_mount("writable", mem_dir.clone()),
2046 Box::new(writer) as Box<dyn MemBackend>,
2047 ),
2048 (
2049 archive_mount("sealed", archive_path.clone()),
2050 Box::new(ArchiveBackend::new(archive_path.clone())),
2051 ),
2052 ])
2053 .unwrap();
2054
2055 let folder = engine.mount("writable").expect("known mem");
2058 assert!(matches!(folder.storage, MountStorage::Folder { .. }));
2059 assert_eq!(folder.capability, MountCapability::Write);
2060
2061 let archive = engine.mount("sealed").expect("known mem");
2062 match &archive.storage {
2063 MountStorage::Archive { path } => assert_eq!(path, &archive_path),
2064 other => panic!("expected Archive storage, got {other:?}"),
2065 }
2066 assert_eq!(archive.capability, MountCapability::ReadOnly);
2067
2068 assert!(engine.mount("missing").is_none());
2070
2071 let mounts = engine.mounts();
2073 assert_eq!(mounts.len(), 2);
2074 assert_eq!(mounts[0].mem, "writable");
2075 assert_eq!(mounts[1].mem, "sealed");
2076 }
2077
2078 #[test]
2079 fn mem_router_writable_set_matches_writable_mount_capability() {
2080 let tmp = TempDir::new().unwrap();
2084 let mem_dir = tmp.path().join("specs");
2085 std::fs::create_dir_all(&mem_dir).unwrap();
2086 let writer = FilesystemMemWriter::new(mem_dir.clone());
2087 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2088
2089 let engine = Engine::from_mounts(vec![
2090 (
2091 folder_mount("specs", mem_dir.clone()),
2092 Box::new(writer) as Box<dyn MemBackend>,
2093 ),
2094 (
2095 archive_mount("ext", archive_path.clone()),
2096 Box::new(ArchiveBackend::new(archive_path)),
2097 ),
2098 ])
2099 .unwrap();
2100
2101 let router = engine.mem_router();
2102 assert!(router.is_writable("specs"));
2103 assert!(!router.is_writable("ext"));
2104 assert!(router.is_visible("specs"));
2105 assert!(router.is_visible("ext"));
2106 let writable: std::collections::HashSet<&String> = router.writable_mems().iter().collect();
2107 assert_eq!(writable.len(), 1);
2108 assert!(writable.contains(&"specs".to_string()));
2109 }
2110
2111 #[test]
2112 fn mem_router_origin_is_explicit_toml_for_workspace_mounts() {
2113 let tmp = TempDir::new().unwrap();
2119 let mem_dir = tmp.path().join("specs");
2120 std::fs::create_dir_all(&mem_dir).unwrap();
2121 let writer = FilesystemMemWriter::new(mem_dir.clone());
2122
2123 let engine = Engine::from_mounts(vec![(
2124 folder_mount("specs", mem_dir),
2125 Box::new(writer) as Box<dyn MemBackend>,
2126 )])
2127 .unwrap();
2128
2129 let origin = engine
2130 .mem_router()
2131 .origin_for_mem("specs")
2132 .expect("known mem");
2133 assert_eq!(origin.kind(), "explicit");
2134 }
2135
2136 #[test]
2137 fn mem_router_dir_for_writable_folder_mount_matches_storage_path() {
2138 let tmp = TempDir::new().unwrap();
2142 let mem_dir = tmp.path().join("specs");
2143 std::fs::create_dir_all(&mem_dir).unwrap();
2144 let writer = FilesystemMemWriter::new(mem_dir.clone());
2145
2146 let engine = Engine::from_mounts(vec![(
2147 folder_mount("specs", mem_dir.clone()),
2148 Box::new(writer) as Box<dyn MemBackend>,
2149 )])
2150 .unwrap();
2151
2152 assert_eq!(
2153 engine.mem_router().dir_for_mem("specs"),
2154 Some(mem_dir.as_path()),
2155 );
2156 assert_eq!(engine.mem_router().dir_for_mem("unknown"), None);
2157 }
2158
2159 #[test]
2160 fn mem_router_archive_path_for_read_only_archive_mount() {
2161 let tmp = TempDir::new().unwrap();
2165 let mem_dir = tmp.path().join("specs");
2166 std::fs::create_dir_all(&mem_dir).unwrap();
2167 let writer = FilesystemMemWriter::new(mem_dir.clone());
2168 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2169
2170 let engine = Engine::from_mounts(vec![
2171 (
2172 folder_mount("specs", mem_dir),
2173 Box::new(writer) as Box<dyn MemBackend>,
2174 ),
2175 (
2176 archive_mount("ext", archive_path.clone()),
2177 Box::new(ArchiveBackend::new(archive_path.clone())),
2178 ),
2179 ])
2180 .unwrap();
2181
2182 let router = engine.mem_router();
2183 assert_eq!(
2184 router.archive_path_for_mem("ext"),
2185 Some(archive_path.as_path()),
2186 );
2187 assert_eq!(router.archive_path_for_mem("specs"), None);
2189 }
2190
2191 #[test]
2192 fn read_mem_config_via_backend_trait_folder_reads_bytes() {
2193 let tmp = TempDir::new().unwrap();
2197 let mem_dir = tmp.path().to_path_buf();
2198 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2199 let body = br#"{
2200 "format": 1,
2201 "schema": "default@1.0.0",
2202 "writeGuidance": { "tone": "neutral" }
2203 }"#;
2204 std::fs::write(mem_dir.join(".memstead").join("config.json"), body).unwrap();
2205
2206 let writer = FilesystemMemWriter::new(mem_dir);
2207 let result = MemBackend::read_mem_config(&writer).unwrap();
2208 let bytes = result.expect("config bytes must surface");
2209 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2210 assert_eq!(parsed["schema"], "default@1.0.0");
2211 }
2212
2213 #[test]
2214 fn read_mem_config_via_backend_trait_folder_missing_returns_none() {
2215 let tmp = TempDir::new().unwrap();
2216 let mem_dir = tmp.path().to_path_buf();
2217 let writer = FilesystemMemWriter::new(mem_dir);
2218 let result = MemBackend::read_mem_config(&writer).unwrap();
2219 assert!(result.is_none());
2220 }
2221
2222 #[test]
2223 fn read_mem_config_via_backend_trait_archive_reads_bytes() {
2224 let tmp = TempDir::new().unwrap();
2227 let archive_path = tmp.path().join("seed.mem");
2228 let body = br#"{
2229 "format": 1,
2230 "schema": "default@1.0.0",
2231 "writeGuidance": { "tone": "archive" }
2232 }"#;
2233 {
2234 let file = std::fs::File::create(&archive_path).unwrap();
2235 let mut writer = zip::ZipWriter::new(file);
2236 writer
2237 .start_file(
2238 ".memstead/config.json",
2239 zip::write::SimpleFileOptions::default(),
2240 )
2241 .unwrap();
2242 use std::io::Write;
2243 writer.write_all(body).unwrap();
2244 writer.finish().unwrap();
2245 }
2246
2247 let backend = ArchiveBackend::new(archive_path);
2248 let result = MemBackend::read_mem_config(&backend).unwrap();
2249 let bytes = result.expect("config bytes must surface");
2250 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2251 assert_eq!(parsed["writeGuidance"]["tone"], "archive");
2252 }
2253
2254 #[test]
2255 fn mem_config_for_returns_none_when_no_config_file_present() {
2256 let tmp = TempDir::new().unwrap();
2259 let mem_dir = tmp.path().to_path_buf();
2260 let writer = FilesystemMemWriter::new(mem_dir.clone());
2261 let engine = Engine::from_mounts(vec![(
2262 folder_mount("specs", mem_dir),
2263 Box::new(writer) as Box<dyn MemBackend>,
2264 )])
2265 .unwrap();
2266 assert!(engine.mem_config_for("specs").is_none());
2267 }
2268
2269 #[test]
2270 fn mem_config_for_returns_some_when_config_file_present() {
2271 let tmp = TempDir::new().unwrap();
2276 let mem_dir = tmp.path().to_path_buf();
2277 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2278 let config_body = r#"{
2279 "format": 1,
2280 "schema": "default@1.0.0",
2281 "writeGuidance": {
2282 "tone": "neutral",
2283 "voice": "active"
2284 }
2285 }"#;
2286 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
2287
2288 let writer = FilesystemMemWriter::new(mem_dir.clone());
2289 let engine = Engine::from_mounts(vec![(
2290 folder_mount("specs", mem_dir),
2291 Box::new(writer) as Box<dyn MemBackend>,
2292 )])
2293 .unwrap();
2294
2295 let cfg = engine
2296 .mem_config_for("specs")
2297 .expect("mem_config should load");
2298 assert_eq!(cfg.write_guidance.len(), 2);
2299 assert_eq!(
2300 cfg.write_guidance.get("tone").and_then(|v| v.as_str()),
2301 Some("neutral"),
2302 );
2303 assert_eq!(
2304 cfg.write_guidance.get("voice").and_then(|v| v.as_str()),
2305 Some("active"),
2306 );
2307 }
2308
2309 #[test]
2310 fn mem_config_for_unknown_mem_returns_none() {
2311 let tmp = TempDir::new().unwrap();
2313 let mem_dir = tmp.path().to_path_buf();
2314 let writer = FilesystemMemWriter::new(mem_dir.clone());
2315 let engine = Engine::from_mounts(vec![(
2316 folder_mount("specs", mem_dir),
2317 Box::new(writer) as Box<dyn MemBackend>,
2318 )])
2319 .unwrap();
2320 assert!(engine.mem_config_for("missing").is_none());
2321 }
2322
2323 #[test]
2324 fn mem_config_for_archive_mount_returns_none() {
2325 let tmp = TempDir::new().unwrap();
2328 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2329 let engine = Engine::from_mounts(vec![(
2330 archive_mount("ext", archive_path.clone()),
2331 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2332 )])
2333 .unwrap();
2334 assert!(engine.mem_config_for("ext").is_none());
2335 }
2336
2337 #[test]
2338 fn mem_configs_named_iterates_only_mounts_with_config() {
2339 let tmp = TempDir::new().unwrap();
2344 let with_config = tmp.path().join("specs");
2345 let without_config = tmp.path().join("memos");
2346 std::fs::create_dir_all(with_config.join(".memstead")).unwrap();
2347 std::fs::create_dir_all(&without_config).unwrap();
2348 let config_body = r#"{
2349 "format": 1,
2350 "schema": "default@1.0.0",
2351 "writeGuidance": { "tone": "neutral" }
2352 }"#;
2353 std::fs::write(
2354 with_config.join(".memstead").join("config.json"),
2355 config_body,
2356 )
2357 .unwrap();
2358
2359 let engine = Engine::from_mounts(vec![
2360 (
2361 folder_mount("specs", with_config.clone()),
2362 Box::new(FilesystemMemWriter::new(with_config)) as Box<dyn MemBackend>,
2363 ),
2364 (
2365 folder_mount("memos", without_config.clone()),
2366 Box::new(FilesystemMemWriter::new(without_config)) as Box<dyn MemBackend>,
2367 ),
2368 ])
2369 .unwrap();
2370
2371 let yielded: Vec<(&str, usize)> = engine
2372 .mem_configs_named()
2373 .map(|(name, cfg)| (name, cfg.write_guidance.len()))
2374 .collect();
2375 assert_eq!(yielded, vec![("specs", 1)]);
2376 }
2377
2378 #[test]
2379 fn schema_for_returns_some_for_known_mem_and_none_for_unknown() {
2380 let tmp = TempDir::new().unwrap();
2384 let mem_dir = tmp.path().to_path_buf();
2385 let writer = FilesystemMemWriter::new(mem_dir.clone());
2386 let engine = Engine::from_mounts(vec![(
2387 folder_mount("specs", mem_dir),
2388 Box::new(writer) as Box<dyn MemBackend>,
2389 )])
2390 .unwrap();
2391 assert!(engine.schema_for("specs").is_some());
2392 assert!(engine.schema_for("missing").is_none());
2393 }
2394
2395 #[test]
2396 fn gitdir_for_unknown_mem_returns_unknown_mem() {
2397 let tmp = TempDir::new().unwrap();
2398 let mem_dir = tmp.path().to_path_buf();
2399 let writer = FilesystemMemWriter::new(mem_dir.clone());
2400 let engine = Engine::from_mounts(vec![(
2401 folder_mount("specs", mem_dir),
2402 Box::new(writer) as Box<dyn MemBackend>,
2403 )])
2404 .unwrap();
2405 let err = engine.gitdir_for("missing").unwrap_err();
2406 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
2407 }
2408
2409 #[test]
2410 fn gitdir_for_folder_mount_returns_no_gitdir_error() {
2411 let tmp = TempDir::new().unwrap();
2414 let mem_dir = tmp.path().to_path_buf();
2415 let writer = FilesystemMemWriter::new(mem_dir.clone());
2416 let engine = Engine::from_mounts(vec![(
2417 folder_mount("specs", mem_dir),
2418 Box::new(writer) as Box<dyn MemBackend>,
2419 )])
2420 .unwrap();
2421 let err = engine.gitdir_for("specs").unwrap_err();
2422 match err {
2423 EngineError::Mem(msg) => assert!(msg.contains("no resolved gitdir")),
2424 other => panic!("expected EngineError::Mem, got {other:?}"),
2425 }
2426 }
2427
2428 #[test]
2429 fn worktree_for_folder_mount_returns_storage_path() {
2430 let tmp = TempDir::new().unwrap();
2431 let mem_dir = tmp.path().to_path_buf();
2432 let writer = FilesystemMemWriter::new(mem_dir.clone());
2433 let engine = Engine::from_mounts(vec![(
2434 folder_mount("specs", mem_dir.clone()),
2435 Box::new(writer) as Box<dyn MemBackend>,
2436 )])
2437 .unwrap();
2438 let worktree = engine.worktree_for("specs").unwrap();
2439 assert_eq!(worktree, mem_dir);
2440 }
2441
2442 #[test]
2443 fn worktree_for_unknown_mem_returns_unknown_mem() {
2444 let tmp = TempDir::new().unwrap();
2445 let mem_dir = tmp.path().to_path_buf();
2446 let writer = FilesystemMemWriter::new(mem_dir.clone());
2447 let engine = Engine::from_mounts(vec![(
2448 folder_mount("specs", mem_dir),
2449 Box::new(writer) as Box<dyn MemBackend>,
2450 )])
2451 .unwrap();
2452 let err = engine.worktree_for("missing").unwrap_err();
2453 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
2454 }
2455
2456 #[test]
2457 fn worktree_for_archive_mount_returns_archive_backed_error() {
2458 let tmp = TempDir::new().unwrap();
2459 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2460 let engine = Engine::from_mounts(vec![(
2461 archive_mount("ext", archive_path.clone()),
2462 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2463 )])
2464 .unwrap();
2465 let err = engine.worktree_for("ext").unwrap_err();
2466 match err {
2467 EngineError::Mem(msg) => assert!(msg.contains("archive-backed")),
2468 other => panic!("expected EngineError::Mem, got {other:?}"),
2469 }
2470 }
2471
2472 #[test]
2473 fn mem_head_sha_for_folder_mount_is_none() {
2474 let tmp = TempDir::new().unwrap();
2477 let mem_dir = tmp.path().to_path_buf();
2478 let writer = FilesystemMemWriter::new(mem_dir.clone());
2479 let engine = Engine::from_mounts(vec![(
2480 folder_mount("specs", mem_dir),
2481 Box::new(writer) as Box<dyn MemBackend>,
2482 )])
2483 .unwrap();
2484 let head = engine.mem_head_sha("specs").unwrap();
2485 assert_eq!(head, None);
2486 }
2487
2488 #[test]
2489 fn mem_head_sha_unknown_mem_returns_unknown_mem() {
2490 let tmp = TempDir::new().unwrap();
2491 let mem_dir = tmp.path().to_path_buf();
2492 let writer = FilesystemMemWriter::new(mem_dir.clone());
2493 let engine = Engine::from_mounts(vec![(
2494 folder_mount("specs", mem_dir),
2495 Box::new(writer) as Box<dyn MemBackend>,
2496 )])
2497 .unwrap();
2498 let err = engine.mem_head_sha("missing").unwrap_err();
2499 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
2500 }
2501
2502 #[test]
2503 fn capability_surfaces_per_mount() {
2504 let tmp = TempDir::new().unwrap();
2505 let mem_dir = tmp.path().to_path_buf();
2506 let writer = FilesystemMemWriter::new(mem_dir.clone());
2507 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2508
2509 let engine = Engine::from_mounts(vec![
2510 (
2511 folder_mount("writable", mem_dir),
2512 Box::new(writer) as Box<dyn MemBackend>,
2513 ),
2514 (
2515 archive_mount("read-only", archive_path.clone()),
2516 Box::new(ArchiveBackend::new(archive_path)),
2517 ),
2518 ])
2519 .unwrap();
2520
2521 assert_eq!(
2522 engine.capability("writable").unwrap(),
2523 MountCapability::Write
2524 );
2525 assert_eq!(
2526 engine.capability("read-only").unwrap(),
2527 MountCapability::ReadOnly
2528 );
2529 assert!(matches!(
2530 engine.capability("missing"),
2531 Err(EngineError::UnknownMem(_))
2532 ));
2533 }
2534
2535 #[test]
2536 fn read_provenance_routes_through_backend() {
2537 let tmp = TempDir::new().unwrap();
2538 let mem_dir = tmp.path().to_path_buf();
2539 let writer = FilesystemMemWriter::new(mem_dir.clone());
2540
2541 let backend_handle: &dyn MemBackend = &writer;
2544 backend_handle
2545 .append_provenance(&Provenance::new(
2546 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
2547 crate::ProvenanceKind::Create,
2548 Some("v:e".into()),
2549 crate::vcs::Actor::Cli,
2550 None,
2551 Some("first".into()),
2552 ))
2553 .unwrap();
2554
2555 let engine = Engine::from_mounts(vec![(
2556 folder_mount("specs", mem_dir),
2557 Box::new(writer) as Box<dyn MemBackend>,
2558 )])
2559 .unwrap();
2560
2561 let records = engine.read_provenance("specs", None).unwrap();
2562 assert_eq!(records.len(), 1);
2563 assert_eq!(records[0].kind, crate::ProvenanceKind::Create);
2564 assert_eq!(records[0].entity.as_deref(), Some("v:e"));
2565 assert_eq!(records[0].note.as_deref(), Some("first"));
2566 }
2567
2568 #[test]
2569 fn archive_mount_returns_sealed_indirectly_through_backend_layer() {
2570 let tmp = TempDir::new().unwrap();
2577 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2578 let backend = ArchiveBackend::new(archive_path);
2579 match MemBackend::write_entity(&backend, Path::new("x.md"), b"x") {
2580 Err(BackendError::Sealed) => {}
2581 other => panic!("expected Sealed, got {other:?}"),
2582 }
2583 }
2584
2585 fn build_demo_engine(tmp: &TempDir) -> Engine {
2595 let mem_dir = tmp.path().to_path_buf();
2596 let writer = FilesystemMemWriter::new(mem_dir.clone());
2597 let mut engine = Engine::from_mounts(vec![(
2598 folder_mount("specs", mem_dir),
2599 Box::new(writer) as Box<dyn MemBackend>,
2600 )])
2601 .unwrap();
2602 let (actor, client) = cli_actor();
2603 let source = engine
2604 .create_entity(
2605 empty_create_args("specs", "Source One"),
2606 actor,
2607 Some(&client),
2608 None,
2609 )
2610 .unwrap();
2611 let target = engine
2612 .create_entity(
2613 empty_create_args("specs", "Target Two"),
2614 actor,
2615 Some(&client),
2616 None,
2617 )
2618 .unwrap();
2619 engine
2620 .create_entity(
2621 empty_create_args("specs", "Lonely Three"),
2622 actor,
2623 Some(&client),
2624 None,
2625 )
2626 .unwrap();
2627 engine
2628 .relate_entity(
2629 RelateEntityArgs {
2630 source: source.id.clone(),
2631 expected_hash: Some(source.content_hash.clone()),
2632 rel_type: "USES".to_string(),
2633 target: target.id.clone(),
2634 remove: false,
2635 description: None,
2636 },
2637 actor,
2638 Some(&client),
2639 None,
2640 )
2641 .unwrap();
2642 engine
2643 }
2644
2645 #[test]
2646 fn stats_reports_per_engine_counts() {
2647 let tmp = TempDir::new().unwrap();
2648 let engine = build_demo_engine(&tmp);
2649 let stats = engine.stats();
2650 assert_eq!(stats.entity_count, 3);
2651 assert_eq!(stats.edge_count, 1);
2652 assert_eq!(stats.mem_count, 1);
2653 assert_eq!(stats.types_in_use, vec!["spec".to_string()]);
2654 assert_eq!(stats.edge_types.get("USES"), Some(&1));
2655 }
2656
2657 #[test]
2658 fn orphans_lists_unconnected_real_entities() {
2659 let tmp = TempDir::new().unwrap();
2660 let engine = build_demo_engine(&tmp);
2661 let orphans = engine.orphans();
2662 assert_eq!(orphans.len(), 1);
2663 assert_eq!(orphans[0].as_ref(), "specs--lonely-three");
2664 }
2665
2666 #[test]
2671 fn schema_breakdowns_attribute_to_mem_pin() {
2672 let tmp = TempDir::new().unwrap();
2673 let engine = build_demo_engine(&tmp);
2674
2675 let orphans = engine.orphans();
2676 let orphans_by_schema = engine.orphans_by_schema(&orphans);
2677 assert_eq!(
2678 orphans_by_schema.values().sum::<usize>(),
2679 orphans.len(),
2680 "per-schema orphan counts must sum to the raw total"
2681 );
2682 assert_eq!(orphans_by_schema.len(), 1, "one mem ⇒ one schema bucket");
2683 let (schema, count) = orphans_by_schema.iter().next().unwrap();
2684 assert!(!schema.is_empty(), "specs mem is pinned: {schema:?}");
2685 assert_eq!(*count, 1);
2686
2687 let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
2690 let communities_by_schema = engine.communities_by_schema(&mems);
2691 assert_eq!(communities_by_schema.len(), 1);
2692 assert_eq!(
2693 communities_by_schema.values().sum::<usize>(),
2694 engine.communities().count,
2695 );
2696 }
2697
2698 #[test]
2699 fn stubs_lists_unresolved_link_targets() {
2700 let tmp = TempDir::new().unwrap();
2701 let mem_dir = tmp.path().to_path_buf();
2702 let writer = FilesystemMemWriter::new(mem_dir.clone());
2703 let mut engine = Engine::from_mounts(vec![(
2704 folder_mount("specs", mem_dir),
2705 Box::new(writer) as Box<dyn MemBackend>,
2706 )])
2707 .unwrap();
2708 let (actor, client) = cli_actor();
2709 let source = engine
2710 .create_entity(
2711 empty_create_args("specs", "Holder"),
2712 actor,
2713 Some(&client),
2714 None,
2715 )
2716 .unwrap();
2717 engine
2720 .relate_entity(
2721 RelateEntityArgs {
2722 source: source.id.clone(),
2723 expected_hash: Some(source.content_hash.clone()),
2724 rel_type: "USES".to_string(),
2725 target: EntityId::new("specs", "ghost"),
2726 remove: false,
2727 description: None,
2728 },
2729 actor,
2730 Some(&client),
2731 None,
2732 )
2733 .unwrap();
2734 let stubs = engine.stubs();
2735 assert!(
2736 stubs.iter().any(|(id, _)| id.as_ref() == "specs--ghost"),
2737 "expected ghost stub: {stubs:?}"
2738 );
2739 }
2740
2741 #[test]
2742 fn most_connected_orders_by_degree() {
2743 let tmp = TempDir::new().unwrap();
2744 let engine = build_demo_engine(&tmp);
2745 let top = engine.most_connected(5);
2746 assert_eq!(top.len(), 3);
2747 let zero_degree: Vec<_> = top
2749 .iter()
2750 .filter(|c| c.total == 0)
2751 .map(|c| c.id.as_ref().to_string())
2752 .collect();
2753 assert_eq!(zero_degree, vec!["specs--lonely-three".to_string()]);
2754 }
2755
2756 #[test]
2757 fn health_returns_per_engine_summary() {
2758 let tmp = TempDir::new().unwrap();
2759 let engine = build_demo_engine(&tmp);
2760 let health = engine.health();
2761 assert!(
2769 health
2770 .missing_fields
2771 .iter()
2772 .all(|r| r.id.as_ref() != "specs--source-one"),
2773 "post-strict-create fixture must not surface as missing-fields; got {:?}",
2774 health.missing_fields,
2775 );
2776 }
2777
2778 #[test]
2779 fn context_carries_neighbors_and_community() {
2780 let tmp = TempDir::new().unwrap();
2781 let engine = build_demo_engine(&tmp);
2782 let source_id = EntityId::new("specs", "source-one");
2783 let ctx = engine.context(&source_id).unwrap();
2784 assert_eq!(ctx.entity_id, source_id);
2785 assert_eq!(ctx.neighbors.len(), 1);
2786 assert_eq!(ctx.neighbors[0].relationship, "USES");
2787 assert!(matches!(ctx.neighbors[0].direction, Direction::Outgoing));
2788 }
2789
2790 #[test]
2791 fn communities_caches_louvain_until_invalidated() {
2792 let tmp = TempDir::new().unwrap();
2793 let mut engine = build_demo_engine(&tmp);
2794 let entities_before = engine.communities().entity_cluster_map.len();
2796 assert_eq!(
2798 engine.communities().entity_cluster_map.len(),
2799 entities_before
2800 );
2801 let (actor, client) = cli_actor();
2804 engine
2805 .create_entity(
2806 empty_create_args("specs", "Disturber"),
2807 actor,
2808 Some(&client),
2809 None,
2810 )
2811 .unwrap();
2812 let entities_after = engine.communities().entity_cluster_map.len();
2813 assert_eq!(
2814 entities_after,
2815 entities_before + 1,
2816 "create_entity should have invalidated community cache and added the new entity"
2817 );
2818 }
2819
2820 #[test]
2821 fn list_filters_by_metadata_only() {
2822 let tmp = TempDir::new().unwrap();
2823 let engine = build_demo_engine(&tmp);
2824 let scope = SearchScope {
2825 entity_type: Some("spec".to_string()),
2826 ..Default::default()
2827 };
2828 let result = engine.list(&scope);
2829 assert_eq!(result.hits.len(), 3);
2831 }
2832
2833 #[test]
2834 fn list_applies_schema_declared_filter_on_non_default_schema_mem() {
2835 let tmp = TempDir::new().unwrap();
2842 let mem_dir = tmp.path().to_path_buf();
2843 let writer = FilesystemMemWriter::new(mem_dir.clone());
2844 let mount = Mount {
2845 mem: "planning".to_string(),
2846 schema: Some(memstead_schema::SchemaRef::new(
2847 "planning",
2848 semver::Version::new(0, 1, 0),
2849 )),
2850 storage: MountStorage::Folder { path: mem_dir },
2851 capability: MountCapability::Write,
2852 lifecycle: MountLifecycle::Eager,
2853 cross_linkable: true,
2854 migration_target: None,
2855 };
2856 let mut engine =
2857 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2858 let (actor, client) = cli_actor();
2859
2860 for (title, status) in &[("Skip Postgres", "accepted"), ("Use SQLite", "proposed")] {
2865 let mut metadata = indexmap::IndexMap::new();
2866 metadata.insert("status".to_string(), status.to_string());
2867 metadata.insert("deciders".to_string(), "alice".to_string());
2868 metadata.insert("decided_on".to_string(), "2026-05-19".to_string());
2869 let args = crate::engine::CreateEntityArgs {
2870 mem: "planning".to_string(),
2871 title: title.to_string(),
2872 entity_type: "decision".to_string(),
2873 sections: indexmap::IndexMap::from_iter([
2874 ("decision".to_string(), "We chose this.".to_string()),
2875 ("context".to_string(), "Single-user dev.".to_string()),
2876 ("consequences".to_string(), "Lose multi-writer.".to_string()),
2877 ]),
2878 metadata,
2879 relations: Vec::new(),
2880 dry_run: false,
2881 };
2882 engine
2883 .create_entity(args, actor, Some(&client), None)
2884 .unwrap();
2885 }
2886
2887 let scope = SearchScope {
2889 entity_type: Some("decision".to_string()),
2890 filters: std::collections::HashMap::from([(
2891 "status".to_string(),
2892 "accepted".to_string(),
2893 )]),
2894 ..Default::default()
2895 };
2896 let result = engine.list(&scope);
2897 assert_eq!(
2898 result.hits.len(),
2899 1,
2900 "filter on schema-declared field must select only matching entities"
2901 );
2902 assert_eq!(result.hits[0].title, "Skip Postgres");
2903 assert!(
2904 result.warnings.is_empty(),
2905 "no warning should fire when the filter is declared by the mem's pinned schema: {:?}",
2906 result.warnings
2907 );
2908 }
2909
2910 #[test]
2911 fn search_returns_results_against_built_index() {
2912 let tmp = TempDir::new().unwrap();
2913 let engine = build_demo_engine(&tmp);
2914 let scope = SearchScope {
2915 query: Some(crate::ops::Query {
2916 any: vec!["source".to_string()],
2917 ..Default::default()
2918 }),
2919 ..Default::default()
2920 };
2921 let result = engine.search(&scope).expect("native search returns Ok");
2922 assert!(result.total >= 1, "expected ≥1 hit for source: {result:?}");
2923 assert!(
2924 result
2925 .hits
2926 .iter()
2927 .any(|h| h.id.as_ref() == "specs--source-one"),
2928 "expected source-one in hits: {result:?}"
2929 );
2930 }
2931
2932 }