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 folder_mem_root(&self, mem: &str) -> Result<Option<PathBuf>, EngineError> {
185 let mount = self
186 .mounts
187 .iter()
188 .find(|m| m.mount.mem == mem)
189 .ok_or_else(|| self.unknown_mem_error(mem))?;
190 if self.quarantine_reason(mem).is_some() {
191 return Err(self.unknown_mem_error(mem));
192 }
193 match &mount.mount.storage {
194 crate::workspace::MountStorage::Folder { path } => Ok(Some(path.clone())),
195 _ => Ok(None),
196 }
197 }
198
199 pub fn settings(&self) -> &WorkspaceSettings {
206 &self.settings
207 }
208
209 pub fn pipeline_configs(&self) -> &crate::pipeline_store::BindingConfigs {
216 &self.pipeline_configs
217 }
218
219 pub fn pipeline_configs_json(&self) -> String {
232 let empty = || "{\"bindings\":[]}".to_string();
233 let Some(root) = self.workspace_root() else {
234 return empty();
235 };
236 match crate::pipeline_store::load_pipeline_configs(root) {
237 Ok(configs) => serde_json::to_string(&configs).unwrap_or_else(|_| empty()),
238 Err(_) => empty(),
239 }
240 }
241
242 pub fn set_pipeline_configs(&mut self, configs: crate::pipeline_store::BindingConfigs) {
247 self.pipeline_configs = configs;
248 }
249
250 pub fn note_missing_warning(&self, tool: &str, note: Option<&str>) -> Option<WarningHint> {
267 if !self.settings.mutations.require_notes.unwrap_or(false) {
268 return None;
269 }
270 let has_note = note.map(|n| !n.trim().is_empty()).unwrap_or(false);
271 if has_note {
272 return None;
273 }
274 Some(WarningHint::NoteMissing {
275 tool: tool.to_string(),
276 })
277 }
278
279 pub fn backend_factory(&self) -> BackendFactory {
285 self.backend_factory
286 }
287
288 pub fn git_branch_ops(&self) -> Option<super::GitBranchOps> {
295 self.git_branch_ops
296 }
297
298 pub fn get_entity(&self, id: &EntityId) -> Option<&Entity> {
303 self.store.get(id)
304 }
305
306 pub fn anchors_sidecar_error(&self, mem: &str) -> Option<String> {
327 let mount = self.mounts.iter().find(|m| m.mount.mem == mem)?;
328 let bytes = match mount.backend.read_anchors_sidecar() {
332 Err(e) => return Some(format!("could not read the sidecar: {e}")),
335 Ok(None) => return None,
337 Ok(Some(b)) => b,
338 };
339 if bytes.iter().all(|b| b.is_ascii_whitespace()) {
344 return Some(
345 "the sidecar file is empty — an interrupted write leaves this state, and it is not the same as having no anchors; remove the file if the mem genuinely has none"
346 .to_string(),
347 );
348 }
349 match crate::anchor::AnchorSidecar::from_bytes(&bytes) {
350 Ok(_) => None,
351 Err(e) => Some(e.to_string()),
352 }
353 }
354
355 pub fn entity_anchors(&self, id: &EntityId) -> Vec<crate::anchor::Anchor> {
356 let Some(mount) = self.mounts.iter().find(|m| m.mount.mem == id.mem()) else {
357 return Vec::new();
358 };
359 let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
360 return Vec::new();
361 };
362 match crate::anchor::AnchorSidecar::from_bytes(&bytes) {
363 Ok(sc) => sc.get(id.as_ref()).to_vec(),
364 Err(_) => Vec::new(),
368 }
369 }
370
371 pub fn entity_anchors_resolved(&self, id: &EntityId) -> Vec<ResolvedAnchor> {
403 let anchors = self.entity_anchors(id);
404 let source_roots = self.anchor_source_roots(id.mem());
405 anchors
406 .into_iter()
407 .map(|anchor| {
408 let observed = self.observe_anchor(&anchor, &source_roots);
409 let (state, observed_hash) = match observed {
410 Some((state, hash)) => (Some(state), hash),
411 None => (None, None),
412 };
413 ResolvedAnchor {
414 anchor,
415 state,
416 observed_hash,
417 }
418 })
419 .collect()
420 }
421
422 fn observe_anchor(
445 &self,
446 anchor: &crate::anchor::Anchor,
447 source_roots: &std::collections::BTreeMap<String, String>,
448 ) -> Option<(crate::anchor::AnchorState, Option<String>)> {
449 if anchor.grain == crate::anchor::AnchorGrain::Entity {
456 return self.observe_entity_anchor(anchor);
457 }
458 let root = self.workspace_root.as_deref()?;
459 let source_pointer = anchor
460 .source
461 .as_deref()
462 .and_then(|name| source_roots.get(name))
463 .map(String::as_str);
464 observe_path_anchor(root, anchor, source_pointer)
465 }
466
467 fn observe_entity_anchor(
483 &self,
484 anchor: &crate::anchor::Anchor,
485 ) -> Option<(crate::anchor::AnchorState, Option<String>)> {
486 let id = EntityId::canonical(&anchor.artifact);
487
488 if !self.mounts.iter().any(|m| m.mount.mem == id.mem()) {
502 return None;
503 }
504
505 let entity = self.store.get(&id).filter(|e| !e.stub);
506 let Some(entity) = entity else {
507 return Some((
508 crate::anchor::resolve_anchor(anchor, &crate::anchor::ArtifactObservation::Absent),
509 None,
510 ));
511 };
512 let current_hash = if anchor.class.is_hash_bearing() {
513 let rendered = crate::render::render_entity_markdown(entity, None);
514 Some(crate::anchor::prepared_content_hash(rendered.as_bytes()))
515 } else {
516 None
517 };
518 let observation = crate::anchor::ArtifactObservation::Present {
519 current_hash: current_hash.clone(),
520 };
521 Some((
522 crate::anchor::resolve_anchor(anchor, &observation),
523 current_hash,
524 ))
525 }
526
527 pub(crate) fn anchor_source_roots(
534 &self,
535 mem: &str,
536 ) -> std::collections::BTreeMap<String, String> {
537 let mut roots = std::collections::BTreeMap::new();
538 let Some(root) = self.workspace_root.as_deref() else {
539 return roots;
540 };
541 let Ok(configs) = crate::pipeline_store::load_pipeline_configs(root) else {
542 return roots;
543 };
544 for record in configs.bindings.iter().filter(|r| r.mem == mem) {
545 for source in &record.config.sources {
546 roots
547 .entry(source.name.clone())
548 .or_insert_with(|| source.pointer.clone());
549 }
550 }
551 roots
552 }
553
554 pub fn anchors_referencing_artifact(
563 &self,
564 artifact_path: &str,
565 ) -> Vec<(EntityId, crate::anchor::Anchor)> {
566 let mut out = Vec::new();
567 for mount in &self.mounts {
568 let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
569 continue;
570 };
571 let Ok(sc) = crate::anchor::AnchorSidecar::from_bytes(&bytes) else {
572 continue;
573 };
574 let source_roots = self.anchor_source_roots(&mount.mount.mem);
577 for (eid, anchors) in &sc.entities {
578 for a in anchors {
579 let joined = a
580 .source
581 .as_deref()
582 .and_then(|name| source_roots.get(name))
583 .map(|pointer| join_pointer(pointer, anchor_base_path(&a.artifact)));
584 if anchor_references_path(a, artifact_path)
585 || joined.is_some_and(|j| {
586 path_references(
587 &j,
588 a.grain == crate::anchor::AnchorGrain::Tree,
589 artifact_path,
590 )
591 })
592 {
593 out.push((EntityId(eid.clone()), a.clone()));
594 }
595 }
596 }
597 }
598 out
599 }
600
601 pub fn mem_anchors_resolved(&self, mem: &str) -> Vec<(EntityId, ResolvedAnchor)> {
614 let Some(mount) = self.mounts.iter().find(|m| m.mount.mem == mem) else {
615 return Vec::new();
616 };
617 let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
618 return Vec::new();
619 };
620 let Ok(sc) = crate::anchor::AnchorSidecar::from_bytes(&bytes) else {
621 return Vec::new();
622 };
623 let mut out = Vec::new();
624 let source_roots = self.anchor_source_roots(mem);
625 for (eid, anchors) in &sc.entities {
626 for anchor in anchors {
627 let observed = self.observe_anchor(anchor, &source_roots);
628 let (state, observed_hash) = match observed {
629 Some((state, hash)) => (Some(state), hash),
630 None => (None, None),
631 };
632 out.push((
633 EntityId(eid.clone()),
634 ResolvedAnchor {
635 anchor: anchor.clone(),
636 state,
637 observed_hash,
638 },
639 ));
640 }
641 }
642 out
643 }
644
645 pub fn verify_mem_anchors(&self, mem: &str) -> Result<MemAnchorVerification, EngineError> {
658 if !self.mem_router.is_visible(mem) {
659 return Err(self.unknown_mem_error(mem));
660 }
661 let mut report = MemAnchorVerification {
662 mem: mem.to_string(),
663 ..Default::default()
664 };
665 for (eid, resolved) in self.mem_anchors_resolved(mem) {
666 let state = match resolved.state {
667 Some(crate::anchor::AnchorState::Resolves) => {
668 report.resolved += 1;
669 "resolved"
670 }
671 Some(crate::anchor::AnchorState::Drifted) => {
672 report.drifted += 1;
673 "drifted"
674 }
675 Some(crate::anchor::AnchorState::Recheck) => {
676 report.recheck += 1;
677 "recheck"
678 }
679 Some(crate::anchor::AnchorState::Orphaned) | None => {
680 report.unresolvable += 1;
681 "unresolvable"
682 }
683 };
684 report.anchors.push(VerifiedAnchor {
685 entity_id: eid.to_string(),
686 artifact: resolved.anchor.artifact.clone(),
687 grain: resolved.anchor.grain.as_wire().to_string(),
688 class: resolved.anchor.class.as_wire().to_string(),
689 state: state.to_string(),
690 observed_hash: resolved.observed_hash,
691 });
692 }
693 Ok(report)
694 }
695
696 pub fn mem_names(&self) -> Vec<&str> {
700 self.mounts.iter().map(|m| m.mount.mem.as_str()).collect()
701 }
702
703 pub fn derivation_report(
714 &self,
715 mem: &str,
716 ) -> Result<Vec<crate::ops::health::DerivationFinding>, EngineError> {
717 if !self.mem_router.is_visible(mem) {
718 return Err(self.unknown_mem_error(mem));
719 }
720 let Some(schema) = self.schemas.get(mem) else {
721 return Ok(Vec::new());
722 };
723 let declared: std::collections::HashSet<&str> = schema
724 .manifest
725 .relationships
726 .definitions
727 .iter()
728 .filter(|d| d.derivation)
729 .map(|d| d.name.as_str())
730 .collect();
731 if declared.is_empty() {
732 return Ok(Vec::new());
733 }
734 let sidecar = self
735 .mounts
736 .iter()
737 .find(|m| m.mount.mem == mem)
738 .and_then(|m| {
739 m.backend
740 .read_entity(Path::new(crate::derivation::DERIVATION_SIDECAR_PATH))
741 .ok()
742 .flatten()
743 })
744 .and_then(|bytes| crate::derivation::DerivationSidecar::from_bytes(&bytes).ok())
745 .unwrap_or_default();
746
747 let mut out = Vec::new();
748 let mut sources: Vec<&crate::entity::Entity> = self
749 .store
750 .all_entities()
751 .filter(|e| !e.stub && e.id.mem() == mem)
752 .collect();
753 sources.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
754 for entity in sources {
755 for edge in self.store.outgoing(&entity.id) {
756 if !declared.contains(edge.rel_type.as_str())
757 || edge.source != crate::store::EdgeSource::Explicit
758 {
759 continue;
760 }
761 let current = self
762 .store
763 .get(&edge.target)
764 .map(|t| t.content_hash.clone())
765 .unwrap_or_default();
766 match sidecar.get(entity.id.as_ref(), &edge.rel_type, edge.target.as_ref()) {
767 None => out.push(crate::ops::health::DerivationFinding {
768 source: entity.id.clone(),
769 rel_type: edge.rel_type.clone(),
770 target: edge.target.clone(),
771 state: "unbaselined".to_string(),
772 baseline: None,
773 current,
774 }),
775 Some(baseline) if baseline != current => {
776 out.push(crate::ops::health::DerivationFinding {
777 source: entity.id.clone(),
778 rel_type: edge.rel_type.clone(),
779 target: edge.target.clone(),
780 state: "stale".to_string(),
781 baseline: Some(baseline.to_string()),
782 current,
783 })
784 }
785 Some(_) => {}
786 }
787 }
788 }
789 Ok(out)
790 }
791
792 pub fn mount(&self, mem: &str) -> Option<&crate::workspace::Mount> {
810 self.mounts
811 .iter()
812 .find(|m| m.mount.mem == mem)
813 .map(|m| &m.mount)
814 }
815
816 pub fn orphans_by_schema(
823 &self,
824 orphan_ids: &[EntityId],
825 ) -> std::collections::BTreeMap<String, usize> {
826 let mut by_schema = std::collections::BTreeMap::new();
827 for id in orphan_ids {
828 let schema = self
829 .store()
830 .get(id)
831 .and_then(|e| self.mount(&e.mem))
832 .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
833 .unwrap_or_default();
834 *by_schema.entry(schema).or_insert(0) += 1;
835 }
836 by_schema
837 }
838
839 pub fn communities_by_schema(
845 &self,
846 mems: &[String],
847 ) -> std::collections::BTreeMap<String, usize> {
848 let louvain = self.communities();
849 let mut buckets: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
850 std::collections::BTreeMap::new();
851 for name in mems {
852 let schema = self
853 .mount(name)
854 .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
855 .unwrap_or_default();
856 let clusters = crate::graph::community::clusters_in_mem(self.store(), louvain, name);
857 buckets.entry(schema).or_default().extend(clusters);
858 }
859 buckets
860 .into_iter()
861 .map(|(schema, set)| (schema, set.len()))
862 .collect()
863 }
864
865 pub fn mounts(&self) -> Vec<&crate::workspace::Mount> {
869 self.mounts.iter().map(|m| &m.mount).collect()
870 }
871
872 pub fn writable_mem_names(&self) -> Vec<&str> {
880 self.mounts
881 .iter()
882 .filter(|m| m.mount.capability == MountCapability::Write)
883 .map(|m| m.mount.mem.as_str())
884 .collect()
885 }
886
887 pub fn default_writable_mem(&self) -> Option<&str> {
905 self.mounts
906 .iter()
907 .find(|m| m.mount.capability == MountCapability::Write)
908 .map(|m| m.mount.mem.as_str())
909 }
910
911 pub fn folder_path_for_mem(&self, mem: &str) -> Option<&Path> {
919 match self.mount(mem).map(|m| &m.storage) {
920 Some(crate::workspace::MountStorage::Folder { path }) => Some(path.as_path()),
921 _ => None,
922 }
923 }
924
925 pub fn mem_router(&self) -> &MemRouterSnapshot {
935 &self.mem_router
936 }
937
938 pub fn gitdir_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
947 let m = self
948 .mount(mem_name)
949 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
950 match &m.storage {
951 MountStorage::GitBranch { gitdir, .. } => Ok(gitdir.clone()),
952 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
953 Err(EngineError::Mem(format!(
954 "mem '{mem_name}' has no resolved gitdir"
955 )))
956 }
957 }
958 }
959
960 pub fn worktree_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
975 let m = self
976 .mount(mem_name)
977 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
978 match &m.storage {
979 MountStorage::Folder { path } => Ok(path.clone()),
980 MountStorage::GitBranch { .. } => {
981 if let Some(root) = self.workspace_root.as_deref() {
982 let candidate = root.join(mem_name);
983 if candidate
984 .join(crate::mem::MEM_META_DIR)
985 .join("config.json")
986 .is_file()
987 {
988 return Ok(candidate.canonicalize().unwrap_or(candidate));
989 }
990 }
991 Err(EngineError::Mem(format!(
992 "mem '{mem_name}' has no working tree (mem-repo-backed)"
993 )))
994 }
995 MountStorage::Archive { .. } => Err(EngineError::Mem(format!(
996 "mem '{mem_name}' is archive-backed and has no worktree"
997 ))),
998 MountStorage::InMemory => Err(EngineError::Mem(format!(
999 "mem '{mem_name}' is in-memory and has no worktree"
1000 ))),
1001 }
1002 }
1003
1004 pub fn mem_config_for(&self, mem: &str) -> Option<&memstead_schema::config::MemConfig> {
1022 self.mounts
1023 .iter()
1024 .find(|m| m.mount.mem == mem)
1025 .and_then(|m| m.mem_config.as_ref())
1026 }
1027
1028 pub fn archive_provenance_for(&self, mem: &str) -> Option<&memstead_schema::ArchiveProvenance> {
1035 self.mounts
1036 .iter()
1037 .find(|m| m.mount.mem == mem)
1038 .and_then(|m| m.archive_provenance.as_ref())
1039 }
1040
1041 pub fn mem_configs_named(
1052 &self,
1053 ) -> impl Iterator<Item = (&str, &memstead_schema::config::MemConfig)> {
1054 self.mounts
1055 .iter()
1056 .filter_map(|m| m.mem_config.as_ref().map(|c| (m.mount.mem.as_str(), c)))
1057 }
1058
1059 pub fn schema_for(&self, mem: &str) -> Option<std::sync::Arc<memstead_schema::Schema>> {
1066 self.schemas.get(mem).cloned()
1067 }
1068
1069 pub fn mem_head_sha(&self, mem_name: &str) -> Result<Option<String>, EngineError> {
1080 let m = self
1081 .mounts
1082 .iter()
1083 .find(|m| m.mount.mem == mem_name)
1084 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1085 Ok(m.last_known_head.clone())
1086 }
1087
1088 pub fn mem_drifted(&self, mem_name: &str) -> Result<bool, EngineError> {
1103 let m = self
1104 .mounts
1105 .iter()
1106 .find(|m| m.mount.mem == mem_name)
1107 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1108 let live = m.backend.current_head().ok().flatten();
1109 Ok(live != m.last_known_head)
1110 }
1111
1112 pub fn workspace_root(&self) -> Option<&Path> {
1117 self.workspace_root.as_deref()
1118 }
1119
1120 pub fn load_warnings(&self) -> &[WarningHint] {
1125 &self.load_warnings
1126 }
1127
1128 pub fn quarantined_mems(&self) -> &[crate::engine::QuarantinedMem] {
1132 &self.quarantined
1133 }
1134
1135 pub fn quarantine_reason(&self, mem: &str) -> Option<&crate::engine::QuarantinedMem> {
1137 self.quarantined.iter().find(|q| q.mount.mem == mem)
1138 }
1139
1140 pub fn deferred_mems(&self) -> Vec<&str> {
1149 self.mounts
1150 .iter()
1151 .filter(|m| m.deferred)
1152 .map(|m| m.mount.mem.as_str())
1153 .collect()
1154 }
1155
1156 pub fn mem_is_deferred(&self, mem: &str) -> bool {
1158 self.mounts.iter().any(|m| m.deferred && m.mount.mem == mem)
1159 }
1160
1161 pub fn unknown_mem_error(&self, mem: &str) -> EngineError {
1168 match self.quarantine_reason(mem) {
1169 Some(q) => EngineError::MemQuarantined {
1170 mem: mem.to_string(),
1171 reason_code: q.reason_code.clone(),
1172 reason_message: q.reason_message.clone(),
1173 },
1174 None => EngineError::UnknownMem(mem.to_string()),
1175 }
1176 }
1177
1178 pub fn boot_diagnosis(&self) -> Option<(&str, &str)> {
1181 self.boot_diagnosis
1182 .as_ref()
1183 .map(|(c, m)| (c.as_str(), m.as_str()))
1184 }
1185
1186 pub fn diagnostic_shell(reason_code: String, reason_message: String) -> Engine {
1194 let mut engine =
1195 Engine::from_mounts(Vec::new()).expect("an empty mount list always constructs");
1196 engine.boot_diagnosis = Some((reason_code, reason_message));
1197 engine
1198 }
1199
1200 pub fn extend_quarantine(&mut self, entries: Vec<crate::engine::QuarantinedMem>) {
1206 self.quarantined.extend(entries);
1207 }
1208
1209 pub fn communities(&self) -> &LouvainOutput {
1238 if let Some((memo_key, _)) = self.community_memo.get() {
1244 debug_assert_eq!(
1245 *memo_key,
1246 self.derived_key(),
1247 "community memo key lags the engine — a mutation path missed invalidate_communities"
1248 );
1249 }
1250 &self
1251 .community_memo
1252 .get_or_init(|| (self.derived_key(), self.compute_communities()))
1253 .1
1254 }
1255
1256 pub fn derived_key(&self) -> super::DerivedKey {
1259 super::DerivedKey {
1260 store_generation: self.store.generation(),
1261 schemas_epoch: self.schemas_epoch,
1262 }
1263 }
1264
1265 fn compute_communities(&self) -> LouvainOutput {
1266 {
1267 let schema = self
1274 .schemas
1275 .iter()
1276 .min_by(|a, b| a.0.cmp(b.0))
1277 .map(|(_, s)| s.clone())
1278 .unwrap_or_else(Schema::builtin_default);
1279 let manifest = &schema.manifest;
1280 let resolution = manifest.community.resolution;
1281 let seed = manifest.community.seed;
1282 let schema_for_weights = schema.clone();
1283 detect_communities(&self.store, resolution, seed, move |rel_type| {
1284 schema_for_weights
1285 .manifest
1286 .relationships
1287 .definitions
1288 .iter()
1289 .find(|d| d.name == rel_type)
1290 .map(|d| d.default_weight as f64)
1291 .unwrap_or(1.0)
1292 })
1293 }
1294 }
1295
1296 pub fn invalidate_communities(&mut self) {
1309 let key = self.derived_key();
1310 if !matches!(self.community_memo.get(), Some((k, _)) if *k == key) {
1311 self.community_memo = OnceCell::new();
1312 }
1313 if !matches!(self.labelling_memo.get(), Some((k, _)) if *k == key) {
1314 self.labelling_memo = OnceCell::new();
1315 }
1316 }
1317
1318 pub fn mem_labelling(&self, mem: &str) -> Option<&crate::ops::labelling::MemLabelling> {
1323 if let Some((memo_key, _)) = self.labelling_memo.get() {
1327 debug_assert_eq!(
1328 *memo_key,
1329 self.derived_key(),
1330 "labelling memo key lags the engine — a mutation path missed invalidate_communities"
1331 );
1332 }
1333 let (_, map) = self.labelling_memo.get_or_init(|| {
1334 let mut out = std::collections::HashMap::new();
1335 for (mem_name, schema) in &self.schemas {
1336 if let Some(lab) = crate::ops::labelling::labelling_of(schema) {
1337 out.insert(
1338 mem_name.clone(),
1339 crate::ops::labelling::compute_mem_labelling(
1340 &self.store,
1341 mem_name,
1342 &lab.attack,
1343 ),
1344 );
1345 }
1346 }
1347 (self.derived_key(), out)
1348 });
1349 map.get(mem)
1350 }
1351
1352 pub fn computed_labelling(
1358 &self,
1359 entity: &Entity,
1360 ) -> Option<crate::ops::labelling::LabellingView> {
1361 use crate::ops::labelling::{Label, compute_shape, labelling_of};
1362 if entity.stub {
1363 return None;
1364 }
1365 let schema = self.schemas.get(entity.mem.as_str())?;
1366 let lab = labelling_of(schema)?;
1367 let mem_lab = self.mem_labelling(entity.mem.as_str())?;
1368 let label = *mem_lab.labels.get(entity.id.0.as_str())?;
1369 let defeated_by = if label == Label::Defeated {
1370 mem_lab.accepted_attackers_of(entity.id.0.as_str())
1371 } else {
1372 Vec::new()
1373 };
1374 let undecided_by = if label == Label::Undecided {
1375 mem_lab.undecided_attackers_of(entity.id.0.as_str())
1376 } else {
1377 Vec::new()
1378 };
1379 let shape = lab.support.as_ref().map(|walk| {
1380 let label_of = |id: &EntityId| -> Option<Label> {
1381 self.mem_labelling(id.mem())
1382 .and_then(|ml| ml.labels.get(id.0.as_str()).copied())
1383 };
1384 compute_shape(&self.store, &entity.id, walk, &label_of)
1385 });
1386 Some(crate::ops::labelling::LabellingView {
1387 label,
1388 defeated_by,
1389 undecided_by,
1390 shape,
1391 })
1392 }
1393
1394 pub fn health_labelling_axis(&self, mem_filter: Option<&str>) -> serde_json::Value {
1400 use crate::ops::labelling::Label;
1401 let mut mems = serde_json::Map::new();
1402 let mut mem_names: Vec<&String> = self.schemas.keys().collect();
1403 mem_names.sort();
1404 for mem in mem_names {
1405 if let Some(v) = mem_filter
1406 && mem != v
1407 {
1408 continue;
1409 }
1410 let Some(ml) = self.mem_labelling(mem) else {
1411 continue;
1412 };
1413 let mut accepted = 0usize;
1414 let mut defeated: Vec<serde_json::Value> = Vec::new();
1415 let mut undecided: Vec<serde_json::Value> = Vec::new();
1416 for (id, label) in &ml.labels {
1417 match label {
1418 Label::Accepted => accepted += 1,
1419 Label::Defeated => defeated.push(serde_json::json!({
1420 "id": id,
1421 "defeated_by": ml.accepted_attackers_of(id),
1422 })),
1423 Label::Undecided => undecided.push(serde_json::json!({
1424 "id": id,
1425 "undecided_by": ml.undecided_attackers_of(id),
1426 })),
1427 }
1428 }
1429 mems.insert(
1430 mem.clone(),
1431 serde_json::json!({
1432 "counts": {
1433 "accepted": accepted,
1434 "defeated": defeated.len(),
1435 "undecided": undecided.len(),
1436 },
1437 "defeated": defeated,
1438 "undecided": undecided,
1439 "cross_mem_edges_excluded": ml.cross_mem_edges_excluded,
1440 }),
1441 );
1442 }
1443 serde_json::Value::Object(mems)
1444 }
1445
1446 pub fn orphans(&self) -> Vec<EntityId> {
1450 crate::graph::query::find_orphans_with_schemas(&self.store, &self.schemas)
1451 }
1452
1453 pub fn leaf_population(&self) -> std::collections::BTreeMap<String, usize> {
1457 crate::graph::query::leaf_population(&self.store, &self.schemas)
1458 }
1459
1460 pub fn stubs(&self) -> Vec<(EntityId, Vec<EntityId>)> {
1462 crate::graph::query::find_stubs(&self.store)
1463 }
1464
1465 pub fn most_connected(&self, limit: usize) -> Vec<crate::graph::query::Connectivity> {
1467 crate::graph::query::most_connected(&self.store, limit)
1468 }
1469
1470 pub fn missing_required_outgoing(
1474 &self,
1475 mem_filter: Option<&str>,
1476 ) -> Vec<crate::ops::health::MissingRequiredOutgoingReport> {
1477 crate::ops::health::collect_missing_required_outgoing(
1478 &self.store,
1479 mem_filter,
1480 &self.schemas,
1481 )
1482 }
1483
1484 pub fn constraint_findings(
1489 &self,
1490 mem_filter: Option<&str>,
1491 ) -> Vec<crate::ops::health::ConstraintFindingReport> {
1492 crate::ops::health::collect_constraint_findings(&self.store, mem_filter, &self.schemas)
1493 }
1494
1495 pub fn computed_signals(
1500 &self,
1501 entity: &Entity,
1502 ) -> Option<Vec<crate::ops::signals::ComputedSignal>> {
1503 if entity.stub {
1504 return None;
1505 }
1506 let schema = self.schemas.get(entity.mem.as_str())?;
1507 let td = schema.types.get(entity.entity_type.as_str())?;
1508 if td.signals.is_empty() {
1509 return None;
1510 }
1511 Some(crate::ops::signals::compute_signals(
1512 &self.store,
1513 td,
1514 &entity.id,
1515 ))
1516 }
1517
1518 pub fn signal_reports(
1521 &self,
1522 mem_filter: Option<&str>,
1523 ) -> Vec<crate::ops::health::SignalReport> {
1524 crate::ops::health::collect_signal_reports(&self.store, mem_filter, &self.schemas)
1525 }
1526
1527 pub fn health_signals_axis(&self, mem_filter: Option<&str>) -> serde_json::Value {
1532 use memstead_schema::SignalLevel;
1533 let reports = self.signal_reports(mem_filter);
1534 let mut notice = 0usize;
1535 let mut warn = 0usize;
1536 for r in &reports {
1537 for s in &r.signals {
1538 match s.level {
1539 Some(SignalLevel::Notice) => notice += 1,
1540 Some(SignalLevel::Warn) => warn += 1,
1541 None => {}
1542 }
1543 }
1544 }
1545 serde_json::json!({
1546 "entities": reports,
1547 "counts": { "notice": notice, "warn": warn },
1548 })
1549 }
1550
1551 pub fn schema_format_defects(&self) -> Vec<crate::ops::health::SchemaFormatDefect> {
1554 crate::ops::health::collect_schema_format_defects(&self.schemas)
1555 }
1556
1557 pub fn conformance_findings(
1563 &self,
1564 mem: &str,
1565 target_schema: Option<&memstead_schema::SchemaRef>,
1566 ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
1567 let pinned = self
1568 .schemas
1569 .get(mem)
1570 .ok_or_else(|| self.unknown_mem_error(mem))?;
1571 let effective: Arc<Schema> = match target_schema {
1572 None => pinned.clone(),
1573 Some(target) => self.resolve_schema_by_ref(target).ok_or_else(|| {
1574 let consulted: Vec<_> = self
1575 .workspace_schemas
1576 .iter()
1577 .chain(self.builtin_schemas.iter())
1578 .cloned()
1579 .collect();
1580 EngineError::SchemaNotFound {
1581 mem: mem.to_string(),
1582 pin: target.as_display(),
1583 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1584 &target.name,
1585 &target.version,
1586 &consulted,
1587 ),
1588 install_hint: None,
1589 }
1590 .with_schema_install_probe(self.workspace_root())
1591 })?,
1592 };
1593 Ok(crate::ops::integrity::conformance_findings(
1594 &self.store,
1595 mem,
1596 &effective,
1597 &self.schemas,
1598 ))
1599 }
1600
1601 pub(crate) fn resolve_schema_by_ref(
1605 &self,
1606 target: &memstead_schema::SchemaRef,
1607 ) -> Option<Arc<Schema>> {
1608 self.schemas
1609 .values()
1610 .chain(self.workspace_schemas.iter())
1611 .chain(self.builtin_schemas.iter())
1612 .find(|s| {
1613 let (name, version) = s.id();
1614 name == target.name && version == target.version
1615 })
1616 .cloned()
1617 }
1618
1619 pub fn schema_pin(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
1624 self.mounts
1625 .iter()
1626 .find(|m| m.mount.mem == mem)
1627 .and_then(|m| m.mount.schema.clone())
1628 }
1629
1630 pub fn migration_target(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
1633 self.mounts
1634 .iter()
1635 .find(|m| m.mount.mem == mem)
1636 .and_then(|m| m.mount.migration_target.clone())
1637 }
1638
1639 pub fn consistency_findings(
1643 &self,
1644 mem: &str,
1645 ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
1646 if !self.schemas.contains_key(mem) {
1647 return Err(self.unknown_mem_error(mem));
1648 }
1649 Ok(crate::ops::integrity::consistency_findings(
1650 &self.store,
1651 mem,
1652 ))
1653 }
1654
1655 pub fn health(&self) -> crate::ops::HealthSummary {
1657 self.health_inner(None)
1658 }
1659
1660 pub fn health_scoped(
1669 &self,
1670 mem: Option<&str>,
1671 ) -> Result<crate::ops::HealthSummary, crate::EngineError> {
1672 if let Some(name) = mem
1673 && (self.quarantine_reason(name).is_some() || !self.mem_router.is_visible(name))
1674 {
1675 return Err(self.unknown_mem_error(name));
1676 }
1677 Ok(self.health_inner(mem))
1678 }
1679
1680 fn health_inner(&self, mem: Option<&str>) -> crate::ops::HealthSummary {
1681 let fallback = engine_fallback_type();
1682 let mut summary =
1683 crate::ops::health::compute_health(&self.store, fallback.as_ref(), &self.schemas, mem);
1684 if !self.load_warnings.is_empty() {
1691 let mut merged = self.load_warnings.clone();
1692 merged.append(&mut summary.warnings);
1693 summary.warnings = merged;
1694 }
1695 summary.quarantined = self
1699 .quarantined
1700 .iter()
1701 .map(|q| crate::ops::QuarantinedMemReport {
1702 mem: q.mount.mem.clone(),
1703 reason_code: q.reason_code.clone(),
1704 reason_message: q.reason_message.clone(),
1705 })
1706 .collect();
1707 summary.load_errors = self
1713 .load_errors
1714 .iter()
1715 .map(|(path, msg)| crate::ops::LoadErrorReport {
1716 file: path.display().to_string(),
1717 error: msg.clone(),
1718 })
1719 .collect();
1720 summary.boot_diagnosis = self
1721 .boot_diagnosis
1722 .as_ref()
1723 .map(|(code, message)| serde_json::json!({ "code": code, "message": message }));
1724 if let Some(root) = self.workspace_root.as_deref()
1730 && let Some(outer) = crate::workspace_root::find_enclosing_git_repo(root)
1731 && !crate::workspace_root::outer_repo_ignores_mem_repo(&outer, root)
1732 {
1733 summary
1734 .warnings
1735 .push(WarningHint::OuterRepoNotIgnoringMemRepo {
1736 outer_repo_root: outer.display().to_string(),
1737 workspace_root: root.display().to_string(),
1738 });
1739 }
1740 summary.warnings.extend(self.authoring_drift_findings());
1747 summary.warnings.extend(self.unstamped_rot_findings());
1756 if let Some(v) = mem {
1761 summary
1762 .warnings
1763 .retain(|w| w.source_mem().is_none_or(|wv| wv == v));
1764 }
1765 summary
1766 }
1767
1768 fn authoring_drift_findings(&self) -> Vec<WarningHint> {
1773 let Some(root) = self.workspace_root.as_deref() else {
1774 return Vec::new();
1775 };
1776 let mut pins: std::collections::BTreeMap<(String, String), Vec<String>> =
1779 std::collections::BTreeMap::new();
1780 for (mem, schema) in &self.schemas {
1781 let (name, version) = schema.id();
1782 pins.entry((name.to_string(), version.to_string()))
1783 .or_default()
1784 .push(mem.clone());
1785 }
1786 let mut out = Vec::new();
1787 for ((name, version), mut mems) in pins {
1788 mems.sort();
1789 let Some(stamped_path) = self.read_install_provenance(root, &name, &version) else {
1790 continue;
1791 };
1792 let schema_ref = format!("{name}@{version}");
1793 let authoring = std::path::Path::new(&stamped_path);
1794 if !authoring.is_dir() {
1795 out.push(WarningHint::SchemaAuthoringSourceMissing {
1796 schema_ref,
1797 stamped_path,
1798 mems,
1799 });
1800 continue;
1801 }
1802 let sealed = self
1803 .schemas
1804 .get(&mems[0])
1805 .expect("mems collected from self.schemas keys")
1806 .clone();
1807 match memstead_schema::load_schema_from_dir(authoring) {
1808 Err(e) => out.push(WarningHint::SchemaAuthoringSourceDiverged {
1809 schema_ref,
1810 stamped_path,
1811 mems,
1812 detail: format!("the authoring package no longer loads: {e}"),
1813 }),
1814 Ok(authored) => {
1815 if schema_parsed_fingerprint(&authored) != schema_parsed_fingerprint(&sealed) {
1816 out.push(WarningHint::SchemaAuthoringSourceDiverged {
1817 schema_ref,
1818 stamped_path,
1819 mems,
1820 detail: "the parsed authoring package differs from the sealed copy \
1821 the engine runs on"
1822 .to_string(),
1823 });
1824 }
1825 }
1826 }
1827 }
1828 out
1829 }
1830
1831 fn unstamped_rot_findings(&self) -> Vec<WarningHint> {
1840 let Some(root) = self.workspace_root.as_deref() else {
1841 return Vec::new();
1842 };
1843 let mut pins: std::collections::BTreeMap<(String, String), Vec<String>> =
1844 std::collections::BTreeMap::new();
1845 for (mem, schema) in &self.schemas {
1846 let (name, version) = schema.id();
1847 pins.entry((name.to_string(), version.to_string()))
1848 .or_default()
1849 .push(mem.clone());
1850 }
1851 let mut out = Vec::new();
1852 for ((name, version), mut mems) in pins {
1853 mems.sort();
1854 if self
1855 .read_install_provenance(root, &name, &version)
1856 .is_some()
1857 {
1858 continue; }
1860 let schema_ref = format!("{name}@{version}");
1861 let sealed_dir = root.join(".memstead").join("schemas").join(&schema_ref);
1864 let detail: Option<String> = if sealed_dir.join("schema.yaml").is_file() {
1865 memstead_schema::load_schema_from_dir(&sealed_dir)
1866 .err()
1867 .map(|e| e.to_string())
1868 } else if let Some((manifest, types)) = self.read_sealed_package_yamls(&name, &version)
1869 {
1870 memstead_schema::loader::check_package_reauthorable(&manifest, &types)
1871 .err()
1872 .map(|e| e.to_string())
1873 } else {
1874 None };
1876 if let Some(detail) = detail {
1877 out.push(WarningHint::SchemaUnstampedSourceRot {
1878 schema_ref,
1879 mems,
1880 detail,
1881 });
1882 }
1883 }
1884 out
1885 }
1886
1887 fn read_sealed_package_yamls(
1893 &self,
1894 name: &str,
1895 version: &str,
1896 ) -> Option<(String, Vec<(String, String)>)> {
1897 let ops = self.git_branch_ops()?;
1898 let root = self.workspace_root.as_deref()?;
1899 let gitdir = self
1900 .mounts
1901 .iter()
1902 .find_map(|m| match &m.mount.storage {
1903 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
1904 _ => None,
1905 })
1906 .or_else(|| {
1907 let g = root.join("mem-repo").join(".git");
1908 g.is_dir().then_some(g)
1909 })?;
1910 let read = |rel: &str| -> Option<String> {
1911 (ops.read_schema_file)(&gitdir, name, version, rel)
1912 .ok()
1913 .flatten()
1914 .and_then(|bytes| String::from_utf8(bytes).ok())
1915 };
1916 let manifest = read("schema.yaml")?;
1917 let schema = self
1918 .schemas
1919 .values()
1920 .find(|s| {
1921 let (n, v) = s.id();
1922 n == name && v.to_string() == version
1923 })?
1924 .clone();
1925 let mut type_names: Vec<String> = schema.types.keys().cloned().collect();
1926 type_names.sort();
1927 let mut types = Vec::new();
1928 for t in type_names {
1929 if let Some(body) = read(&format!("types/{t}.yaml")) {
1930 types.push((t, body));
1931 }
1932 }
1933 Some((manifest, types))
1934 }
1935
1936 fn read_install_provenance(&self, root: &Path, name: &str, version: &str) -> Option<String> {
1943 let folder_stamp = root
1944 .join(".memstead")
1945 .join("schemas")
1946 .join(format!("{name}@{version}"))
1947 .join(memstead_schema::INSTALL_PROVENANCE_FILE);
1948 let bytes = if folder_stamp.is_file() {
1949 std::fs::read(&folder_stamp).ok()
1950 } else {
1951 let ops = self.git_branch_ops()?;
1952 let gitdir = self
1953 .mounts
1954 .iter()
1955 .find_map(|m| match &m.mount.storage {
1956 crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
1957 Some(gitdir.clone())
1958 }
1959 _ => None,
1960 })
1961 .or_else(|| {
1962 let g = root.join("mem-repo").join(".git");
1963 g.is_dir().then_some(g)
1964 })?;
1965 (ops.read_schema_file)(
1966 &gitdir,
1967 name,
1968 version,
1969 memstead_schema::INSTALL_PROVENANCE_FILE,
1970 )
1971 .ok()
1972 .flatten()
1973 }?;
1974 let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
1975 v.get("authoring_path")?.as_str().map(String::from)
1976 }
1977
1978 pub fn status(&self) -> crate::ops::Status {
1982 let mut types_in_use: Vec<String> = self
1983 .store
1984 .all_entities()
1985 .filter(|e| !e.stub && !e.entity_type.is_empty())
1986 .map(|e| e.entity_type.clone())
1987 .collect();
1988 types_in_use.sort();
1989 types_in_use.dedup();
1990
1991 let mut edge_types: HashMap<String, usize> = HashMap::new();
1992 for id in self.store.all_ids() {
1993 for edge in self.store.outgoing(id) {
1994 *edge_types.entry(edge.rel_type.clone()).or_insert(0) += 1;
1995 }
1996 }
1997
1998 crate::ops::Status {
1999 entity_count: self.store.all_entities().filter(|e| !e.stub).count(),
2000 edge_count: self.store.edge_count(),
2001 edge_types,
2002 community_count: self.communities().count,
2003 mem_count: self.mounts.len(),
2004 types_in_use,
2005 }
2006 }
2007
2008 pub fn context(&self, id: &EntityId) -> Option<ContextResult> {
2012 let entity = self.store.get(id)?;
2013 let community = self
2014 .communities()
2015 .entity_cluster_map
2016 .get(id.as_ref())
2017 .cloned();
2018 let mut neighbors = Vec::new();
2019 for edge in self.store.outgoing(id) {
2020 if let Some(target) = self.store.get(&edge.target) {
2021 neighbors.push(NeighborInfo {
2022 id: target.id.clone(),
2023 title: target.title.clone(),
2024 relationship: edge.rel_type.clone(),
2025 direction: Direction::Outgoing,
2026 });
2027 }
2028 }
2029 for edge in self.store.incoming(id) {
2030 if let Some(source) = self.store.get(&edge.from) {
2031 neighbors.push(NeighborInfo {
2032 id: source.id.clone(),
2033 title: source.title.clone(),
2034 relationship: edge.rel_type.clone(),
2035 direction: Direction::Incoming,
2036 });
2037 }
2038 }
2039 Some(ContextResult {
2040 entity_id: entity.id.clone(),
2041 community,
2042 neighbors,
2043 })
2044 }
2045
2046 #[cfg(not(target_arch = "wasm32"))]
2052 pub fn search_indexes(&self) -> &HashMap<String, MemIndex> {
2053 if let Some((memo_key, _)) = self.search_indexes_memo.get() {
2054 debug_assert_eq!(
2055 *memo_key,
2056 self.derived_key(),
2057 "search memo key lags the engine — a mutation path missed invalidate_search_indexes"
2058 );
2059 }
2060 &self
2061 .search_indexes_memo
2062 .get_or_init(|| (self.derived_key(), build_all(&self.store, &self.schemas)))
2063 .1
2064 }
2065
2066 #[cfg(not(target_arch = "wasm32"))]
2087 pub(crate) fn maintain_search_indexes(&mut self, touched: &[crate::EntityId]) {
2088 let current = super::DerivedKey {
2089 store_generation: self.store.generation(),
2090 schemas_epoch: self.schemas_epoch,
2091 };
2092 let Some((memo_key, indexes)) = self.search_indexes_memo.get_mut() else {
2093 return;
2094 };
2095 if *memo_key == current {
2096 return;
2097 }
2098 if memo_key.schemas_epoch != current.schemas_epoch {
2099 self.search_indexes_memo = OnceCell::new();
2100 return;
2101 }
2102 let mut touched_mems: std::collections::HashSet<&str> = std::collections::HashSet::new();
2103 for id in touched {
2104 let Some(idx) = indexes.get_mut(id.mem()) else {
2105 continue;
2106 };
2107 let result = match self.store.get(id) {
2108 Some(entity) if !entity.stub => idx.index_entity(entity),
2109 _ => idx.remove_entity(id),
2110 };
2111 if let Err(e) = result {
2112 tracing::warn!(
2113 id = id.as_ref(),
2114 error = %e,
2115 "incremental index maintenance failed; dropping the memo for a full rebuild"
2116 );
2117 self.search_indexes_memo = OnceCell::new();
2118 return;
2119 }
2120 touched_mems.insert(id.mem());
2121 }
2122 for (mem, idx) in indexes.iter_mut() {
2123 if !touched_mems.contains(mem.as_str()) {
2124 continue;
2125 }
2126 if let Err(e) = idx.commit() {
2127 tracing::warn!(
2128 mem = mem.as_str(),
2129 error = %e,
2130 "incremental index commit failed; dropping the memo for a full rebuild"
2131 );
2132 self.search_indexes_memo = OnceCell::new();
2133 return;
2134 }
2135 }
2136 *memo_key = current;
2137 }
2138
2139 #[cfg(target_arch = "wasm32")]
2142 pub(crate) fn maintain_search_indexes(&mut self, _touched: &[crate::EntityId]) {}
2143
2144 pub fn drop_search_indexes(&mut self) {
2151 #[cfg(not(target_arch = "wasm32"))]
2152 {
2153 self.search_indexes_memo = OnceCell::new();
2154 }
2155 }
2156
2157 pub fn invalidate_search_indexes(&mut self) {
2158 #[cfg(not(target_arch = "wasm32"))]
2159 {
2160 if let Some((memo_key, _)) = self.search_indexes_memo.get()
2164 && *memo_key == self.derived_key()
2165 {
2166 return;
2167 }
2168 self.search_indexes_memo = OnceCell::new();
2169 }
2170 }
2171
2172 #[cfg(not(target_arch = "wasm32"))]
2174 pub fn list(&self, scope: &SearchScope) -> crate::ops::ListResult {
2175 let fallback = engine_fallback_type();
2176 crate::ops::search::list(&self.store, scope, fallback.as_ref(), &self.schemas)
2177 }
2178
2179 pub fn search(&self, scope: &SearchScope) -> Result<SearchResult, EngineError> {
2185 if let Some(mem) = scope.mem.as_deref()
2191 && (self.quarantine_reason(mem).is_some() || !self.mem_router.is_visible(mem))
2192 {
2193 return Err(self.unknown_mem_error(mem));
2194 }
2195 #[cfg(target_arch = "wasm32")]
2196 {
2197 let _ = scope;
2198 return Err(EngineError::SearchUnavailable);
2199 }
2200 #[cfg(not(target_arch = "wasm32"))]
2201 {
2202 let fallback = engine_fallback_type();
2203 Ok(crate::ops::search::search(
2204 &self.store,
2205 scope,
2206 fallback.as_ref(),
2207 self.search_indexes(),
2208 &self.schemas,
2209 ))
2210 }
2211 }
2212
2213 pub fn list_entities(&self, mem: &str) -> Result<Vec<PathBuf>, EngineError> {
2216 let m = self.find_mount(mem)?;
2217 m.backend.list_entities().map_err(EngineError::Backend)
2218 }
2219
2220 pub fn read_entity(&self, mem: &str, rel_path: &Path) -> Result<Option<Vec<u8>>, EngineError> {
2222 let m = self.find_mount(mem)?;
2223 m.backend
2224 .read_entity(rel_path)
2225 .map_err(EngineError::Backend)
2226 }
2227
2228 pub fn read_provenance(
2232 &self,
2233 mem: &str,
2234 cursor: Option<&str>,
2235 ) -> Result<Vec<Provenance>, EngineError> {
2236 let m = self.find_mount(mem)?;
2237 m.backend
2238 .read_provenance(cursor)
2239 .map_err(EngineError::Backend)
2240 }
2241
2242 pub fn capability(&self, mem: &str) -> Result<crate::workspace::MountCapability, EngineError> {
2247 let m = self.find_mount(mem)?;
2248 Ok(m.mount.capability)
2249 }
2250
2251 pub fn edge_is_from_readonly(&self, from: &EntityId) -> bool {
2266 match self.capability(from.mem()) {
2267 Ok(crate::workspace::MountCapability::ReadOnly) => true,
2268 Ok(crate::workspace::MountCapability::Write) | Err(_) => false,
2269 }
2270 }
2271
2272 pub fn cross_mem_link_allowed(&self, from_mem: &str, to_mem: &str) -> bool {
2306 use memstead_schema::workspace_config::CrossLinkValue;
2307 if from_mem == to_mem {
2308 return true;
2309 }
2310
2311 if let Some(value) = self.settings.cross_mem_links.get(from_mem) {
2313 match value {
2314 CrossLinkValue::Wildcard => return true,
2315 CrossLinkValue::List(targets) => {
2316 if targets.iter().any(|t| t == to_mem) {
2317 return true;
2318 }
2319 }
2323 }
2324 }
2325
2326 let rule_set = self.create_rule_set_memo.get_or_init(|| {
2328 crate::mem_management::CreateRuleSet::new(
2329 self.settings.mem_create_rules.clone(),
2330 )
2331 .unwrap_or_else(|err| {
2332 tracing::warn!(
2333 error = %err,
2334 "cross_mem_link_allowed: failed to compile mem_create_rules — synthesis disabled (resolver falls back to explicit-policy-only)"
2335 );
2336 crate::mem_management::CreateRuleSet::default()
2337 })
2338 });
2339
2340 let candidate = match self.mount(from_mem).and_then(|m| m.mem_path()) {
2349 Some(path) => format!("{path}/{from_mem}"),
2350 None => from_mem.to_string(),
2351 };
2352 if let Some(matched) = rule_set.first_match(std::path::Path::new(&candidate))
2353 && let Some(synth) = matched.default_cross_links.as_ref()
2354 {
2355 return match synth {
2356 CrossLinkValue::Wildcard => true,
2357 CrossLinkValue::List(targets) => targets.iter().any(|t| t == to_mem),
2358 };
2359 }
2360
2361 false
2362 }
2363
2364 pub(super) fn find_mount(&self, mem: &str) -> Result<&MountedBackend, EngineError> {
2365 self.mounts
2366 .iter()
2367 .find(|m| m.mount.mem == mem)
2368 .ok_or_else(|| self.unknown_mem_error(mem))
2369 }
2370}
2371
2372pub(crate) fn anchor_base_path(artifact: &str) -> &str {
2376 let cut = artifact.find(['@', '#']).unwrap_or(artifact.len());
2377 &artifact[..cut]
2378}
2379
2380#[derive(Debug, Clone, Default, serde::Serialize)]
2383pub struct MemAnchorVerification {
2384 pub mem: String,
2385 pub resolved: usize,
2388 pub drifted: usize,
2390 pub recheck: usize,
2393 pub unresolvable: usize,
2395 pub anchors: Vec<VerifiedAnchor>,
2396}
2397
2398#[derive(Debug, Clone, serde::Serialize)]
2400pub struct VerifiedAnchor {
2401 pub entity_id: String,
2402 pub artifact: String,
2403 pub grain: String,
2404 pub class: String,
2405 pub state: String,
2407 #[serde(skip_serializing_if = "Option::is_none")]
2408 pub observed_hash: Option<String>,
2409}
2410
2411#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
2418pub struct ResolvedAnchor {
2419 #[serde(flatten)]
2422 pub anchor: crate::anchor::Anchor,
2423 #[serde(skip_serializing_if = "Option::is_none")]
2430 pub state: Option<crate::anchor::AnchorState>,
2431 #[serde(skip)]
2441 pub observed_hash: Option<String>,
2442}
2443
2444fn observe_path_anchor(
2460 root: &Path,
2461 anchor: &crate::anchor::Anchor,
2462 source_pointer: Option<&str>,
2463) -> Option<(crate::anchor::AnchorState, Option<String>)> {
2464 use crate::anchor::AnchorGrain;
2465 match anchor.grain {
2466 AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => {}
2467 AnchorGrain::Url | AnchorGrain::Entity => return None,
2468 }
2469 let base = anchor_base_path(&anchor.artifact);
2470 let path = source_pointer
2477 .map(|pointer| root.join(join_pointer(pointer, base)))
2478 .filter(|joined| joined.exists())
2479 .unwrap_or_else(|| root.join(base));
2480 if !path.exists() {
2481 return Some((
2482 crate::anchor::resolve_anchor(anchor, &crate::anchor::ArtifactObservation::Absent),
2483 None,
2484 ));
2485 }
2486 let current_hash = if anchor.class.is_hash_bearing()
2487 && matches!(anchor.grain, AnchorGrain::File | AnchorGrain::Span)
2488 && path.is_file()
2489 {
2490 std::fs::read(&path)
2491 .ok()
2492 .map(|bytes| crate::anchor::prepared_content_hash(&bytes))
2493 } else {
2494 None
2495 };
2496 let observation = crate::anchor::ArtifactObservation::Present {
2497 current_hash: current_hash.clone(),
2498 };
2499 Some((
2500 crate::anchor::resolve_anchor(anchor, &observation),
2501 current_hash,
2502 ))
2503}
2504
2505fn schema_parsed_fingerprint(schema: &memstead_schema::Schema) -> String {
2512 let mut keys: Vec<&String> = schema.types.keys().collect();
2513 keys.sort();
2514 let types: Vec<String> = keys
2515 .iter()
2516 .map(|k| format!("{k}={:?}", schema.types[k.as_str()]))
2517 .collect();
2518 format!(
2519 "{:?}|{}|{}",
2520 schema.manifest,
2521 schema.version,
2522 types.join(";")
2523 )
2524}
2525
2526fn anchor_references_path(anchor: &crate::anchor::Anchor, path: &str) -> bool {
2527 let base = anchor_base_path(&anchor.artifact);
2528 path_references(base, anchor.grain == crate::anchor::AnchorGrain::Tree, path)
2529}
2530
2531fn path_references(base: &str, is_tree: bool, path: &str) -> bool {
2534 if base == path {
2535 return true;
2536 }
2537 if is_tree {
2538 let prefix = base.strip_suffix('/').unwrap_or(base);
2539 return path.starts_with(&format!("{prefix}/"));
2540 }
2541 false
2542}
2543
2544pub(crate) fn join_pointer(pointer: &str, base: &str) -> String {
2550 let pointer = pointer.trim_end_matches('/');
2551 if pointer.is_empty() || pointer == "." {
2552 base.to_string()
2553 } else {
2554 format!("{pointer}/{base}")
2555 }
2556}
2557
2558#[cfg(test)]
2559mod tests {
2560 use std::path::Path;
2561
2562 use tempfile::TempDir;
2563
2564 use crate::backend::{BackendError, MemBackend};
2565 use crate::engine::test_helpers::*;
2566 use crate::engine::{Engine, EngineError, RelateEntityArgs};
2567 use crate::entity::EntityId;
2568 use crate::ops::{Direction, SearchScope, WarningHint};
2569 use crate::provenance::Provenance;
2570 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
2571
2572 use crate::vcs::CommitContext;
2573 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
2574
2575 #[test]
2580 fn schema_origin_classifies_builtin_first_party_and_unknown_third_party() {
2581 use std::sync::Arc;
2582
2583 use crate::render::OriginClass;
2584
2585 let tmp = TempDir::new().unwrap();
2586 let engine = Engine::from_mounts(vec![(
2587 folder_mount("specs", tmp.path().to_path_buf()),
2588 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf())) as Box<dyn MemBackend>,
2589 )])
2590 .unwrap();
2591
2592 let builtin = engine.builtin_schemas()[0].clone();
2594 assert_eq!(
2595 engine.schema_origin(&builtin),
2596 OriginClass::FirstParty,
2597 "a built-in schema is first-party"
2598 );
2599
2600 let foreign = Arc::new(memstead_schema::Schema {
2604 manifest: builtin.manifest.clone(),
2605 version: semver::Version::new(99, 0, 0),
2606 types: builtin.types.clone(),
2607 });
2608 assert_eq!(
2609 engine.schema_origin(&foreign),
2610 OriginClass::ThirdParty,
2611 "a schema in neither catalogue classifies third-party (safe default)"
2612 );
2613 }
2614
2615 #[test]
2621 fn mem_origin_class_writable_first_party_readonly_third_party() {
2622 use crate::render::OriginClass;
2623
2624 let tmp = TempDir::new().unwrap();
2625 let writable_dir = tmp.path().join("writable");
2627 std::fs::create_dir_all(&writable_dir).unwrap();
2628 let writer = FilesystemMemWriter::new(writable_dir.clone());
2629
2630 let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
2632 let archive_path = build_archive(tmp.path(), "ext", &[("ext.md", body.as_bytes())]);
2633
2634 let engine = Engine::from_mounts(vec![
2635 (
2636 folder_mount("local", writable_dir),
2637 Box::new(writer) as Box<dyn MemBackend>,
2638 ),
2639 (
2640 archive_mount("external", archive_path.clone()),
2641 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2642 ),
2643 ])
2644 .unwrap();
2645
2646 assert_eq!(
2647 engine.mem_origin_class("local"),
2648 OriginClass::FirstParty,
2649 "a writable mount is first-party"
2650 );
2651 assert_eq!(
2652 engine.mem_origin_class("external"),
2653 OriginClass::ThirdParty,
2654 "a read-only mount is third-party"
2655 );
2656 assert_eq!(
2657 engine.mem_origin_class("no-such-mem"),
2658 OriginClass::ThirdParty,
2659 "an unknown mem is third-party (safe default)"
2660 );
2661 }
2662
2663 #[test]
2668 fn declared_origin_overrides_inference_per_mem() {
2669 use crate::render::OriginClass;
2670
2671 let tmp = TempDir::new().unwrap();
2672 let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
2673 let vouched_path = build_archive(tmp.path(), "vouched", &[("v.md", body.as_bytes())]);
2674 let other_path = build_archive(tmp.path(), "other", &[("o.md", body.as_bytes())]);
2675
2676 let mut engine = Engine::from_mounts(vec![
2677 (
2678 archive_mount("vouched", vouched_path.clone()),
2679 Box::new(ArchiveBackend::new(vouched_path)) as Box<dyn MemBackend>,
2680 ),
2681 (
2682 archive_mount("other", other_path.clone()),
2683 Box::new(ArchiveBackend::new(other_path)) as Box<dyn MemBackend>,
2684 ),
2685 ])
2686 .unwrap();
2687
2688 engine.declare_mem_origin("vouched", OriginClass::FirstParty);
2689
2690 assert_eq!(
2691 engine.mem_origin_class("vouched"),
2692 OriginClass::FirstParty,
2693 "the deployment's declaration wins over the read-only inference"
2694 );
2695 assert_eq!(
2696 engine.mem_origin_class("other"),
2697 OriginClass::ThirdParty,
2698 "an undeclared sibling mount keeps the safe default"
2699 );
2700 }
2701
2702 #[test]
2708 fn schema_origin_third_party_until_pinned_by_a_writable_mount() {
2709 use memstead_schema::SchemaRef;
2710
2711 use crate::render::OriginClass;
2712
2713 let manifest = r#"name: trust-test
2714version: 0.1.0
2715description: adopt-gate test schema
2716when_to_use: tests
2717types:
2718 - doc
2719relationships:
2720 mode: strict
2721 definitions:
2722 - name: _default
2723 description: fallback
2724 default_weight: 1.0
2725community:
2726 resolution: 1.0
2727 seed: 42
2728"#;
2729 let pin = SchemaRef::new("trust-test", semver::Version::new(0, 1, 0));
2730
2731 let mk_engine = |cap: MountCapability| -> Engine {
2732 let tmp = TempDir::new().unwrap();
2733 let schemas_dir = tmp.path().join("schemas");
2734 std::fs::create_dir_all(&schemas_dir).unwrap();
2735 write_schema_files_with_default_type(&schemas_dir, "trust-test", manifest, &["doc"]);
2736 let mem_dir = tmp.path().join("mem");
2737 std::fs::create_dir_all(&mem_dir).unwrap();
2738 let mount = Mount {
2739 mem: "v".to_string(),
2740 schema: Some(pin.clone()),
2741 storage: MountStorage::Folder {
2742 path: mem_dir.clone(),
2743 },
2744 capability: cap,
2745 lifecycle: MountLifecycle::Eager,
2746 cross_linkable: true,
2747 migration_target: None,
2748 };
2749 let backend = Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>;
2750 std::mem::forget(tmp);
2754 Engine::from_mounts_with_schemas_dir(vec![(mount, backend)], Some(&schemas_dir))
2755 .unwrap()
2756 };
2757
2758 let ro = mk_engine(MountCapability::ReadOnly);
2760 let schema = ro.schemas().get("v").expect("schema resolved").clone();
2761 assert_eq!(
2762 ro.schema_origin(&schema),
2763 OriginClass::ThirdParty,
2764 "a non-built-in schema pinned only by a read-only mount is third-party"
2765 );
2766
2767 let rw = mk_engine(MountCapability::Write);
2769 let schema = rw.schemas().get("v").expect("schema resolved").clone();
2770 assert_eq!(
2771 rw.schema_origin(&schema),
2772 OriginClass::FirstParty,
2773 "a writable mount pinning the schema adopts it → first-party"
2774 );
2775 }
2776
2777 #[test]
2784 fn archive_provenance_surfaces_per_entity_and_reports_absence() {
2785 use memstead_schema::History;
2786
2787 let tmp = TempDir::new().unwrap();
2788 let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
2789 let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
2790 let beta = b"---\ntype: spec\n---\n# Beta\n\n## Identity\n\nb\n\n## Purpose\n\np\n";
2791 let provenance = br#"{"format":1,"history":"summarised","entities":{"alpha":{"rationale":"why alpha exists","kind":"create","timestamp":"2026-06-24T00:00:00Z","actor":"agent"}}}"#;
2793 let archive = build_archive(
2794 tmp.path(),
2795 "seed",
2796 &[
2797 (".memstead/config.json", config),
2798 ("alpha.md", alpha),
2799 ("beta.md", beta),
2800 (".memstead/provenance.json", provenance),
2801 ],
2802 );
2803 let engine = Engine::from_mounts(vec![(
2804 archive_mount("seed", archive.clone()),
2805 Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
2806 )])
2807 .unwrap();
2808
2809 let prov = engine
2810 .archive_provenance_for("seed")
2811 .expect("provenance payload read from the archive");
2812 assert_eq!(
2813 prov.history,
2814 History::Summarised,
2815 "history-not-shipped is observable"
2816 );
2817 assert_eq!(
2818 prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
2819 Some("why alpha exists"),
2820 "noted entity surfaces its rationale"
2821 );
2822 assert!(
2823 prov.entity("beta").is_none(),
2824 "unnoted entity is absent (reported absent, not fabricated)"
2825 );
2826 }
2827
2828 #[test]
2832 fn archive_without_provenance_reports_absent() {
2833 let tmp = TempDir::new().unwrap();
2834 let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
2835 let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
2836 let archive = build_archive(
2837 tmp.path(),
2838 "seed",
2839 &[(".memstead/config.json", config), ("alpha.md", alpha)],
2840 );
2841 let engine = Engine::from_mounts(vec![(
2842 archive_mount("seed", archive.clone()),
2843 Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
2844 )])
2845 .unwrap();
2846 assert!(
2847 engine.archive_provenance_for("seed").is_none(),
2848 "an archive without a provenance payload reports provenance absent"
2849 );
2850 }
2851
2852 #[test]
2853 fn folder_mount_routes_reads_to_filesystem_backend() {
2854 let tmp = TempDir::new().unwrap();
2855 let mem_dir = tmp.path().to_path_buf();
2856 let writer = FilesystemMemWriter::new(mem_dir.clone());
2857 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"alpha")
2861 .unwrap();
2862 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
2863 .unwrap();
2864
2865 let engine = Engine::from_mounts(vec![(
2866 folder_mount("specs", mem_dir),
2867 Box::new(writer) as Box<dyn MemBackend>,
2868 )])
2869 .unwrap();
2870
2871 let mut paths: Vec<String> = engine
2872 .list_entities("specs")
2873 .unwrap()
2874 .into_iter()
2875 .map(|p| p.to_string_lossy().into_owned())
2876 .collect();
2877 paths.sort();
2878 assert_eq!(paths, vec!["a.md".to_string()]);
2879
2880 assert_eq!(
2881 engine.read_entity("specs", Path::new("a.md")).unwrap(),
2882 Some(b"alpha".to_vec())
2883 );
2884 }
2885
2886 #[test]
2887 fn heterogeneous_mounts_route_to_correct_backend() {
2888 let tmp = TempDir::new().unwrap();
2889
2890 let folder_dir = tmp.path().join("folder-mem");
2892 std::fs::create_dir_all(&folder_dir).unwrap();
2893 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
2894 <FilesystemMemWriter as MemWriter>::write_entity(
2895 &folder_writer,
2896 Path::new("local.md"),
2897 b"local",
2898 )
2899 .unwrap();
2900 <FilesystemMemWriter as MemWriter>::commit(
2901 &folder_writer,
2902 "seed",
2903 &CommitContext::internal(),
2904 )
2905 .unwrap();
2906
2907 let archive_path = build_archive(
2909 tmp.path(),
2910 "external",
2911 &[("ext.md", b"external"), ("dir/nested.md", b"nested")],
2912 );
2913
2914 let engine = Engine::from_mounts(vec![
2915 (
2916 folder_mount("local", folder_dir),
2917 Box::new(folder_writer) as Box<dyn MemBackend>,
2918 ),
2919 (
2920 archive_mount("external", archive_path.clone()),
2921 Box::new(ArchiveBackend::new(archive_path)),
2922 ),
2923 ])
2924 .unwrap();
2925
2926 assert_eq!(engine.mem_names(), vec!["local", "external"]);
2928 assert_eq!(
2929 engine.read_entity("local", Path::new("local.md")).unwrap(),
2930 Some(b"local".to_vec())
2931 );
2932 assert_eq!(
2933 engine.read_entity("external", Path::new("ext.md")).unwrap(),
2934 Some(b"external".to_vec())
2935 );
2936 assert_eq!(
2937 engine
2938 .read_entity("external", Path::new("dir/nested.md"))
2939 .unwrap(),
2940 Some(b"nested".to_vec())
2941 );
2942 assert_eq!(
2945 engine.read_entity("local", Path::new("ext.md")).unwrap(),
2946 None
2947 );
2948 assert_eq!(
2949 engine
2950 .read_entity("external", Path::new("local.md"))
2951 .unwrap(),
2952 None
2953 );
2954 }
2955
2956 #[test]
2957 fn edge_is_from_readonly_classifies_every_edge_by_source_mount_capability() {
2958 let tmp = TempDir::new().unwrap();
2964
2965 let folder_dir = tmp.path().join("local-mem");
2969 std::fs::create_dir_all(&folder_dir).unwrap();
2970 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
2971 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";
2972 <FilesystemMemWriter as MemWriter>::write_entity(
2973 &folder_writer,
2974 Path::new("note.md"),
2975 local_md,
2976 )
2977 .unwrap();
2978 <FilesystemMemWriter as MemWriter>::commit(
2979 &folder_writer,
2980 "seed",
2981 &CommitContext::internal(),
2982 )
2983 .unwrap();
2984
2985 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";
2989 let archive_path = build_archive(tmp.path(), "external", &[("archived.md", archive_md)]);
2990
2991 let engine = Engine::from_mounts(vec![
2992 (
2993 folder_mount("local", folder_dir),
2994 Box::new(folder_writer) as Box<dyn MemBackend>,
2995 ),
2996 (
2997 archive_mount("external", archive_path.clone()),
2998 Box::new(ArchiveBackend::new(archive_path)),
2999 ),
3000 ])
3001 .unwrap();
3002
3003 let local_id = EntityId::new("local", "note");
3005 let archived_id = EntityId::new("external", "archived");
3006 assert!(engine.get_entity(&local_id).is_some());
3007 assert!(engine.get_entity(&archived_id).is_some());
3008 assert!(matches!(
3009 engine.capability("local").unwrap(),
3010 MountCapability::Write
3011 ));
3012 assert!(matches!(
3013 engine.capability("external").unwrap(),
3014 MountCapability::ReadOnly
3015 ));
3016
3017 let mut seen_write_edge = false;
3023 let mut seen_readonly_edge = false;
3024 for from in engine.store().all_ids().cloned().collect::<Vec<_>>() {
3025 for _edge in engine.store().outgoing(&from) {
3026 let is_ro = engine.edge_is_from_readonly(&from);
3027 match engine.capability(from.mem()).unwrap() {
3028 MountCapability::Write => {
3029 assert!(
3030 !is_ro,
3031 "edge from write mem {} reported as ReadOnly",
3032 from.mem()
3033 );
3034 seen_write_edge = true;
3035 }
3036 MountCapability::ReadOnly => {
3037 assert!(
3038 is_ro,
3039 "edge from readonly mem {} reported as Write",
3040 from.mem()
3041 );
3042 seen_readonly_edge = true;
3043 }
3044 }
3045 }
3046 }
3047 assert!(
3048 seen_write_edge,
3049 "fixture must produce at least one edge from a write mem"
3050 );
3051 assert!(
3052 seen_readonly_edge,
3053 "fixture must produce at least one edge from a readonly mem"
3054 );
3055
3056 let phantom = EntityId::new("missing-mem", "phantom");
3059 assert!(
3060 !engine.edge_is_from_readonly(&phantom),
3061 "absent mount must not be reported as ReadOnly"
3062 );
3063 }
3064
3065 #[test]
3068 fn cross_mem_link_allowed_same_mem_always_true() {
3069 let tmp = TempDir::new().unwrap();
3072 let engine = build_demo_engine(&tmp);
3073 assert!(engine.cross_mem_link_allowed("specs", "specs"));
3074 assert!(engine.cross_mem_link_allowed("anywhere", "anywhere"));
3079 }
3080
3081 #[test]
3082 fn cross_mem_link_allowed_absent_denies_by_default() {
3083 let tmp = TempDir::new().unwrap();
3086 let engine = build_demo_engine(&tmp);
3087 assert!(!engine.cross_mem_link_allowed("specs", "engine"));
3088 assert!(!engine.cross_mem_link_allowed("missing", "anywhere"));
3089 }
3090
3091 #[test]
3092 fn cross_mem_link_allowed_wildcard_admits_any_target() {
3093 use memstead_schema::workspace_config::CrossLinkValue;
3094 let tmp = TempDir::new().unwrap();
3095 let mut engine = build_demo_engine(&tmp);
3096 let mut settings = crate::workspace::WorkspaceSettings::default();
3097 settings
3098 .cross_mem_links
3099 .insert("specs".to_string(), CrossLinkValue::Wildcard);
3100 engine.set_settings(settings);
3101 assert!(engine.cross_mem_link_allowed("specs", "engine"));
3102 assert!(engine.cross_mem_link_allowed("specs", "macos"));
3103 assert!(engine.cross_mem_link_allowed("specs", "any-other"));
3104 assert!(!engine.cross_mem_link_allowed("engine", "specs"));
3107 }
3108
3109 #[test]
3110 fn cross_mem_link_allowed_allowlist_enforces_membership() {
3111 use memstead_schema::workspace_config::CrossLinkValue;
3112 let tmp = TempDir::new().unwrap();
3113 let mut engine = build_demo_engine(&tmp);
3114 let mut settings = crate::workspace::WorkspaceSettings::default();
3115 settings.cross_mem_links.insert(
3116 "specs".to_string(),
3117 CrossLinkValue::List(vec!["engine".to_string(), "macos".to_string()]),
3118 );
3119 engine.set_settings(settings);
3120 assert!(engine.cross_mem_link_allowed("specs", "engine"));
3121 assert!(engine.cross_mem_link_allowed("specs", "macos"));
3122 assert!(!engine.cross_mem_link_allowed("specs", "external"));
3123 }
3124
3125 #[test]
3126 fn cross_mem_link_allowed_synthesises_from_matching_create_rule_wildcard() {
3127 use memstead_schema::workspace_config::CrossLinkValue;
3131 let tmp = TempDir::new().unwrap();
3132 let mut engine = build_demo_engine(&tmp);
3133 let mut settings = crate::workspace::WorkspaceSettings::default();
3134 settings
3135 .mem_create_rules
3136 .push(crate::workspace::CreateRuleSetting {
3137 pattern: "exec-*".to_string(),
3138 schemas: vec!["default".to_string()],
3139 default_cross_links: Some(CrossLinkValue::Wildcard),
3140 });
3141 engine.set_settings(settings);
3142 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
3145 assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
3146 assert!(!engine.cross_mem_link_allowed("orphan", "specs"));
3148 }
3149
3150 #[test]
3157 fn cross_mem_link_allowed_synthesises_for_hierarchical_mem() {
3158 use memstead_schema::workspace_config::CrossLinkValue;
3159 let tmp = TempDir::new().unwrap();
3160 let mem_dir = tmp.path().to_path_buf();
3161 let mount = Mount {
3166 mem: "project".into(),
3167 schema: Some(pin("default")),
3168 storage: MountStorage::GitBranch {
3169 gitdir: mem_dir.join(".git"),
3170 branch: "memstead/project".into(),
3171 },
3172 capability: MountCapability::Write,
3173 lifecycle: MountLifecycle::Eager,
3174 cross_linkable: true,
3175 migration_target: None,
3176 };
3177 let writer = FilesystemMemWriter::new(mem_dir.clone());
3178 let mut engine =
3179 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3180 let mut settings = crate::workspace::WorkspaceSettings::default();
3181 settings
3182 .mem_create_rules
3183 .push(crate::workspace::CreateRuleSetting {
3184 pattern: "memstead/*".to_string(),
3185 schemas: vec!["default".to_string()],
3186 default_cross_links: Some(CrossLinkValue::List(vec!["engine".to_string()])),
3187 });
3188 engine.set_settings(settings);
3189 assert!(
3190 engine.cross_mem_link_allowed("project", "engine"),
3191 "synthesis must match via the composed `memstead/project` candidate"
3192 );
3193 assert!(
3194 !engine.cross_mem_link_allowed("project", "macos"),
3195 "a target outside the rule's default_cross_links is still denied"
3196 );
3197 }
3198
3199 #[test]
3200 fn cross_mem_link_allowed_synthesises_from_matching_create_rule_list() {
3201 use memstead_schema::workspace_config::CrossLinkValue;
3204 let tmp = TempDir::new().unwrap();
3205 let mut engine = build_demo_engine(&tmp);
3206 let mut settings = crate::workspace::WorkspaceSettings::default();
3207 settings
3208 .mem_create_rules
3209 .push(crate::workspace::CreateRuleSetting {
3210 pattern: "exec-*".to_string(),
3211 schemas: vec!["default".to_string()],
3212 default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
3213 });
3214 engine.set_settings(settings);
3215 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
3216 assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
3218 }
3219
3220 #[test]
3221 fn cross_mem_link_allowed_explicit_policy_wins_over_synthesis() {
3222 use memstead_schema::workspace_config::CrossLinkValue;
3225 let tmp = TempDir::new().unwrap();
3226 let mut engine = build_demo_engine(&tmp);
3227 let mut settings = crate::workspace::WorkspaceSettings::default();
3228 settings
3229 .cross_mem_links
3230 .insert("exec-foo".to_string(), CrossLinkValue::Wildcard);
3231 engine.set_settings(settings);
3234 assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
3235 }
3236
3237 #[test]
3238 fn cross_mem_link_allowed_synthesis_unions_into_explicit_list() {
3239 use memstead_schema::workspace_config::CrossLinkValue;
3242 let tmp = TempDir::new().unwrap();
3243 let mut engine = build_demo_engine(&tmp);
3244 let mut settings = crate::workspace::WorkspaceSettings::default();
3245 settings.cross_mem_links.insert(
3246 "exec-foo".to_string(),
3247 CrossLinkValue::List(vec!["specs".to_string()]),
3248 );
3249 settings
3250 .mem_create_rules
3251 .push(crate::workspace::CreateRuleSetting {
3252 pattern: "exec-*".to_string(),
3253 schemas: vec!["default".to_string()],
3254 default_cross_links: Some(CrossLinkValue::List(vec!["macos".to_string()])),
3255 });
3256 engine.set_settings(settings);
3257 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
3259 assert!(engine.cross_mem_link_allowed("exec-foo", "macos"));
3261 assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
3263 }
3264
3265 #[test]
3266 fn cross_mem_link_allowed_set_settings_invalidates_compiled_rule_cache() {
3267 use memstead_schema::workspace_config::CrossLinkValue;
3270 let tmp = TempDir::new().unwrap();
3271 let mut engine = build_demo_engine(&tmp);
3272
3273 let mut s1 = crate::workspace::WorkspaceSettings::default();
3275 s1.mem_create_rules
3276 .push(crate::workspace::CreateRuleSetting {
3277 pattern: "exec-*".to_string(),
3278 schemas: vec!["default".to_string()],
3279 default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
3280 });
3281 engine.set_settings(s1);
3282 assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
3283
3284 let mut s2 = crate::workspace::WorkspaceSettings::default();
3288 s2.mem_create_rules
3289 .push(crate::workspace::CreateRuleSetting {
3290 pattern: "exec-*".to_string(),
3291 schemas: vec!["default".to_string()],
3292 default_cross_links: None,
3293 });
3294 engine.set_settings(s2);
3295 assert!(!engine.cross_mem_link_allowed("exec-foo", "specs"));
3296 }
3297
3298 #[test]
3299 fn cross_mem_link_allowed_malformed_glob_falls_back_to_explicit_policy() {
3300 use memstead_schema::workspace_config::CrossLinkValue;
3304 let tmp = TempDir::new().unwrap();
3305 let mut engine = build_demo_engine(&tmp);
3306 let mut settings = crate::workspace::WorkspaceSettings::default();
3307 settings
3308 .mem_create_rules
3309 .push(crate::workspace::CreateRuleSetting {
3310 pattern: "[unclosed".to_string(),
3311 schemas: vec!["default".to_string()],
3312 default_cross_links: Some(CrossLinkValue::Wildcard),
3313 });
3314 settings
3316 .cross_mem_links
3317 .insert("specs".to_string(), CrossLinkValue::Wildcard);
3318 engine.set_settings(settings);
3319 assert!(engine.cross_mem_link_allowed("specs", "engine"));
3321 assert!(!engine.cross_mem_link_allowed("orphan", "anything"));
3324 }
3325
3326 #[test]
3327 fn cross_mem_link_allowed_empty_list_denies_all_cross_mem_targets() {
3328 use memstead_schema::workspace_config::CrossLinkValue;
3332 let tmp = TempDir::new().unwrap();
3333 let mut engine = build_demo_engine(&tmp);
3334 let mut settings = crate::workspace::WorkspaceSettings::default();
3335 settings
3336 .cross_mem_links
3337 .insert("specs".to_string(), CrossLinkValue::List(Vec::new()));
3338 engine.set_settings(settings);
3339 assert!(engine.cross_mem_link_allowed("specs", "specs"));
3341 assert!(!engine.cross_mem_link_allowed("specs", "engine"));
3343 assert!(!engine.cross_mem_link_allowed("specs", "anything"));
3344 }
3345
3346 #[test]
3347 fn from_mounts_load_warnings_merge_into_health_summary() {
3348 let tmp = TempDir::new().unwrap();
3349 let mem_dir = tmp.path().to_path_buf();
3350 let body = "---\ntype: spec\n---\n# Dup2\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3351 std::fs::write(mem_dir.join("dup2.md"), body).unwrap();
3352
3353 let writer = FilesystemMemWriter::new(mem_dir.clone());
3354 let engine = Engine::from_mounts(vec![(
3355 folder_mount("specs", mem_dir),
3356 Box::new(writer) as Box<dyn MemBackend>,
3357 )])
3358 .unwrap();
3359
3360 let summary = engine.health();
3361 assert!(
3362 summary
3363 .warnings
3364 .iter()
3365 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3366 "health() must merge load_warnings into summary.warnings: {:?}",
3367 summary.warnings,
3368 );
3369 }
3370
3371 #[test]
3372 fn workspace_root_accessor_is_none_for_engine_built_from_mounts() {
3373 let tmp = TempDir::new().unwrap();
3374 let mem_dir = tmp.path().to_path_buf();
3375 let writer = FilesystemMemWriter::new(mem_dir.clone());
3376 let mut mount = folder_mount("specs", mem_dir);
3379 mount.schema = Some("default@1.3.0".parse().unwrap());
3380 let engine =
3381 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3382 assert!(
3383 engine.workspace_root().is_none(),
3384 "from_mounts has no workspace path",
3385 );
3386 assert!(engine.load_warnings().is_empty());
3387 }
3388
3389 #[test]
3390 fn health_omits_outer_repo_warning_when_workspace_root_unset() {
3391 let tmp = TempDir::new().unwrap();
3392 let mem_dir = tmp.path().to_path_buf();
3393 let writer = FilesystemMemWriter::new(mem_dir.clone());
3394 let engine = Engine::from_mounts(vec![(
3395 folder_mount("specs", mem_dir),
3396 Box::new(writer) as Box<dyn MemBackend>,
3397 )])
3398 .unwrap();
3399 let health = engine.health();
3400 assert!(
3401 !health
3402 .warnings
3403 .iter()
3404 .any(|w| matches!(w, WarningHint::OuterRepoNotIgnoringMemRepo { .. })),
3405 "outer-repo check must skip when workspace_root is None",
3406 );
3407 }
3408
3409 #[test]
3410 fn writable_mem_names_filters_by_capability() {
3411 let tmp = TempDir::new().unwrap();
3412 let mem_dir = tmp.path().to_path_buf();
3413 let writer = FilesystemMemWriter::new(mem_dir.clone());
3414 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3415
3416 let engine = Engine::from_mounts(vec![
3417 (
3418 folder_mount("writable", mem_dir),
3419 Box::new(writer) as Box<dyn MemBackend>,
3420 ),
3421 (
3422 archive_mount("sealed", archive_path.clone()),
3423 Box::new(ArchiveBackend::new(archive_path)),
3424 ),
3425 ])
3426 .unwrap();
3427
3428 let names = engine.writable_mem_names();
3431 assert_eq!(names, vec!["writable"]);
3432 }
3433
3434 #[test]
3441 fn default_writable_mem_is_declaration_first_not_alphabetical() {
3442 let tmp = TempDir::new().unwrap();
3443 let test_dir = tmp.path().join("test");
3444 let other_dir = tmp.path().join("other");
3445 std::fs::create_dir_all(&test_dir).unwrap();
3446 std::fs::create_dir_all(&other_dir).unwrap();
3447
3448 let engine = Engine::from_mounts(vec![
3449 (
3450 folder_mount("test", test_dir.clone()),
3451 Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
3452 ),
3453 (
3454 folder_mount("other", other_dir.clone()),
3455 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
3456 ),
3457 ])
3458 .unwrap();
3459
3460 assert_eq!(
3461 engine.default_writable_mem(),
3462 Some("test"),
3463 "default must be the declaration-first writable mem, not the alphabetically-first",
3464 );
3465 }
3466
3467 #[test]
3472 fn default_writable_mem_follows_declaration_order() {
3473 let tmp = TempDir::new().unwrap();
3474 let other_dir = tmp.path().join("other");
3475 let test_dir = tmp.path().join("test");
3476 std::fs::create_dir_all(&other_dir).unwrap();
3477 std::fs::create_dir_all(&test_dir).unwrap();
3478
3479 let engine = Engine::from_mounts(vec![
3480 (
3481 folder_mount("other", other_dir.clone()),
3482 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
3483 ),
3484 (
3485 folder_mount("test", test_dir.clone()),
3486 Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
3487 ),
3488 ])
3489 .unwrap();
3490
3491 assert_eq!(engine.default_writable_mem(), Some("other"));
3492 }
3493
3494 #[test]
3496 fn default_writable_mem_none_without_writable_mount() {
3497 let tmp = TempDir::new().unwrap();
3498 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3499 let engine = Engine::from_mounts(vec![(
3500 archive_mount("sealed", archive_path.clone()),
3501 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3502 )])
3503 .unwrap();
3504 assert_eq!(engine.default_writable_mem(), None);
3505 }
3506
3507 #[test]
3508 fn folder_path_for_mem_returns_path_for_folder_mounts_only() {
3509 let tmp = TempDir::new().unwrap();
3510 let mem_dir = tmp.path().join("specs");
3511 std::fs::create_dir_all(&mem_dir).unwrap();
3512 let writer = FilesystemMemWriter::new(mem_dir.clone());
3513 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3514
3515 let engine = Engine::from_mounts(vec![
3516 (
3517 folder_mount("specs", mem_dir.clone()),
3518 Box::new(writer) as Box<dyn MemBackend>,
3519 ),
3520 (
3521 archive_mount("sealed", archive_path.clone()),
3522 Box::new(ArchiveBackend::new(archive_path)),
3523 ),
3524 ])
3525 .unwrap();
3526
3527 assert_eq!(engine.folder_path_for_mem("specs"), Some(mem_dir.as_path()),);
3529 assert_eq!(engine.folder_path_for_mem("sealed"), None);
3531 assert_eq!(engine.folder_path_for_mem("missing"), None);
3533 }
3534
3535 #[test]
3536 fn mount_accessor_returns_public_mount_shape() {
3537 let tmp = TempDir::new().unwrap();
3544 let mem_dir = tmp.path().to_path_buf();
3545 let writer = FilesystemMemWriter::new(mem_dir.clone());
3546 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3547
3548 let engine = Engine::from_mounts(vec![
3549 (
3550 folder_mount("writable", mem_dir.clone()),
3551 Box::new(writer) as Box<dyn MemBackend>,
3552 ),
3553 (
3554 archive_mount("sealed", archive_path.clone()),
3555 Box::new(ArchiveBackend::new(archive_path.clone())),
3556 ),
3557 ])
3558 .unwrap();
3559
3560 let folder = engine.mount("writable").expect("known mem");
3563 assert!(matches!(folder.storage, MountStorage::Folder { .. }));
3564 assert_eq!(folder.capability, MountCapability::Write);
3565
3566 let archive = engine.mount("sealed").expect("known mem");
3567 match &archive.storage {
3568 MountStorage::Archive { path } => assert_eq!(path, &archive_path),
3569 other => panic!("expected Archive storage, got {other:?}"),
3570 }
3571 assert_eq!(archive.capability, MountCapability::ReadOnly);
3572
3573 assert!(engine.mount("missing").is_none());
3575
3576 let mounts = engine.mounts();
3578 assert_eq!(mounts.len(), 2);
3579 assert_eq!(mounts[0].mem, "writable");
3580 assert_eq!(mounts[1].mem, "sealed");
3581 }
3582
3583 #[test]
3584 fn mem_router_writable_set_matches_writable_mount_capability() {
3585 let tmp = TempDir::new().unwrap();
3589 let mem_dir = tmp.path().join("specs");
3590 std::fs::create_dir_all(&mem_dir).unwrap();
3591 let writer = FilesystemMemWriter::new(mem_dir.clone());
3592 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3593
3594 let engine = Engine::from_mounts(vec![
3595 (
3596 folder_mount("specs", mem_dir.clone()),
3597 Box::new(writer) as Box<dyn MemBackend>,
3598 ),
3599 (
3600 archive_mount("ext", archive_path.clone()),
3601 Box::new(ArchiveBackend::new(archive_path)),
3602 ),
3603 ])
3604 .unwrap();
3605
3606 let router = engine.mem_router();
3607 assert!(router.is_writable("specs"));
3608 assert!(!router.is_writable("ext"));
3609 assert!(router.is_visible("specs"));
3610 assert!(router.is_visible("ext"));
3611 let writable: std::collections::HashSet<&String> = router.writable_mems().iter().collect();
3612 assert_eq!(writable.len(), 1);
3613 assert!(writable.contains(&"specs".to_string()));
3614 }
3615
3616 #[test]
3617 fn mem_router_origin_is_explicit_toml_for_workspace_mounts() {
3618 let tmp = TempDir::new().unwrap();
3624 let mem_dir = tmp.path().join("specs");
3625 std::fs::create_dir_all(&mem_dir).unwrap();
3626 let writer = FilesystemMemWriter::new(mem_dir.clone());
3627
3628 let engine = Engine::from_mounts(vec![(
3629 folder_mount("specs", mem_dir),
3630 Box::new(writer) as Box<dyn MemBackend>,
3631 )])
3632 .unwrap();
3633
3634 let origin = engine
3635 .mem_router()
3636 .origin_for_mem("specs")
3637 .expect("known mem");
3638 assert_eq!(origin.kind(), "explicit");
3639 }
3640
3641 #[test]
3642 fn mem_router_dir_for_writable_folder_mount_matches_storage_path() {
3643 let tmp = TempDir::new().unwrap();
3647 let mem_dir = tmp.path().join("specs");
3648 std::fs::create_dir_all(&mem_dir).unwrap();
3649 let writer = FilesystemMemWriter::new(mem_dir.clone());
3650
3651 let engine = Engine::from_mounts(vec![(
3652 folder_mount("specs", mem_dir.clone()),
3653 Box::new(writer) as Box<dyn MemBackend>,
3654 )])
3655 .unwrap();
3656
3657 assert_eq!(
3658 engine.mem_router().dir_for_mem("specs"),
3659 Some(mem_dir.as_path()),
3660 );
3661 assert_eq!(engine.mem_router().dir_for_mem("unknown"), None);
3662 }
3663
3664 #[test]
3665 fn mem_router_archive_path_for_read_only_archive_mount() {
3666 let tmp = TempDir::new().unwrap();
3670 let mem_dir = tmp.path().join("specs");
3671 std::fs::create_dir_all(&mem_dir).unwrap();
3672 let writer = FilesystemMemWriter::new(mem_dir.clone());
3673 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3674
3675 let engine = Engine::from_mounts(vec![
3676 (
3677 folder_mount("specs", mem_dir),
3678 Box::new(writer) as Box<dyn MemBackend>,
3679 ),
3680 (
3681 archive_mount("ext", archive_path.clone()),
3682 Box::new(ArchiveBackend::new(archive_path.clone())),
3683 ),
3684 ])
3685 .unwrap();
3686
3687 let router = engine.mem_router();
3688 assert_eq!(
3689 router.archive_path_for_mem("ext"),
3690 Some(archive_path.as_path()),
3691 );
3692 assert_eq!(router.archive_path_for_mem("specs"), None);
3694 }
3695
3696 #[test]
3697 fn read_mem_config_via_backend_trait_folder_reads_bytes() {
3698 let tmp = TempDir::new().unwrap();
3702 let mem_dir = tmp.path().to_path_buf();
3703 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3704 let body = br#"{
3705 "format": 1,
3706 "schema": "default@1.0.0",
3707 "writeGuidance": { "tone": "neutral" }
3708 }"#;
3709 std::fs::write(mem_dir.join(".memstead").join("config.json"), body).unwrap();
3710
3711 let writer = FilesystemMemWriter::new(mem_dir);
3712 let result = MemBackend::read_mem_config(&writer).unwrap();
3713 let bytes = result.expect("config bytes must surface");
3714 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3715 assert_eq!(parsed["schema"], "default@1.0.0");
3716 }
3717
3718 #[test]
3719 fn read_mem_config_via_backend_trait_folder_missing_returns_none() {
3720 let tmp = TempDir::new().unwrap();
3721 let mem_dir = tmp.path().to_path_buf();
3722 let writer = FilesystemMemWriter::new(mem_dir);
3723 let result = MemBackend::read_mem_config(&writer).unwrap();
3724 assert!(result.is_none());
3725 }
3726
3727 #[test]
3728 fn read_mem_config_via_backend_trait_archive_reads_bytes() {
3729 let tmp = TempDir::new().unwrap();
3732 let archive_path = tmp.path().join("seed.mem");
3733 let body = br#"{
3734 "format": 1,
3735 "schema": "default@1.0.0",
3736 "writeGuidance": { "tone": "archive" }
3737 }"#;
3738 {
3739 let file = std::fs::File::create(&archive_path).unwrap();
3740 let mut writer = zip::ZipWriter::new(file);
3741 writer
3742 .start_file(
3743 ".memstead/config.json",
3744 zip::write::SimpleFileOptions::default(),
3745 )
3746 .unwrap();
3747 use std::io::Write;
3748 writer.write_all(body).unwrap();
3749 writer.finish().unwrap();
3750 }
3751
3752 let backend = ArchiveBackend::new(archive_path);
3753 let result = MemBackend::read_mem_config(&backend).unwrap();
3754 let bytes = result.expect("config bytes must surface");
3755 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3756 assert_eq!(parsed["writeGuidance"]["tone"], "archive");
3757 }
3758
3759 #[test]
3760 fn mem_config_for_returns_none_when_no_config_file_present() {
3761 let tmp = TempDir::new().unwrap();
3764 let mem_dir = tmp.path().to_path_buf();
3765 let writer = FilesystemMemWriter::new(mem_dir.clone());
3766 let engine = Engine::from_mounts(vec![(
3767 folder_mount("specs", mem_dir),
3768 Box::new(writer) as Box<dyn MemBackend>,
3769 )])
3770 .unwrap();
3771 assert!(engine.mem_config_for("specs").is_none());
3772 }
3773
3774 #[test]
3775 fn mem_config_for_returns_some_when_config_file_present() {
3776 let tmp = TempDir::new().unwrap();
3781 let mem_dir = tmp.path().to_path_buf();
3782 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3783 let config_body = r#"{
3784 "format": 1,
3785 "schema": "default@1.0.0",
3786 "writeGuidance": {
3787 "tone": "neutral",
3788 "voice": "active"
3789 }
3790 }"#;
3791 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3792
3793 let writer = FilesystemMemWriter::new(mem_dir.clone());
3794 let engine = Engine::from_mounts(vec![(
3795 folder_mount("specs", mem_dir),
3796 Box::new(writer) as Box<dyn MemBackend>,
3797 )])
3798 .unwrap();
3799
3800 let cfg = engine
3801 .mem_config_for("specs")
3802 .expect("mem_config should load");
3803 assert_eq!(cfg.write_guidance.len(), 2);
3804 assert_eq!(
3805 cfg.write_guidance.get("tone").and_then(|v| v.as_str()),
3806 Some("neutral"),
3807 );
3808 assert_eq!(
3809 cfg.write_guidance.get("voice").and_then(|v| v.as_str()),
3810 Some("active"),
3811 );
3812 }
3813
3814 #[test]
3815 fn mem_config_for_unknown_mem_returns_none() {
3816 let tmp = TempDir::new().unwrap();
3818 let mem_dir = tmp.path().to_path_buf();
3819 let writer = FilesystemMemWriter::new(mem_dir.clone());
3820 let engine = Engine::from_mounts(vec![(
3821 folder_mount("specs", mem_dir),
3822 Box::new(writer) as Box<dyn MemBackend>,
3823 )])
3824 .unwrap();
3825 assert!(engine.mem_config_for("missing").is_none());
3826 }
3827
3828 #[test]
3829 fn mem_config_for_archive_mount_returns_none() {
3830 let tmp = TempDir::new().unwrap();
3833 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3834 let engine = Engine::from_mounts(vec![(
3835 archive_mount("ext", archive_path.clone()),
3836 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3837 )])
3838 .unwrap();
3839 assert!(engine.mem_config_for("ext").is_none());
3840 }
3841
3842 #[test]
3843 fn mem_configs_named_iterates_only_mounts_with_config() {
3844 let tmp = TempDir::new().unwrap();
3849 let with_config = tmp.path().join("specs");
3850 let without_config = tmp.path().join("memos");
3851 std::fs::create_dir_all(with_config.join(".memstead")).unwrap();
3852 std::fs::create_dir_all(&without_config).unwrap();
3853 let config_body = r#"{
3854 "format": 1,
3855 "schema": "default@1.0.0",
3856 "writeGuidance": { "tone": "neutral" }
3857 }"#;
3858 std::fs::write(
3859 with_config.join(".memstead").join("config.json"),
3860 config_body,
3861 )
3862 .unwrap();
3863
3864 let engine = Engine::from_mounts(vec![
3865 (
3866 folder_mount("specs", with_config.clone()),
3867 Box::new(FilesystemMemWriter::new(with_config)) as Box<dyn MemBackend>,
3868 ),
3869 (
3870 folder_mount("memos", without_config.clone()),
3871 Box::new(FilesystemMemWriter::new(without_config)) as Box<dyn MemBackend>,
3872 ),
3873 ])
3874 .unwrap();
3875
3876 let yielded: Vec<(&str, usize)> = engine
3877 .mem_configs_named()
3878 .map(|(name, cfg)| (name, cfg.write_guidance.len()))
3879 .collect();
3880 assert_eq!(yielded, vec![("specs", 1)]);
3881 }
3882
3883 #[test]
3884 fn schema_for_returns_some_for_known_mem_and_none_for_unknown() {
3885 let tmp = TempDir::new().unwrap();
3889 let mem_dir = tmp.path().to_path_buf();
3890 let writer = FilesystemMemWriter::new(mem_dir.clone());
3891 let engine = Engine::from_mounts(vec![(
3892 folder_mount("specs", mem_dir),
3893 Box::new(writer) as Box<dyn MemBackend>,
3894 )])
3895 .unwrap();
3896 assert!(engine.schema_for("specs").is_some());
3897 assert!(engine.schema_for("missing").is_none());
3898 }
3899
3900 #[test]
3901 fn gitdir_for_unknown_mem_returns_unknown_mem() {
3902 let tmp = TempDir::new().unwrap();
3903 let mem_dir = tmp.path().to_path_buf();
3904 let writer = FilesystemMemWriter::new(mem_dir.clone());
3905 let engine = Engine::from_mounts(vec![(
3906 folder_mount("specs", mem_dir),
3907 Box::new(writer) as Box<dyn MemBackend>,
3908 )])
3909 .unwrap();
3910 let err = engine.gitdir_for("missing").unwrap_err();
3911 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3912 }
3913
3914 #[test]
3915 fn gitdir_for_folder_mount_returns_no_gitdir_error() {
3916 let tmp = TempDir::new().unwrap();
3919 let mem_dir = tmp.path().to_path_buf();
3920 let writer = FilesystemMemWriter::new(mem_dir.clone());
3921 let engine = Engine::from_mounts(vec![(
3922 folder_mount("specs", mem_dir),
3923 Box::new(writer) as Box<dyn MemBackend>,
3924 )])
3925 .unwrap();
3926 let err = engine.gitdir_for("specs").unwrap_err();
3927 match err {
3928 EngineError::Mem(msg) => assert!(msg.contains("no resolved gitdir")),
3929 other => panic!("expected EngineError::Mem, got {other:?}"),
3930 }
3931 }
3932
3933 #[test]
3934 fn worktree_for_folder_mount_returns_storage_path() {
3935 let tmp = TempDir::new().unwrap();
3936 let mem_dir = tmp.path().to_path_buf();
3937 let writer = FilesystemMemWriter::new(mem_dir.clone());
3938 let engine = Engine::from_mounts(vec![(
3939 folder_mount("specs", mem_dir.clone()),
3940 Box::new(writer) as Box<dyn MemBackend>,
3941 )])
3942 .unwrap();
3943 let worktree = engine.worktree_for("specs").unwrap();
3944 assert_eq!(worktree, mem_dir);
3945 }
3946
3947 #[test]
3948 fn worktree_for_unknown_mem_returns_unknown_mem() {
3949 let tmp = TempDir::new().unwrap();
3950 let mem_dir = tmp.path().to_path_buf();
3951 let writer = FilesystemMemWriter::new(mem_dir.clone());
3952 let engine = Engine::from_mounts(vec![(
3953 folder_mount("specs", mem_dir),
3954 Box::new(writer) as Box<dyn MemBackend>,
3955 )])
3956 .unwrap();
3957 let err = engine.worktree_for("missing").unwrap_err();
3958 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3959 }
3960
3961 #[test]
3962 fn worktree_for_archive_mount_returns_archive_backed_error() {
3963 let tmp = TempDir::new().unwrap();
3964 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3965 let engine = Engine::from_mounts(vec![(
3966 archive_mount("ext", archive_path.clone()),
3967 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3968 )])
3969 .unwrap();
3970 let err = engine.worktree_for("ext").unwrap_err();
3971 match err {
3972 EngineError::Mem(msg) => assert!(msg.contains("archive-backed")),
3973 other => panic!("expected EngineError::Mem, got {other:?}"),
3974 }
3975 }
3976
3977 #[test]
3978 fn mem_head_sha_for_folder_mount_is_none() {
3979 let tmp = TempDir::new().unwrap();
3982 let mem_dir = tmp.path().to_path_buf();
3983 let writer = FilesystemMemWriter::new(mem_dir.clone());
3984 let engine = Engine::from_mounts(vec![(
3985 folder_mount("specs", mem_dir),
3986 Box::new(writer) as Box<dyn MemBackend>,
3987 )])
3988 .unwrap();
3989 let head = engine.mem_head_sha("specs").unwrap();
3990 assert_eq!(head, None);
3991 }
3992
3993 #[test]
3994 fn mem_head_sha_unknown_mem_returns_unknown_mem() {
3995 let tmp = TempDir::new().unwrap();
3996 let mem_dir = tmp.path().to_path_buf();
3997 let writer = FilesystemMemWriter::new(mem_dir.clone());
3998 let engine = Engine::from_mounts(vec![(
3999 folder_mount("specs", mem_dir),
4000 Box::new(writer) as Box<dyn MemBackend>,
4001 )])
4002 .unwrap();
4003 let err = engine.mem_head_sha("missing").unwrap_err();
4004 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
4005 }
4006
4007 #[test]
4008 fn capability_surfaces_per_mount() {
4009 let tmp = TempDir::new().unwrap();
4010 let mem_dir = tmp.path().to_path_buf();
4011 let writer = FilesystemMemWriter::new(mem_dir.clone());
4012 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
4013
4014 let engine = Engine::from_mounts(vec![
4015 (
4016 folder_mount("writable", mem_dir),
4017 Box::new(writer) as Box<dyn MemBackend>,
4018 ),
4019 (
4020 archive_mount("read-only", archive_path.clone()),
4021 Box::new(ArchiveBackend::new(archive_path)),
4022 ),
4023 ])
4024 .unwrap();
4025
4026 assert_eq!(
4027 engine.capability("writable").unwrap(),
4028 MountCapability::Write
4029 );
4030 assert_eq!(
4031 engine.capability("read-only").unwrap(),
4032 MountCapability::ReadOnly
4033 );
4034 assert!(matches!(
4035 engine.capability("missing"),
4036 Err(EngineError::UnknownMem(_))
4037 ));
4038 }
4039
4040 #[test]
4041 fn read_provenance_routes_through_backend() {
4042 let tmp = TempDir::new().unwrap();
4043 let mem_dir = tmp.path().to_path_buf();
4044 let writer = FilesystemMemWriter::new(mem_dir.clone());
4045
4046 let backend_handle: &dyn MemBackend = &writer;
4049 backend_handle
4050 .append_provenance(&Provenance::new(
4051 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
4052 crate::ProvenanceKind::Create,
4053 Some("v:e".into()),
4054 crate::vcs::Actor::Cli,
4055 None,
4056 Some("first".into()),
4057 ))
4058 .unwrap();
4059
4060 let engine = Engine::from_mounts(vec![(
4061 folder_mount("specs", mem_dir),
4062 Box::new(writer) as Box<dyn MemBackend>,
4063 )])
4064 .unwrap();
4065
4066 let records = engine.read_provenance("specs", None).unwrap();
4067 assert_eq!(records.len(), 1);
4068 assert_eq!(records[0].kind, crate::ProvenanceKind::Create);
4069 assert_eq!(records[0].entity.as_deref(), Some("v:e"));
4070 assert_eq!(records[0].note.as_deref(), Some("first"));
4071 }
4072
4073 #[test]
4074 fn archive_mount_returns_sealed_indirectly_through_backend_layer() {
4075 let tmp = TempDir::new().unwrap();
4082 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
4083 let backend = ArchiveBackend::new(archive_path);
4084 match MemBackend::write_entity(&backend, Path::new("x.md"), b"x") {
4085 Err(BackendError::Sealed) => {}
4086 other => panic!("expected Sealed, got {other:?}"),
4087 }
4088 }
4089
4090 #[test]
4109 fn generation_keyed_memos_survive_rollback_and_track_mutations() {
4110 use indexmap::IndexMap;
4111
4112 let tmp = TempDir::new().unwrap();
4113 let mut engine = build_demo_engine(&tmp);
4114
4115 let _ = engine.communities();
4117 let memo_gen_before = engine.community_memo.get().expect("memo filled").0;
4118 let _ = engine.communities();
4119 assert_eq!(
4120 engine.community_memo.get().expect("still filled").0,
4121 memo_gen_before,
4122 "repeated reads must serve the memo"
4123 );
4124
4125 let bare = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4128 anchors: Vec::new(),
4129 anchors_unset: Vec::new(),
4130 id,
4131 expected_hash: None,
4132 sections: IndexMap::new(),
4133 append_sections: IndexMap::new(),
4134 patch_sections: IndexMap::new(),
4135 metadata: IndexMap::new(),
4136 metadata_unset: Vec::new(),
4137 declare_relations: Vec::new(),
4138 dry_run: false,
4139 relations_unset: Vec::new(),
4140 };
4141 let mut real = bare(crate::EntityId::new("specs", "source-one"));
4142 real.append_sections
4143 .insert("identity".to_string(), "appended line".to_string());
4144 let missing = bare(crate::EntityId::new("specs", "does-not-exist"));
4145 let (actor, client) = cli_actor();
4146 let result = engine
4147 .batch_update(
4148 vec![(real, None), (missing, None)],
4149 actor,
4150 Some(&client),
4151 false,
4152 )
4153 .expect("refused batch returns a report-all envelope");
4154 assert!(
4155 !result.applied,
4156 "the missing target refuses the whole batch"
4157 );
4158 assert_eq!(
4159 engine.community_memo.get().map(|m| m.0),
4160 Some(memo_gen_before),
4161 "a refused batch must leave the pre-batch memo standing"
4162 );
4163 assert_eq!(
4164 engine.store().generation(),
4165 memo_gen_before.store_generation,
4166 "rollback restored the store to the memo's generation"
4167 );
4168
4169 let snapshot = engine.store.clone();
4174 let interim_id = crate::EntityId::new("specs", "interim-only");
4175 let mut interim = engine
4176 .store
4177 .get(&crate::EntityId::new("specs", "source-one"))
4178 .expect("demo entity present")
4179 .clone();
4180 interim.id = interim_id.clone();
4181 interim.title = "Interim Only".to_string();
4182 engine.store.upsert(interim_id, interim);
4183 engine.invalidate_communities();
4184 let _ = engine.communities();
4185 let interim_gen = engine.community_memo.get().expect("interim memo").0;
4186 assert_ne!(interim_gen, memo_gen_before);
4187 engine.store = snapshot; engine.invalidate_communities();
4189 engine.invalidate_search_indexes();
4190 if let Some((g, _)) = engine.community_memo.get() {
4191 assert_ne!(
4192 *g, interim_gen,
4193 "a rolled-back interim state must never be served as fresh"
4194 );
4195 }
4196 assert!(
4197 !engine
4198 .communities()
4199 .entity_cluster_map
4200 .keys()
4201 .any(|id| id.contains("interim-only")),
4202 "the partition served after rollback reflects the restored store, not the interim one"
4203 );
4204
4205 let (actor, client) = cli_actor();
4208 engine
4209 .create_entity(
4210 empty_create_args("specs", "Fourth Entity"),
4211 actor,
4212 Some(&client),
4213 None,
4214 )
4215 .unwrap();
4216 assert!(
4217 engine
4218 .communities()
4219 .entity_cluster_map
4220 .keys()
4221 .any(|id| id.contains("fourth-entity")),
4222 "recomputed partition sees the new entity"
4223 );
4224 let fresh = {
4225 let schema = engine
4226 .schemas
4227 .iter()
4228 .min_by(|a, b| a.0.cmp(b.0))
4229 .map(|(_, s)| s.clone())
4230 .expect("demo engine has a schema");
4231 let schema_for_weights = schema.clone();
4232 crate::graph::community::detect_communities(
4233 engine.store(),
4234 schema.manifest.community.resolution,
4235 schema.manifest.community.seed,
4236 move |rel_type| {
4237 schema_for_weights
4238 .manifest
4239 .relationships
4240 .definitions
4241 .iter()
4242 .find(|d| d.name == rel_type)
4243 .map(|d| d.default_weight as f64)
4244 .unwrap_or(1.0)
4245 },
4246 )
4247 };
4248 assert_eq!(
4249 engine.communities().entity_cluster_map,
4250 fresh.entity_cluster_map,
4251 "memo must equal a from-scratch detection over the current store"
4252 );
4253 }
4254
4255 fn build_demo_engine(tmp: &TempDir) -> Engine {
4256 let mem_dir = tmp.path().to_path_buf();
4257 let writer = FilesystemMemWriter::new(mem_dir.clone());
4258 let mut engine = Engine::from_mounts(vec![(
4259 folder_mount("specs", mem_dir),
4260 Box::new(writer) as Box<dyn MemBackend>,
4261 )])
4262 .unwrap();
4263 let (actor, client) = cli_actor();
4264 let source = engine
4265 .create_entity(
4266 empty_create_args("specs", "Source One"),
4267 actor,
4268 Some(&client),
4269 None,
4270 )
4271 .unwrap();
4272 let target = engine
4273 .create_entity(
4274 empty_create_args("specs", "Target Two"),
4275 actor,
4276 Some(&client),
4277 None,
4278 )
4279 .unwrap();
4280 engine
4281 .create_entity(
4282 empty_create_args("specs", "Lonely Three"),
4283 actor,
4284 Some(&client),
4285 None,
4286 )
4287 .unwrap();
4288 engine
4289 .relate_entity(
4290 RelateEntityArgs {
4291 source: source.id.clone(),
4292 expected_hash: Some(source.content_hash.clone()),
4293 rel_type: "USES".to_string(),
4294 target: target.id.clone(),
4295 remove: false,
4296 description: None,
4297 dry_run: false,
4298 },
4299 actor,
4300 Some(&client),
4301 None,
4302 )
4303 .unwrap();
4304 engine
4305 }
4306
4307 #[test]
4308 fn status_reports_per_engine_counts() {
4309 let tmp = TempDir::new().unwrap();
4310 let engine = build_demo_engine(&tmp);
4311 let stats = engine.status();
4312 assert_eq!(stats.entity_count, 3);
4313 assert_eq!(stats.edge_count, 1);
4314 assert_eq!(stats.mem_count, 1);
4315 assert_eq!(stats.types_in_use, vec!["spec".to_string()]);
4316 assert_eq!(stats.edge_types.get("USES"), Some(&1));
4317 }
4318
4319 #[test]
4320 fn orphans_lists_unconnected_real_entities() {
4321 let tmp = TempDir::new().unwrap();
4322 let engine = build_demo_engine(&tmp);
4323 let orphans = engine.orphans();
4324 assert_eq!(orphans.len(), 1);
4325 assert_eq!(orphans[0].as_ref(), "specs--lonely-three");
4326 }
4327
4328 #[test]
4333 fn schema_breakdowns_attribute_to_mem_pin() {
4334 let tmp = TempDir::new().unwrap();
4335 let engine = build_demo_engine(&tmp);
4336
4337 let orphans = engine.orphans();
4338 let orphans_by_schema = engine.orphans_by_schema(&orphans);
4339 assert_eq!(
4340 orphans_by_schema.values().sum::<usize>(),
4341 orphans.len(),
4342 "per-schema orphan counts must sum to the raw total"
4343 );
4344 assert_eq!(orphans_by_schema.len(), 1, "one mem ⇒ one schema bucket");
4345 let (schema, count) = orphans_by_schema.iter().next().unwrap();
4346 assert!(!schema.is_empty(), "specs mem is pinned: {schema:?}");
4347 assert_eq!(*count, 1);
4348
4349 let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
4352 let communities_by_schema = engine.communities_by_schema(&mems);
4353 assert_eq!(communities_by_schema.len(), 1);
4354 assert_eq!(
4355 communities_by_schema.values().sum::<usize>(),
4356 engine.communities().count,
4357 );
4358 }
4359
4360 #[test]
4361 fn stubs_lists_unresolved_link_targets() {
4362 let tmp = TempDir::new().unwrap();
4363 let mem_dir = tmp.path().to_path_buf();
4364 let writer = FilesystemMemWriter::new(mem_dir.clone());
4365 let mut engine = Engine::from_mounts(vec![(
4366 folder_mount("specs", mem_dir),
4367 Box::new(writer) as Box<dyn MemBackend>,
4368 )])
4369 .unwrap();
4370 let (actor, client) = cli_actor();
4371 let source = engine
4372 .create_entity(
4373 empty_create_args("specs", "Holder"),
4374 actor,
4375 Some(&client),
4376 None,
4377 )
4378 .unwrap();
4379 engine
4382 .relate_entity(
4383 RelateEntityArgs {
4384 source: source.id.clone(),
4385 expected_hash: Some(source.content_hash.clone()),
4386 rel_type: "USES".to_string(),
4387 target: EntityId::new("specs", "ghost"),
4388 remove: false,
4389 description: None,
4390 dry_run: false,
4391 },
4392 actor,
4393 Some(&client),
4394 None,
4395 )
4396 .unwrap();
4397 let stubs = engine.stubs();
4398 assert!(
4399 stubs.iter().any(|(id, _)| id.as_ref() == "specs--ghost"),
4400 "expected ghost stub: {stubs:?}"
4401 );
4402 }
4403
4404 #[test]
4405 fn most_connected_orders_by_degree() {
4406 let tmp = TempDir::new().unwrap();
4407 let engine = build_demo_engine(&tmp);
4408 let top = engine.most_connected(5);
4409 assert_eq!(top.len(), 3);
4410 let zero_degree: Vec<_> = top
4412 .iter()
4413 .filter(|c| c.total == 0)
4414 .map(|c| c.id.as_ref().to_string())
4415 .collect();
4416 assert_eq!(zero_degree, vec!["specs--lonely-three".to_string()]);
4417 }
4418
4419 #[test]
4420 fn health_returns_per_engine_summary() {
4421 let tmp = TempDir::new().unwrap();
4422 let engine = build_demo_engine(&tmp);
4423 let health = engine.health();
4424 assert!(
4432 health
4433 .missing_fields
4434 .iter()
4435 .all(|r| r.id.as_ref() != "specs--source-one"),
4436 "post-strict-create fixture must not surface as missing-fields; got {:?}",
4437 health.missing_fields,
4438 );
4439 }
4440
4441 #[test]
4442 fn context_carries_neighbors_and_community() {
4443 let tmp = TempDir::new().unwrap();
4444 let engine = build_demo_engine(&tmp);
4445 let source_id = EntityId::new("specs", "source-one");
4446 let ctx = engine.context(&source_id).unwrap();
4447 assert_eq!(ctx.entity_id, source_id);
4448 assert_eq!(ctx.neighbors.len(), 1);
4449 assert_eq!(ctx.neighbors[0].relationship, "USES");
4450 assert!(matches!(ctx.neighbors[0].direction, Direction::Outgoing));
4451 }
4452
4453 #[test]
4454 fn communities_caches_louvain_until_invalidated() {
4455 let tmp = TempDir::new().unwrap();
4456 let mut engine = build_demo_engine(&tmp);
4457 let entities_before = engine.communities().entity_cluster_map.len();
4459 assert_eq!(
4461 engine.communities().entity_cluster_map.len(),
4462 entities_before
4463 );
4464 let (actor, client) = cli_actor();
4467 engine
4468 .create_entity(
4469 empty_create_args("specs", "Disturber"),
4470 actor,
4471 Some(&client),
4472 None,
4473 )
4474 .unwrap();
4475 let entities_after = engine.communities().entity_cluster_map.len();
4476 assert_eq!(
4477 entities_after,
4478 entities_before + 1,
4479 "create_entity should have invalidated community cache and added the new entity"
4480 );
4481 }
4482
4483 #[test]
4484 fn list_filters_by_metadata_only() {
4485 let tmp = TempDir::new().unwrap();
4486 let engine = build_demo_engine(&tmp);
4487 let scope = SearchScope {
4488 entity_type: Some("spec".to_string()),
4489 ..Default::default()
4490 };
4491 let result = engine.list(&scope);
4492 assert_eq!(result.hits.len(), 3);
4494 }
4495
4496 #[test]
4497 fn list_applies_schema_declared_filter_on_non_default_schema_mem() {
4498 let tmp = TempDir::new().unwrap();
4505 let mem_dir = tmp.path().to_path_buf();
4506 let writer = FilesystemMemWriter::new(mem_dir.clone());
4507 let mount = Mount {
4508 mem: "planning".to_string(),
4509 schema: Some(memstead_schema::SchemaRef::new(
4510 "planning",
4511 semver::Version::new(0, 1, 0),
4512 )),
4513 storage: MountStorage::Folder { path: mem_dir },
4514 capability: MountCapability::Write,
4515 lifecycle: MountLifecycle::Eager,
4516 cross_linkable: true,
4517 migration_target: None,
4518 };
4519 let mut engine =
4520 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
4521 let (actor, client) = cli_actor();
4522
4523 for (title, status) in &[("Skip Postgres", "accepted"), ("Use SQLite", "proposed")] {
4528 let mut metadata = indexmap::IndexMap::new();
4529 metadata.insert("status".to_string(), status.to_string());
4530 metadata.insert("deciders".to_string(), "alice".to_string());
4531 metadata.insert("decided_on".to_string(), "2026-05-19".to_string());
4532 let args = crate::engine::CreateEntityArgs {
4533 anchors: Vec::new(),
4534 mem: "planning".to_string(),
4535 title: title.to_string(),
4536 entity_type: "decision".to_string(),
4537 sections: indexmap::IndexMap::from_iter([
4538 ("decision".to_string(), "We chose this.".to_string()),
4539 ("context".to_string(), "Single-user dev.".to_string()),
4540 ("consequences".to_string(), "Lose multi-writer.".to_string()),
4541 ]),
4542 metadata,
4543 relations: Vec::new(),
4544 dry_run: false,
4545 };
4546 engine
4547 .create_entity(args, actor, Some(&client), None)
4548 .unwrap();
4549 }
4550
4551 let scope = SearchScope {
4553 entity_type: Some("decision".to_string()),
4554 filters: std::collections::HashMap::from([(
4555 "status".to_string(),
4556 "accepted".to_string(),
4557 )]),
4558 ..Default::default()
4559 };
4560 let result = engine.list(&scope);
4561 assert_eq!(
4562 result.hits.len(),
4563 1,
4564 "filter on schema-declared field must select only matching entities"
4565 );
4566 assert_eq!(result.hits[0].title, "Skip Postgres");
4567 assert!(
4568 result.warnings.is_empty(),
4569 "no warning should fire when the filter is declared by the mem's pinned schema: {:?}",
4570 result.warnings
4571 );
4572 }
4573
4574 #[test]
4575 fn search_returns_results_against_built_index() {
4576 let tmp = TempDir::new().unwrap();
4577 let engine = build_demo_engine(&tmp);
4578 let scope = SearchScope {
4579 query: Some(crate::ops::Query {
4580 any: vec!["source".to_string()],
4581 ..Default::default()
4582 }),
4583 ..Default::default()
4584 };
4585 let result = engine.search(&scope).expect("native search returns Ok");
4586 assert!(result.total >= 1, "expected ≥1 hit for source: {result:?}");
4587 assert!(
4588 result
4589 .hits
4590 .iter()
4591 .any(|h| h.id.as_ref() == "specs--source-one"),
4592 "expected source-one in hits: {result:?}"
4593 );
4594 }
4595
4596 }