1use std::cell::OnceCell;
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use memstead_schema::Schema;
21
22use crate::backend::MemBackend;
23use crate::engine_fallback_type;
24use crate::entity::loader::parse_entries;
25use crate::entity::source::{SourceEntry, SourceReadError};
26use crate::entity::store_builder::push_entities_into_store;
27use crate::mem::{MemOrigin, MemRouterSnapshot};
28use crate::ops::WarningHint;
29use crate::store::Store;
30use crate::workspace::{Mount, MountCapability, MountStorage, WorkspaceSettings};
31
32use super::{BootError, Engine, EngineError, MountedBackend};
33
34impl Engine {
35 pub fn from_mounts(mounts: Vec<(Mount, Box<dyn MemBackend>)>) -> Result<Self, EngineError> {
47 Self::from_mounts_inner(mounts, Vec::new(), Vec::new())
48 }
49
50 pub fn from_mounts_with_schemas_dir(
61 mounts: Vec<(Mount, Box<dyn MemBackend>)>,
62 schemas_dir: Option<&Path>,
63 ) -> Result<Self, EngineError> {
64 let (extra_schemas, failed) = load_workspace_schemas_with_failures(schemas_dir);
65 Self::from_mounts_inner(mounts, extra_schemas, failed)
66 }
67
68 pub fn from_mounts_with_schemas_dir_and_extra(
77 mounts: Vec<(Mount, Box<dyn MemBackend>)>,
78 schemas_dir: Option<&Path>,
79 mut extra: Vec<Arc<memstead_schema::Schema>>,
80 ) -> Result<Self, EngineError> {
81 let (mut local, failed) = load_workspace_schemas_with_failures(schemas_dir);
82 local.append(&mut extra);
83 Self::from_mounts_inner(mounts, local, failed)
84 }
85
86 pub(crate) fn from_mounts_inner(
87 mounts: Vec<(Mount, Box<dyn MemBackend>)>,
88 extra_schemas: Vec<Arc<memstead_schema::Schema>>,
89 failed_schema_packages: Vec<FailedSchemaPackage>,
90 ) -> Result<Self, EngineError> {
91 let mut seen: std::collections::HashSet<String> =
92 std::collections::HashSet::with_capacity(mounts.len());
93 let mut mounted: Vec<MountedBackend> = Vec::with_capacity(mounts.len());
94 for (mount, backend) in mounts {
95 if !seen.insert(mount.mem.clone()) {
96 return Err(EngineError::DuplicateMem(mount.mem));
97 }
98 let last_known_head = backend.current_head().ok().flatten();
104 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
113 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
114 memstead_schema::config::parse_mem_config(&value).ok()
115 });
116 let archive_provenance =
121 backend
122 .read_archive_provenance()
123 .ok()
124 .flatten()
125 .and_then(|bytes| {
126 memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok()
127 });
128 mounted.push(MountedBackend {
129 mount,
130 backend,
131 last_known_head,
132 mem_config,
133 archive_provenance,
134 deferred: false,
138 });
139 }
140
141 let builtin_schemas_only = memstead_schema::builtins::load_builtin_schemas()
151 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
152 let workspace_schemas = extra_schemas.clone();
155 let mut catalogue: Vec<Arc<memstead_schema::Schema>> =
156 Vec::with_capacity(extra_schemas.len() + builtin_schemas_only.len());
157 catalogue.extend(extra_schemas);
158 catalogue.extend(builtin_schemas_only.clone());
159 let builtin_schemas = catalogue;
160 let mut store = Store::new();
161 let mut load_errors: Vec<(PathBuf, String)> = Vec::new();
162 let mut schemas: HashMap<String, Arc<Schema>> = HashMap::with_capacity(mounted.len());
163 let fallback = engine_fallback_type();
164
165 let mem_names: Vec<String> = mounted.iter().map(|m| m.mount.mem.clone()).collect();
172 let known_suffixes: Vec<String> = mem_names
173 .iter()
174 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
175 .collect();
176 let mut load_warnings: Vec<WarningHint> = Vec::new();
177
178 let mut quarantined: Vec<crate::engine::QuarantinedMem> = Vec::new();
185 let mut quarantined_idx: std::collections::HashSet<usize> =
186 std::collections::HashSet::new();
187 let mut deferred_idx: std::collections::HashSet<usize> = std::collections::HashSet::new();
193
194 for (m_idx, m) in mounted.iter().enumerate() {
195 let config_pin = m.mem_config.as_ref().and_then(|c| c.schema.as_ref());
204 let mount_pin = m.mount.schema.as_ref();
205 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
209 && cfg != mp
210 {
211 load_warnings.push(WarningHint::SchemaPinMismatch {
212 mem: m.mount.mem.clone(),
213 config_pin: cfg.as_display(),
214 mount_pin: mp.as_display(),
215 });
216 }
217 if let Some(stamp) = m
232 .mem_config
233 .as_ref()
234 .and_then(|c| c.mutation_stamp.as_ref())
235 && let Some(direction) = crate::build_info::skew_direction(
236 &stamp.engine_version,
237 crate::build_info::full_version(),
238 )
239 {
240 load_warnings.push(WarningHint::EngineVersionSkew {
241 mem: m.mount.mem.clone(),
242 stamped_engine: stamp.engine_version.clone(),
243 running_engine: crate::build_info::full_version().to_string(),
244 stamped_schema: stamp.schema.clone(),
245 direction,
246 });
247 }
248 let settled_pin = config_pin.or(mount_pin);
251 let Some(effective_pin) = m.mount.migration_target.as_ref().or(settled_pin) else {
254 let e = EngineError::MemConfigIncomplete {
256 mem: m.mount.mem.clone(),
257 missing_fields: vec!["schema".to_string()],
258 };
259 quarantined.push(crate::engine::QuarantinedMem {
260 mount: m.mount.clone(),
261 reason_code: e.code().to_string(),
262 reason_message: e.to_string(),
263 });
264 quarantined_idx.insert(m_idx);
265 continue;
266 };
267 let schema = match SchemaResolver::new(&builtin_schemas).resolve(effective_pin) {
268 Ok(schema) => schema,
269 Err(sources) => {
270 let failed = failed_schema_packages.iter().find(|f| {
278 f.name.as_deref() == Some(effective_pin.name.as_str())
279 && f.version
280 .as_deref()
281 .is_none_or(|v| v == effective_pin.version.to_string())
282 });
283 let (reason_code, reason_message) = match failed {
284 Some(f) => (
285 "SCHEMA_LOAD_FAILED".to_string(),
286 format!(
287 "schema package at {} failed to load: {}",
288 f.path.display(),
289 f.error
290 ),
291 ),
292 None => {
293 let e = EngineError::SchemaNotFound {
294 mem: m.mount.mem.clone(),
295 pin: effective_pin.as_display(),
296 sources,
297 install_hint: None,
298 };
299 (e.code().to_string(), e.to_string())
300 }
301 };
302 quarantined.push(crate::engine::QuarantinedMem {
303 mount: m.mount.clone(),
304 reason_code,
305 reason_message,
306 });
307 quarantined_idx.insert(m_idx);
308 continue;
309 }
310 };
311 schemas.insert(m.mount.mem.clone(), schema.clone());
312
313 let locally_installed = workspace_schemas.iter().any(|s| {
323 s.manifest.name == effective_pin.name && s.version == effective_pin.version
324 });
325 let is_builtin = builtin_schemas_only.iter().any(|s| {
326 s.manifest.name == effective_pin.name && s.version == effective_pin.version
327 });
328 if !locally_installed
329 && is_builtin
330 && let Some(newest) =
331 newest_builtin_version(&effective_pin.name, &builtin_schemas_only)
332 && *newest > effective_pin.version
333 {
334 load_warnings.push(WarningHint::SchemaGenerationsBehind {
335 mem: m.mount.mem.clone(),
336 pinned: effective_pin.as_display(),
337 newest: newest.to_string(),
338 });
339 }
340
341 if let Err(memstead_schema::SchemaLoadError::SectionHeadingMismatch { violations }) =
346 memstead_schema::check_section_heading_roundtrip(&schema)
347 {
348 let (name, version) = schema.id();
349 load_warnings.push(WarningHint::SchemaHeadingRoundtripViolation {
350 mem: m.mount.mem.clone(),
351 schema_ref: format!("{name}@{version}"),
352 violations: violations.iter().map(Into::into).collect(),
353 });
354 }
355
356 if m.mount.lifecycle == crate::workspace::MountLifecycle::Lazy {
362 if let Some(w) = unbacked_mount_warning(&m.mount, m.backend.as_ref(), None) {
363 load_warnings.push(w);
364 }
365 deferred_idx.insert(m_idx);
366 continue;
367 }
368
369 let (entries, read_errors) = match collect_source_entries(m.backend.as_ref()) {
370 Ok(pair) => pair,
371 Err(e) => {
372 quarantined.push(crate::engine::QuarantinedMem {
375 mount: m.mount.clone(),
376 reason_code: e.code().to_string(),
377 reason_message: e.to_string(),
378 });
379 quarantined_idx.insert(m_idx);
380 schemas.remove(&m.mount.mem);
381 continue;
382 }
383 };
384 let path_backed = matches!(
411 m.mount.storage,
412 crate::workspace::MountStorage::Folder { .. }
413 | crate::workspace::MountStorage::Archive { .. }
414 );
415 if path_backed && !m.backend.storage_present().unwrap_or(true) {
416 let location = match &m.mount.storage {
417 crate::workspace::MountStorage::GitBranch { branch, .. } => branch.clone(),
418 crate::workspace::MountStorage::Folder { path }
419 | crate::workspace::MountStorage::Archive { path } => {
420 path.display().to_string()
421 }
422 crate::workspace::MountStorage::InMemory => String::new(),
423 };
424 quarantined.push(crate::engine::QuarantinedMem {
425 mount: m.mount.clone(),
426 reason_code: "MOUNT_UNBACKED".to_string(),
427 reason_message: format!(
428 "the mount's storage is gone ({location}); it is configured but cannot \
429 serve, so it is held out of the roster rather than answering reads \
430 with an empty graph"
431 ),
432 });
433 quarantined_idx.insert(m_idx);
434 schemas.remove(&m.mount.mem);
435 continue;
436 }
437 if let Some(w) =
441 unbacked_mount_warning(&m.mount, m.backend.as_ref(), Some(entries.len()))
442 {
443 load_warnings.push(w);
444 }
445 let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
446 push_entities_into_store(
452 &mut store,
453 load_result.entities,
454 fallback.as_ref(),
455 Some(crate::entity::store_builder::LoadCollector {
456 warnings: &mut load_warnings,
457 known_suffixes: &known_suffixes,
458 mem_names: &mem_names,
459 }),
460 );
461 if let crate::workspace::MountStorage::Folder { path } = &m.mount.storage {
466 let root = path.clone();
467 load_errors.extend(load_result.errors.into_iter().map(|(p, msg)| {
468 let abs = if p.is_relative() { root.join(&p) } else { p };
469 (abs, msg)
470 }));
471 } else {
472 load_errors.extend(load_result.errors);
473 }
474 }
475
476 for idx in &deferred_idx {
479 mounted[*idx].deferred = true;
480 }
481
482 if !quarantined_idx.is_empty() {
487 let mut keep_idx = 0usize;
488 mounted.retain(|_| {
489 let keep = !quarantined_idx.contains(&keep_idx);
490 keep_idx += 1;
491 keep
492 });
493 }
494
495 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
503 mounted
504 .iter()
505 .map(|m| (m.mount.mem.clone(), m.mount.capability))
506 .collect();
507 crate::entity::store_builder::validate_loaded_relations(
508 &mut store,
509 &schemas,
510 &mount_caps,
511 &mut load_warnings,
512 );
513
514 crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
519
520 load_warnings.retain(|w| match w {
529 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
530 store.get(resolved_id).is_none_or(|e| e.stub)
531 }
532 _ => true,
533 });
534
535 let mem_router = build_mem_router_from_mounts(&mounted);
541
542 let mounts_baseline =
548 std::cell::RefCell::new(mounted.iter().map(|m| m.mount.clone()).collect::<Vec<_>>());
549
550 Ok(Self {
551 mounts: mounted,
552 mounts_baseline,
553 store,
554 schemas,
555 workspace_schemas,
556 builtin_schemas: builtin_schemas_only,
557 load_errors,
558 community_memo: OnceCell::new(),
559 labelling_memo: OnceCell::new(),
560 #[cfg(not(target_arch = "wasm32"))]
561 search_indexes_memo: OnceCell::new(),
562 settings: WorkspaceSettings::default(),
563 create_rule_set_memo: OnceCell::new(),
564 declared_origins: HashMap::new(),
565 workspace_root: None,
566 load_warnings,
567 quarantined,
568 boot_diagnosis: None,
569 pipeline_configs: crate::pipeline_store::BindingConfigs::default(),
570 mem_router: Arc::new(mem_router),
571 backend_factory: crate::workspace_store::instantiate_lean_backend,
572 unmounted_storage_prober: None,
573 schemas_epoch: 0,
574 git_branch_ops: None,
575 event_subscribers: Arc::new(std::sync::Mutex::new(
576 crate::engine::events::SubscriberRegistry::new(),
577 )),
578 pending_mem_changed: Vec::new(),
579 mutation_clock: Arc::new(std::time::SystemTime::now),
580 current_role: crate::vcs::Role::Unspecified,
581 })
582 }
583
584 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
602 use crate::workspace_store::{
603 FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
604 instantiate_lean_backend,
605 };
606
607 let workspace = match detect_layout(workspace_root) {
608 Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
612 Some(ws) => ws,
613 None => {
614 return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
615 }
616 },
617 Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
618 };
619
620 let settings = workspace.settings.clone();
621 let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
622 Vec::with_capacity(workspace.mounts.len());
623 let mut instantiate_quarantine: Vec<crate::engine::QuarantinedMem> = Vec::new();
627 for mount in workspace.mounts {
628 match instantiate_lean_backend(&mount) {
629 Ok(backend) => mounts.push((mount, backend)),
630 Err(e) => instantiate_quarantine.push(crate::engine::QuarantinedMem {
631 reason_code: e.code().to_string(),
632 reason_message: e.to_string(),
633 mount,
634 }),
635 }
636 }
637 let fixed_dir = workspace_root.join(".memstead").join("schemas");
646 let (local, failed) = load_workspace_schemas_with_failures(Some(fixed_dir.as_path()));
647 let mut engine = Engine::from_mounts_inner(mounts, local, failed)
650 .map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
651 engine.quarantined.extend(instantiate_quarantine);
652 engine.set_settings(settings);
653 engine.workspace_root = Some(workspace_root.to_path_buf());
654 engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
665 workspace_root,
666 )?);
667 Ok(engine)
678 }
679}
680
681pub(crate) fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
714 let mut router = MemRouterSnapshot::new();
715 for m in mounts {
716 match m.mount.capability {
717 MountCapability::Write => {
718 let dir: Option<PathBuf> = match &m.mount.storage {
719 MountStorage::Folder { path } => Some(path.clone()),
720 MountStorage::GitBranch { .. } => None,
721 MountStorage::Archive { .. } => None,
722 MountStorage::InMemory => None,
726 };
727 router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
728 }
729 MountCapability::ReadOnly => match &m.mount.storage {
730 MountStorage::Archive { path } => {
731 router.add_read_only(m.mount.mem.clone(), path.clone());
732 }
733 MountStorage::Folder { path } => {
734 router.add_read_only(m.mount.mem.clone(), path.clone());
735 }
736 MountStorage::GitBranch { gitdir, .. } => {
737 router.add_read_only(m.mount.mem.clone(), gitdir.clone());
738 }
739 MountStorage::InMemory => {}
745 },
746 }
747 }
748 router
749}
750
751pub fn resolve_builtin_schema_pin_pub(
756 pin: &memstead_schema::SchemaRef,
757 catalogue: &[Arc<memstead_schema::Schema>],
758) -> Option<Arc<memstead_schema::Schema>> {
759 resolve_builtin_schema_pin(pin, catalogue)
760}
761
762fn newest_builtin_version<'a>(
767 name: &str,
768 builtins: &'a [Arc<memstead_schema::Schema>],
769) -> Option<&'a semver::Version> {
770 builtins
771 .iter()
772 .filter(|s| s.manifest.name == name)
773 .map(|s| &s.version)
774 .max()
775}
776
777pub struct SchemaResolver<'a> {
792 catalogue: &'a [Arc<memstead_schema::Schema>],
793}
794
795impl<'a> SchemaResolver<'a> {
796 pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
799 Self { catalogue }
800 }
801
802 pub fn resolve(
806 &self,
807 pin: &memstead_schema::SchemaRef,
808 ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
809 {
810 resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
811 crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
812 &pin.name,
813 &pin.version,
814 self.catalogue,
815 )
816 })
817 }
818}
819
820pub fn load_workspace_schemas(
828 schemas_dir: Option<&Path>,
829) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
830 Ok(load_workspace_schemas_with_failures(schemas_dir).0)
831}
832
833#[derive(Debug, Clone)]
840pub struct FailedSchemaPackage {
841 pub path: PathBuf,
842 pub name: Option<String>,
843 pub version: Option<String>,
844 pub error: String,
846}
847
848pub fn load_workspace_schemas_with_failures(
854 schemas_dir: Option<&Path>,
855) -> (Vec<Arc<memstead_schema::Schema>>, Vec<FailedSchemaPackage>) {
856 let Some(dir) = schemas_dir else {
857 return (Vec::new(), Vec::new());
858 };
859 if !dir.is_dir() {
860 return (Vec::new(), Vec::new());
861 }
862 let entries = match std::fs::read_dir(dir) {
863 Ok(e) => e,
864 Err(_) => return (Vec::new(), Vec::new()),
865 };
866 let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
867 let mut failures: Vec<FailedSchemaPackage> = Vec::new();
868 for entry in entries.flatten() {
869 let path = entry.path();
870 if !path.is_dir() {
871 continue;
872 }
873 if !path.join("schema.yaml").is_file() {
874 continue;
875 }
876 match memstead_schema::load_schema_from_dir(&path) {
877 Ok(schema) => schemas.push(Arc::new(schema)),
878 Err(e) => {
879 let header = std::fs::read_to_string(path.join("schema.yaml")).unwrap_or_default();
883 let peek = |k: &str| {
884 header
885 .lines()
886 .find_map(|l| l.strip_prefix(&format!("{k}:")))
887 .map(|v| v.trim().trim_matches('"').to_string())
888 .filter(|v| !v.is_empty())
889 };
890 failures.push(FailedSchemaPackage {
891 path: path.clone(),
892 name: peek("name"),
893 version: peek("version"),
894 error: e.to_string(),
895 });
896 }
897 }
898 }
899 (schemas, failures)
900}
901
902pub(super) fn resolve_builtin_schema_pin(
903 pin: &memstead_schema::SchemaRef,
904 catalogue: &[Arc<memstead_schema::Schema>],
905) -> Option<Arc<memstead_schema::Schema>> {
906 catalogue
907 .iter()
908 .find(|s| {
909 let id = s.id();
910 id.0 == pin.name && id.1 == pin.version
911 })
912 .cloned()
913}
914
915pub(super) fn unbacked_mount_warning(
923 mount: &crate::workspace::Mount,
924 backend: &dyn MemBackend,
925 entity_count: Option<usize>,
926) -> Option<WarningHint> {
927 use crate::ops::MountUnbackedReason;
928 use crate::workspace::MountStorage;
929 let (location, missing_reason) = match &mount.storage {
930 MountStorage::GitBranch { branch, .. } => (branch.clone(), MountUnbackedReason::MissingRef),
931 MountStorage::Folder { path } => {
932 (path.display().to_string(), MountUnbackedReason::MissingPath)
933 }
934 MountStorage::Archive { path } => {
935 (path.display().to_string(), MountUnbackedReason::MissingPath)
936 }
937 MountStorage::InMemory => return None,
938 };
939 if !backend.storage_present().unwrap_or(true) {
940 return Some(WarningHint::MountUnbacked {
941 mem: mount.mem.clone(),
942 reason: missing_reason,
943 location,
944 });
945 }
946 if entity_count == Some(0) {
947 return Some(WarningHint::MountUnbacked {
948 mem: mount.mem.clone(),
949 reason: MountUnbackedReason::Empty,
950 location,
951 });
952 }
953 None
954}
955
956pub(super) fn collect_source_entries(
957 backend: &dyn MemBackend,
958) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
959 let paths = backend.list_entities()?;
960 let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
961 let mut errors: Vec<SourceReadError> = Vec::new();
962 for path in paths {
963 match backend.read_entity(&path) {
964 Ok(Some(bytes)) => match String::from_utf8(bytes) {
965 Ok(content) => entries.push(SourceEntry {
966 relative_path: path.to_string_lossy().into_owned(),
967 source_path: path.clone(),
968 content,
969 }),
970 Err(e) => errors.push(SourceReadError {
971 source_path: path,
972 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
973 }),
974 },
975 Ok(None) => {
976 }
978 Err(e) => errors.push(SourceReadError {
979 source_path: path,
980 error: std::io::Error::other(e.to_string()),
981 }),
982 }
983 }
984 Ok((entries, errors))
985}
986
987#[cfg(test)]
988mod tests {
989
990 use std::path::Path;
991
992 use memstead_schema::SchemaRef;
993 use tempfile::TempDir;
994
995 use crate::backend::MemBackend;
996 use crate::engine::test_helpers::*;
997 use crate::engine::{Engine, EngineError};
998 use crate::ops::WarningHint;
999 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
1000 use crate::vcs::CommitContext;
1001 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1002
1003 #[test]
1008 fn unbacked_mount_probe_classes_missing_path_empty_and_present() {
1009 use crate::engine::boot::unbacked_mount_warning;
1010 use crate::ops::MountUnbackedReason;
1011 let tmp = TempDir::new().unwrap();
1012 let mount = |path: std::path::PathBuf| Mount {
1013 mem: "probe".into(),
1014 schema: None,
1015 storage: MountStorage::Folder { path },
1016 capability: MountCapability::Write,
1017 lifecycle: MountLifecycle::Eager,
1018 cross_linkable: true,
1019 migration_target: None,
1020 };
1021
1022 let gone = tmp.path().join("gone");
1023 let backend = FilesystemMemWriter::new(gone.clone());
1024 let w = unbacked_mount_warning(&mount(gone.clone()), &backend, Some(0))
1025 .expect("a missing folder is unbacked");
1026 match &w {
1027 WarningHint::MountUnbacked {
1028 mem,
1029 reason,
1030 location,
1031 } => {
1032 assert_eq!(mem, "probe");
1033 assert_eq!(*reason, MountUnbackedReason::MissingPath);
1034 assert_eq!(location, &gone.display().to_string());
1035 }
1036 other => panic!("unexpected variant: {other:?}"),
1037 }
1038 assert_eq!(w.code(), "MOUNT_UNBACKED");
1039 let json = serde_json::to_value(&w).unwrap();
1040 assert_eq!(json["details"]["reason"], "missing_path");
1041 assert!(unbacked_mount_warning(&mount(gone), &backend, None).is_some());
1043
1044 let hollow = tmp.path().join("hollow");
1045 std::fs::create_dir_all(&hollow).unwrap();
1046 let backend = FilesystemMemWriter::new(hollow.clone());
1047 let w = unbacked_mount_warning(&mount(hollow.clone()), &backend, Some(0))
1048 .expect("an entity-less folder is unbacked");
1049 assert_eq!(
1050 serde_json::to_value(&w).unwrap()["details"]["reason"],
1051 "empty"
1052 );
1053 assert!(unbacked_mount_warning(&mount(hollow.clone()), &backend, None).is_none());
1055 assert!(unbacked_mount_warning(&mount(hollow), &backend, Some(1)).is_none());
1057 }
1058
1059 #[test]
1063 fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
1064 let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
1065 let resolver = super::SchemaResolver::new(&catalogue);
1066
1067 let ok: SchemaRef = "default@1.0.0".parse().unwrap();
1068 assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
1069
1070 let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
1071 let sources = resolver.resolve(&miss).unwrap_err();
1072 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
1073 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1074 assert!(sources.iter().all(|s| !s.pinned_version_match));
1075 }
1076
1077 #[test]
1078 fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
1079 let engine = Engine::from_mounts(Vec::new()).unwrap();
1080 assert!(engine.mem_names().is_empty());
1081 match engine.list_entities("missing") {
1082 Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
1083 other => panic!("expected UnknownMem, got {other:?}"),
1084 }
1085 }
1086
1087 #[test]
1088 fn duplicate_mem_names_rejected_at_construction() {
1089 let tmp = TempDir::new().unwrap();
1090 let writer1: Box<dyn MemBackend> =
1091 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1092 let writer2: Box<dyn MemBackend> =
1093 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1094 let err = Engine::from_mounts(vec![
1095 (folder_mount("specs", tmp.path().to_path_buf()), writer1),
1096 (folder_mount("specs", tmp.path().to_path_buf()), writer2),
1097 ])
1098 .unwrap_err();
1099 assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
1100 }
1101
1102 #[test]
1103 fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
1104 let tmp = TempDir::new().unwrap();
1110 let mem_dir = tmp.path().to_path_buf();
1111 let body =
1112 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1113 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1114
1115 let writer = FilesystemMemWriter::new(mem_dir.clone());
1116 let engine = Engine::from_mounts(vec![(
1117 folder_mount("specs", mem_dir),
1118 Box::new(writer) as Box<dyn MemBackend>,
1119 )])
1120 .unwrap();
1121
1122 let warnings = engine.load_warnings();
1123 assert!(
1124 warnings
1125 .iter()
1126 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1127 "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
1128 );
1129 }
1130
1131 #[test]
1139 fn generation_behind_hint_fires_for_old_builtin_pin_only() {
1140 let tmp = TempDir::new().unwrap();
1142 let mem_dir = tmp.path().to_path_buf();
1143 let writer = FilesystemMemWriter::new(mem_dir.clone());
1144 let mut engine = Engine::from_mounts(vec![(
1145 folder_mount("specs", mem_dir),
1146 Box::new(writer) as Box<dyn MemBackend>,
1147 )])
1148 .unwrap();
1149 let behind: Vec<_> = engine
1150 .load_warnings()
1151 .iter()
1152 .filter_map(|w| match w {
1153 WarningHint::SchemaGenerationsBehind {
1154 mem,
1155 pinned,
1156 newest,
1157 } => Some((mem.clone(), pinned.clone(), newest.clone())),
1158 _ => None,
1159 })
1160 .collect();
1161 assert_eq!(
1162 behind,
1163 vec![(
1164 "specs".to_string(),
1165 "default@1.0.0".to_string(),
1166 "1.3.0".to_string()
1167 )],
1168 "old built-in pin must surface the generation-behind hint"
1169 );
1170 assert!(
1171 engine
1172 .health()
1173 .warnings
1174 .iter()
1175 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1176 "the hint rides health without an include gate"
1177 );
1178 engine
1180 .create_entity_with_ctx(
1181 crate::engine::CreateEntityArgs {
1182 anchors: Vec::new(),
1183 mem: "specs".to_string(),
1184 title: "Still writable".to_string(),
1185 entity_type: "spec".to_string(),
1186 sections: indexmap::IndexMap::from_iter([
1187 ("identity".to_string(), "i".to_string()),
1188 ("purpose".to_string(), "p".to_string()),
1189 ]),
1190 metadata: indexmap::IndexMap::new(),
1191 relations: Vec::new(),
1192 dry_run: false,
1193 },
1194 &crate::vcs::CommitContext::internal(),
1195 )
1196 .expect("generation-behind hint must never block mutations");
1197
1198 let tmp = TempDir::new().unwrap();
1200 let mem_dir = tmp.path().to_path_buf();
1201 let writer = FilesystemMemWriter::new(mem_dir.clone());
1202 let mut mount = folder_mount("specs", mem_dir);
1203 mount.schema = Some("default@1.3.0".parse().unwrap());
1204 let engine =
1205 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1206 assert!(
1207 !engine
1208 .load_warnings()
1209 .iter()
1210 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1211 "newest built-in pin must stay silent: {:?}",
1212 engine.load_warnings()
1213 );
1214 let health_json = serde_json::to_string(&engine.health().warnings).unwrap();
1215 assert!(
1216 !health_json.contains("SCHEMA_GENERATIONS_BEHIND"),
1217 "newest pin: health output carries no generation hint"
1218 );
1219 }
1220
1221 #[test]
1225 fn newest_builtin_version_orders_by_semver_not_string() {
1226 let manifest = |version: &str| {
1227 format!(
1228 r#"name: gen-test
1229version: {version}
1230description: test
1231when_to_use: test
1232types:
1233 - note
1234relationships:
1235 mode: strict
1236 definitions:
1237 - name: _default
1238 description: default
1239 default_weight: 1.0
1240 - name: PART_OF
1241 description: hier
1242 default_weight: 3.0
1243community:
1244 resolution: 1.0
1245 seed: 42
1246"#
1247 )
1248 };
1249 let type_yaml = r#"name: note
1250description: test
1251when_to_use: test
1252sections:
1253 - key: body
1254 heading: Body
1255 required: true
1256 search_weight: 10.0
1257 catch_all: true
1258metadata_fields: []
1259title_weight: 1.0
1260text_fields: [body]
1261hierarchy_relationship: PART_OF
1262no_self_loop_relationships: []
1263updatable_fields: [title, body]
1264health_required_fields: [body]
1265staleness_threshold_days: 30
1266write_rules: []
1267"#;
1268 let types = vec![("note".to_string(), type_yaml.to_string())];
1269 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = ["0.9.0", "0.10.0", "0.2.0"]
1270 .iter()
1271 .map(|v| {
1272 std::sync::Arc::new(
1273 memstead_schema::load_schema_from_memory(&manifest(v), &types)
1274 .expect("fixture schema loads"),
1275 )
1276 })
1277 .collect();
1278 let newest = super::newest_builtin_version("gen-test", &catalogue)
1279 .expect("name present in catalogue");
1280 assert_eq!(newest.to_string(), "0.10.0", "semver, not string, ordering");
1281 assert!(super::newest_builtin_version("absent", &catalogue).is_none());
1282 }
1283
1284 #[test]
1290 fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
1291 let tmp = TempDir::new().unwrap();
1292 let mem_dir = tmp.path().to_path_buf();
1293 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1296 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1297 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1298 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1299
1300 let writer = FilesystemMemWriter::new(mem_dir.clone());
1301 let engine = Engine::from_mounts(vec![(
1302 folder_mount("specs", mem_dir),
1303 Box::new(writer) as Box<dyn MemBackend>,
1304 )])
1305 .unwrap();
1306
1307 let source_id = crate::entity::EntityId::new("specs", "source");
1308 let target_id = crate::entity::EntityId::new("specs", "target");
1309 let source_entity = engine.get_entity(&source_id).expect("source loaded");
1310 assert!(
1313 source_entity.relationships.is_empty(),
1314 "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
1315 source_entity.relationships,
1316 );
1317 let outgoing: Vec<_> = engine
1319 .store()
1320 .outgoing(&source_id)
1321 .iter()
1322 .filter(|e| e.rel_type == "MADE_UP_TYPE")
1323 .collect();
1324 assert!(
1325 outgoing.is_empty(),
1326 "MADE_UP_TYPE edge must be dropped from the store"
1327 );
1328 let parsed_invalid: Vec<_> = engine
1330 .load_warnings()
1331 .iter()
1332 .filter_map(|w| match w {
1333 WarningHint::ParsedRelationInvalid {
1334 entity_id,
1335 rel_type,
1336 target,
1337 reason,
1338 origin,
1339 recovery,
1340 } => Some((
1341 entity_id.clone(),
1342 rel_type.clone(),
1343 target.clone(),
1344 reason.clone(),
1345 origin.clone(),
1346 recovery.clone(),
1347 )),
1348 _ => None,
1349 })
1350 .collect();
1351 assert_eq!(
1352 parsed_invalid.len(),
1353 1,
1354 "expected one warning, got {parsed_invalid:?}"
1355 );
1356 assert_eq!(parsed_invalid[0].0, source_id);
1357 assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
1358 assert_eq!(parsed_invalid[0].2, target_id);
1359 assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
1360 assert_eq!(parsed_invalid[0].4, "writable");
1361 let recovery = parsed_invalid[0]
1363 .5
1364 .as_ref()
1365 .expect("writable-origin warning must carry recovery");
1366 assert_eq!(
1367 recovery.kind,
1368 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1369 );
1370 assert_eq!(recovery.source_id, parsed_invalid[0].0);
1371 assert_eq!(recovery.target_id, parsed_invalid[0].2);
1372 assert_eq!(recovery.rel_type, parsed_invalid[0].1);
1373 }
1374
1375 #[test]
1383 fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
1384 let tmp = TempDir::new().unwrap();
1385 let mem_dir = tmp.path().to_path_buf();
1386 let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
1390 let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
1391 std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
1392 std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
1393
1394 let writer = FilesystemMemWriter::new(mem_dir.clone());
1395 let engine = Engine::from_mounts(vec![(
1396 folder_mount("specs", mem_dir),
1397 Box::new(writer) as Box<dyn MemBackend>,
1398 )])
1399 .unwrap();
1400
1401 let alpha_id = crate::entity::EntityId::new("specs", "alpha");
1402 let beta_id = crate::entity::EntityId::new("specs", "beta");
1403
1404 assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
1407 assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
1408
1409 let surviving: Vec<_> = engine
1412 .store()
1413 .all_entities()
1414 .flat_map(|e| {
1415 engine
1416 .store()
1417 .outgoing(&e.id)
1418 .iter()
1419 .filter(|edge| edge.rel_type == "PART_OF")
1420 .map(|edge| (e.id.clone(), edge.target.clone()))
1421 .collect::<Vec<_>>()
1422 })
1423 .collect();
1424 assert_eq!(
1425 surviving.len(),
1426 1,
1427 "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
1428 );
1429
1430 let cycle_drops: Vec<_> = engine
1433 .load_warnings()
1434 .iter()
1435 .filter_map(|w| match w {
1436 WarningHint::ParsedRelationInvalid {
1437 entity_id,
1438 rel_type,
1439 target,
1440 reason,
1441 ..
1442 } if reason == "cycle" => {
1443 Some((entity_id.clone(), rel_type.clone(), target.clone()))
1444 }
1445 _ => None,
1446 })
1447 .collect();
1448 assert_eq!(
1449 cycle_drops.len(),
1450 1,
1451 "exactly one cycle warning must fire, got {cycle_drops:?}",
1452 );
1453 let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
1455 assert_eq!(dropped_rel_type, "PART_OF");
1456 let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
1457 let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
1458 assert!(
1459 is_alpha_to_beta || is_beta_to_alpha,
1460 "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
1461 );
1462 assert_ne!(
1464 (&surviving[0].0, &surviving[0].1),
1465 (dropped_from, dropped_to),
1466 "surviving edge must differ from the dropped one",
1467 );
1468 }
1469
1470 #[test]
1478 fn nested_prefix_warning_exempts_real_cross_mem_target_loaded_later() {
1479 let tmp = TempDir::new().unwrap();
1480 let project_dir = tmp.path().join("project");
1481 let registry_dir = tmp.path().join("registry");
1482 std::fs::create_dir_all(&project_dir).unwrap();
1483 std::fs::create_dir_all(®istry_dir).unwrap();
1484
1485 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nReal: [[registry--registry-service]]. Drifted: [[registry--never-created]].\n";
1488 std::fs::write(project_dir.join("source.md"), source).unwrap();
1489
1490 let service = "---\ntype: spec\n---\n# Registry Service\n\n## Identity\n\nA real entity.\n";
1493 std::fs::write(registry_dir.join("registry-service.md"), service).unwrap();
1494
1495 let engine = Engine::from_mounts(vec![
1496 (
1497 folder_mount("project", project_dir.clone()),
1498 Box::new(FilesystemMemWriter::new(project_dir)) as Box<dyn MemBackend>,
1499 ),
1500 (
1501 folder_mount("registry", registry_dir.clone()),
1502 Box::new(FilesystemMemWriter::new(registry_dir)) as Box<dyn MemBackend>,
1503 ),
1504 ])
1505 .unwrap();
1506
1507 let nested: Vec<_> = engine
1508 .load_warnings()
1509 .iter()
1510 .filter_map(|w| match w {
1511 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1512 Some(resolved_id.to_string())
1513 }
1514 _ => None,
1515 })
1516 .collect();
1517 assert!(
1518 !nested.contains(&"registry--registry-service".to_string()),
1519 "a valid cross-mem id resolving to a real entity must not warn, got {nested:?}",
1520 );
1521 assert!(
1522 nested.contains(&"registry--never-created".to_string()),
1523 "a genuinely unresolved nested-prefix link must keep its warning, got {nested:?}",
1524 );
1525 }
1526
1527 #[test]
1537 fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
1538 use crate::engine::test_helpers::write_schema_files_with_default_type;
1539
1540 let tmp = TempDir::new().unwrap();
1541 let schemas_dir = tmp.path().join("schemas");
1542 std::fs::create_dir_all(&schemas_dir).unwrap();
1543 let manifest = r#"name: shape-test
1547version: 0.1.0
1548description: shape-constraint schema
1549when_to_use: tests
1550types:
1551 - doc
1552 - actor
1553relationships:
1554 mode: strict
1555 definitions:
1556 - name: OWNS
1557 description: actor owns doc
1558 default_weight: 1.0
1559 source_types: [actor]
1560 target_types: [doc]
1561 - name: _default
1562 description: fallback
1563 default_weight: 1.0
1564community:
1565 resolution: 1.0
1566 seed: 42
1567"#;
1568 write_schema_files_with_default_type(
1569 &schemas_dir,
1570 "shape-test",
1571 manifest,
1572 &["doc", "actor"],
1573 );
1574
1575 let mem_dir = tmp.path().join("mem");
1576 std::fs::create_dir_all(&mem_dir).unwrap();
1577 let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
1581 let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
1582 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1583 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1584
1585 let writer = FilesystemMemWriter::new(mem_dir.clone());
1586 let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
1587 let mount = Mount {
1588 mem: "specs".to_string(),
1589 schema: Some(pin),
1590 storage: MountStorage::Folder { path: mem_dir },
1591 capability: MountCapability::Write,
1592 lifecycle: MountLifecycle::Eager,
1593 cross_linkable: true,
1594 migration_target: None,
1595 };
1596 let engine = Engine::from_mounts_with_schemas_dir(
1597 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1598 Some(&schemas_dir),
1599 )
1600 .unwrap();
1601
1602 let source_id = crate::entity::EntityId::new("specs", "source");
1603 let target_id = crate::entity::EntityId::new("specs", "target");
1604
1605 let shape_drops: Vec<_> = engine
1606 .load_warnings()
1607 .iter()
1608 .filter_map(|w| match w {
1609 WarningHint::ParsedRelationInvalid {
1610 entity_id,
1611 rel_type,
1612 target,
1613 reason,
1614 origin,
1615 recovery,
1616 } if reason == "shape" => Some((
1617 entity_id.clone(),
1618 rel_type.clone(),
1619 target.clone(),
1620 origin.clone(),
1621 recovery.clone(),
1622 )),
1623 _ => None,
1624 })
1625 .collect();
1626 assert_eq!(
1627 shape_drops.len(),
1628 1,
1629 "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
1630 engine.load_warnings(),
1631 );
1632 let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
1633 shape_drops.into_iter().next().unwrap();
1634 assert_eq!(drop_from, source_id);
1635 assert_eq!(drop_type, "OWNS");
1636 assert_eq!(drop_to, target_id);
1637 assert_eq!(drop_origin, "writable");
1638 let recovery = drop_recovery.expect("writable origin must carry recovery");
1641 assert_eq!(
1642 recovery.kind,
1643 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1644 );
1645 assert_eq!(recovery.source_id, source_id);
1646 assert_eq!(recovery.target_id, target_id);
1647 assert_eq!(recovery.rel_type, "OWNS");
1648 }
1649
1650 #[test]
1656 fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
1657 let tmp = TempDir::new().unwrap();
1658 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1662 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1663 let archive_path = build_archive(
1664 tmp.path(),
1665 "ext",
1666 &[
1667 ("target.md", target.as_bytes()),
1668 ("source.md", source.as_bytes()),
1669 ],
1670 );
1671
1672 let engine = Engine::from_mounts(vec![(
1673 archive_mount("external", archive_path.clone()),
1674 Box::new(ArchiveBackend::new(archive_path)),
1675 )])
1676 .unwrap();
1677
1678 let invalid: Vec<_> = engine
1679 .load_warnings()
1680 .iter()
1681 .filter_map(|w| match w {
1682 WarningHint::ParsedRelationInvalid {
1683 rel_type,
1684 reason,
1685 origin,
1686 recovery,
1687 ..
1688 } => Some((
1689 rel_type.clone(),
1690 reason.clone(),
1691 origin.clone(),
1692 recovery.clone(),
1693 )),
1694 _ => None,
1695 })
1696 .collect();
1697 assert_eq!(
1698 invalid.len(),
1699 1,
1700 "expected one parse-time drop on the readonly mount, got {invalid:?}",
1701 );
1702 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1703 assert_eq!(invalid[0].1, "unknown_rel_type");
1704 assert_eq!(invalid[0].2, "readonly");
1705 assert!(
1706 invalid[0].3.is_none(),
1707 "readonly-origin warning must omit the recovery hint, got {:?}",
1708 invalid[0].3,
1709 );
1710 }
1711
1712 #[test]
1713 fn load_on_init_populates_store_from_folder_mount() {
1714 let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1717
1718 let tmp = TempDir::new().unwrap();
1719 let mem_dir = tmp.path().to_path_buf();
1720 let writer = FilesystemMemWriter::new(mem_dir.clone());
1721 <FilesystemMemWriter as MemWriter>::write_entity(
1722 &writer,
1723 Path::new("hello.md"),
1724 body.as_bytes(),
1725 )
1726 .unwrap();
1727 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1728 .unwrap();
1729
1730 let engine = Engine::from_mounts(vec![(
1731 folder_mount("specs", mem_dir),
1732 Box::new(writer) as Box<dyn MemBackend>,
1733 )])
1734 .unwrap();
1735
1736 assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1738 let id = crate::EntityId::new("specs", "hello");
1739 let entity = engine.get_entity(&id).expect("entity must be present");
1740 assert_eq!(entity.title, "Hello");
1741 assert_eq!(entity.entity_type, "spec");
1742 assert!(engine.load_errors().is_empty());
1743 assert_eq!(engine.schemas().len(), 1);
1745 assert!(engine.schemas().contains_key("specs"));
1746 }
1747
1748 #[test]
1749 fn load_on_init_populates_store_from_archive_mount() {
1750 let body =
1751 "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1752
1753 let tmp = TempDir::new().unwrap();
1754 let archive_path =
1755 build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1756
1757 let engine = Engine::from_mounts(vec![(
1758 archive_mount("external", archive_path.clone()),
1759 Box::new(ArchiveBackend::new(archive_path)),
1760 )])
1761 .unwrap();
1762
1763 let id = crate::EntityId::new("external", "from-archive");
1764 let entity = engine.get_entity(&id).expect("entity must be present");
1765 assert_eq!(entity.title, "From Archive");
1766 assert!(engine.load_errors().is_empty());
1767 }
1768
1769 #[test]
1770 fn load_on_init_populates_store_from_heterogeneous_mounts() {
1771 let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1772 let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1773
1774 let tmp = TempDir::new().unwrap();
1775
1776 let folder_dir = tmp.path().join("folder-mem");
1777 std::fs::create_dir_all(&folder_dir).unwrap();
1778 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1779 <FilesystemMemWriter as MemWriter>::write_entity(
1780 &folder_writer,
1781 Path::new("local.md"),
1782 folder_body.as_bytes(),
1783 )
1784 .unwrap();
1785 <FilesystemMemWriter as MemWriter>::commit(
1786 &folder_writer,
1787 "seed",
1788 &CommitContext::internal(),
1789 )
1790 .unwrap();
1791
1792 let archive_path = build_archive(
1793 tmp.path(),
1794 "external",
1795 &[("external.md", archive_body.as_bytes())],
1796 );
1797
1798 let engine = Engine::from_mounts(vec![
1799 (
1800 folder_mount("local", folder_dir),
1801 Box::new(folder_writer) as Box<dyn MemBackend>,
1802 ),
1803 (
1804 archive_mount("external", archive_path.clone()),
1805 Box::new(ArchiveBackend::new(archive_path)),
1806 ),
1807 ])
1808 .unwrap();
1809
1810 assert_eq!(engine.store().len(), 2);
1812 assert!(
1813 engine
1814 .get_entity(&crate::EntityId::new("local", "local"))
1815 .is_some()
1816 );
1817 assert!(
1818 engine
1819 .get_entity(&crate::EntityId::new("external", "external"))
1820 .is_some()
1821 );
1822 }
1823
1824 #[test]
1825 fn load_on_init_collects_per_file_parse_errors_without_failing() {
1826 let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1830 let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1831
1832 let tmp = TempDir::new().unwrap();
1833 let mem_dir = tmp.path().to_path_buf();
1834 let writer = FilesystemMemWriter::new(mem_dir.clone());
1835 <FilesystemMemWriter as MemWriter>::write_entity(
1836 &writer,
1837 Path::new("good.md"),
1838 good.as_bytes(),
1839 )
1840 .unwrap();
1841 <FilesystemMemWriter as MemWriter>::write_entity(
1842 &writer,
1843 Path::new("bad.md"),
1844 bad.as_bytes(),
1845 )
1846 .unwrap();
1847 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1848 .unwrap();
1849
1850 let engine = Engine::from_mounts(vec![(
1851 folder_mount("specs", mem_dir),
1852 Box::new(writer) as Box<dyn MemBackend>,
1853 )])
1854 .unwrap();
1855
1856 assert!(
1858 engine
1859 .get_entity(&crate::EntityId::new("specs", "good"))
1860 .is_some(),
1861 "good.md must parse and reach the store"
1862 );
1863 let bad_known_to_engine = engine
1868 .get_entity(&crate::EntityId::new("specs", "bad"))
1869 .is_some()
1870 || !engine.load_errors().is_empty();
1871 assert!(
1872 bad_known_to_engine,
1873 "bad.md must either parse or surface in load_errors"
1874 );
1875 }
1876
1877 #[test]
1878 fn empty_mount_list_yields_empty_store() {
1879 let engine = Engine::from_mounts(Vec::new()).unwrap();
1880 assert!(engine.store().is_empty());
1881 assert!(engine.schemas().is_empty());
1882 assert!(engine.load_errors().is_empty());
1883 }
1884
1885 #[test]
1888 fn from_workspace_root_errors_for_empty_layout() {
1889 let tmp = TempDir::new().unwrap();
1890 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1891 match err {
1892 crate::BootError::NotInitialised(p) => {
1893 assert_eq!(p, tmp.path());
1894 }
1895 other => panic!("expected NotInitialised, got {other:?}"),
1896 }
1897 }
1898
1899 #[test]
1900 fn from_workspace_root_loads_new_two_layer_layout() {
1901 let tmp = TempDir::new().unwrap();
1902 let mem_dir = tmp.path().join("mem");
1903 std::fs::create_dir_all(&mem_dir).unwrap();
1904 std::fs::write(
1905 mem_dir.join("hello.md"),
1906 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1907 )
1908 .unwrap();
1909
1910 let memstead = tmp.path().join(".memstead");
1911 std::fs::create_dir_all(&memstead).unwrap();
1912 std::fs::write(
1913 memstead.join("workspace.toml"),
1914 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1915 )
1916 .unwrap();
1917 use crate::workspace_store::WorkspaceStoreAdapter;
1920 let store = crate::FileWorkspaceStore::new();
1921 store
1922 .save_state(
1923 tmp.path(),
1924 &crate::workspace::Workspace {
1925 mounts: vec![folder_mount("specs", mem_dir)],
1926 settings: crate::workspace::WorkspaceSettings::default(),
1927 },
1928 )
1929 .unwrap();
1930
1931 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1932 assert_eq!(engine.mem_names(), vec!["specs"]);
1933 let entity = engine
1934 .get_entity(&crate::EntityId::new("specs", "hello"))
1935 .expect("seeded entity must load through from_workspace_root");
1936 assert_eq!(entity.title, "Hello");
1937 }
1938
1939 #[test]
1944 fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1945 use crate::pipeline::{MediumType, Projection};
1946 let tmp = TempDir::new().unwrap();
1947 let mem_dir = tmp.path().join("mem");
1948 std::fs::create_dir_all(&mem_dir).unwrap();
1949
1950 let memstead = tmp.path().join(".memstead");
1951 std::fs::create_dir_all(&memstead).unwrap();
1952 std::fs::write(
1953 memstead.join("workspace.toml"),
1954 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1955 )
1956 .unwrap();
1957 use crate::workspace_store::WorkspaceStoreAdapter;
1958 crate::FileWorkspaceStore::new()
1959 .save_state(
1960 tmp.path(),
1961 &crate::workspace::Workspace {
1962 mounts: vec![folder_mount("specs", mem_dir)],
1963 settings: crate::workspace::WorkspaceSettings::default(),
1964 },
1965 )
1966 .unwrap();
1967
1968 crate::pipeline_store::write_binding(
1970 tmp.path(),
1971 "specs",
1972 "graph",
1973 &sample_v2_binding("specs"),
1974 )
1975 .unwrap();
1976
1977 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1978 let pc = engine.pipeline_configs();
1979 assert_eq!(pc.bindings.len(), 1, "one binding enumerated");
1980 assert_eq!(pc.bindings[0].mem, "specs");
1981 assert_eq!(pc.bindings[0].name, "graph");
1982 assert_eq!(pc.bindings[0].config.destination_mem, "specs");
1983 assert_eq!(
1984 pc.bindings[0].config.sources[0].medium_type,
1985 MediumType::Codebase
1986 );
1987
1988 crate::pipeline_store::write_projection(
1995 tmp.path(),
1996 "specs",
1997 "legacy",
1998 &Projection {
1999 intent: None,
2000 source_facets: vec!["view".to_string()],
2001 reference_mems: Vec::new(),
2002 destination_mem: "specs".to_string(),
2003 rules: None,
2004 },
2005 )
2006 .unwrap();
2007 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2008 let pc = engine.pipeline_configs();
2009 assert_eq!(pc.bindings.len(), 1, "the healthy binding still serves");
2010 assert_eq!(pc.quarantined.len(), 1, "the legacy one quarantines");
2011 assert_eq!(pc.quarantined[0].name, "legacy");
2012 assert_eq!(pc.quarantined[0].reason_code, "PROJECTION_STORE_LEGACY");
2013 assert!(
2014 pc.quarantined[0]
2015 .reason_message
2016 .contains("memstead projection migrate"),
2017 "quarantine reason names the migrate command, got: {}",
2018 pc.quarantined[0].reason_message
2019 );
2020 }
2021
2022 fn sample_v2_binding(dest: &str) -> crate::binding::Binding {
2025 v2_binding_with_pointer(dest, "..")
2026 }
2027
2028 fn v2_binding_with_pointer(dest: &str, pointer: &str) -> crate::binding::Binding {
2029 use crate::binding::{BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations};
2030 use crate::pipeline::{IngestTrigger, MediumType, Source};
2031 Binding {
2032 version: BINDING_VERSION,
2033 intent: None,
2034 sources: vec![Source {
2035 name: "src".to_string(),
2036 medium_type: MediumType::Codebase,
2037 pointer: pointer.to_string(),
2038 change_detection: None,
2039 scope: Vec::new(),
2040 engagement: None,
2041 preparation: None,
2042 }],
2043 reference_mems: Vec::new(),
2044 destination_mem: dest.to_string(),
2045 deny_paths: Vec::new(),
2046 coverage_semantics: None,
2047 rules: None,
2048 prune: None,
2049 operations: Operations {
2050 build: Some(BuildOperation {
2051 mode: BuildMode::Discovery,
2052 trigger: IngestTrigger::Loop,
2053 batch_size: 10,
2054 post_actions: None,
2055 }),
2056 sync: None,
2057 verify: None,
2058 },
2059 }
2060 }
2061
2062 #[test]
2070 fn entity_anchors_resolve_live_state_for_path_medium() {
2071 use crate::anchor::{AnchorInput, AnchorState};
2072 use crate::vcs::Actor;
2073 use crate::workspace_store::WorkspaceStoreAdapter;
2074 use indexmap::IndexMap;
2075
2076 let tmp = TempDir::new().unwrap();
2077 let mem_dir = tmp.path().join("mem");
2078 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2079 std::fs::write(
2080 mem_dir.join(".memstead").join("config.json"),
2081 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2082 )
2083 .unwrap();
2084
2085 let memstead = tmp.path().join(".memstead");
2086 std::fs::create_dir_all(&memstead).unwrap();
2087 std::fs::write(
2088 memstead.join("workspace.toml"),
2089 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2090 )
2091 .unwrap();
2092 crate::FileWorkspaceStore::new()
2093 .save_state(
2094 tmp.path(),
2095 &crate::workspace::Workspace {
2096 mounts: vec![folder_mount("specs", mem_dir.clone())],
2097 settings: crate::workspace::WorkspaceSettings::default(),
2098 },
2099 )
2100 .unwrap();
2101
2102 crate::pipeline_store::write_binding(
2108 tmp.path(),
2109 "specs",
2110 "graph",
2111 &v2_binding_with_pointer("specs", "src"),
2112 )
2113 .unwrap();
2114 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2115 std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
2116 std::fs::write(tmp.path().join("src").join("gone.rs"), "fn gone() {}").unwrap();
2119
2120 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2121
2122 let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
2123 artifact: Some(artifact.to_string()),
2124 grain: Some("file".to_string()),
2125 class: Some(class.to_string()),
2126 hash: hash.map(str::to_string),
2127 hash_stability: Some("stable".to_string()),
2128 ..Default::default()
2129 };
2130 let mut sections = IndexMap::new();
2131 sections.insert("identity".to_string(), "Covers src.".to_string());
2132 sections.insert("purpose".to_string(), "Track sources.".to_string());
2133 let present_hash = crate::anchor::prepared_content_hash(
2136 &std::fs::read(tmp.path().join("src").join("present.rs")).unwrap(),
2137 );
2138 let created = engine
2139 .create_entity(
2140 crate::CreateEntityArgs {
2141 mem: "specs".to_string(),
2142 title: "Covers".to_string(),
2143 entity_type: "spec".to_string(),
2144 sections,
2145 metadata: IndexMap::new(),
2146 relations: Vec::new(),
2147 anchors: vec![
2148 anchor("src/present.rs", "anchored", Some(&present_hash)), anchor("src/present.rs", "informed-by", None), anchor("src/gone.rs", "anchored", Some("h2")), anchor("src/present.rs", "derived", Some("stale")), ],
2153 dry_run: false,
2154 },
2155 Actor::Agent,
2156 None,
2157 None,
2158 )
2159 .unwrap();
2160
2161 std::fs::remove_file(tmp.path().join("src").join("gone.rs")).unwrap();
2162 let resolved = engine.entity_anchors_resolved(&created.id);
2163 assert_eq!(resolved.len(), 4);
2164 let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
2165 resolved
2166 .iter()
2167 .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
2168 .and_then(|r| r.state)
2169 };
2170 assert_eq!(
2171 state_of(
2172 "src/present.rs",
2173 crate::anchor::AnchorProvenanceClass::Anchored
2174 ),
2175 Some(AnchorState::Resolves),
2176 "recorded hash matches the observed prepared form → resolves"
2177 );
2178 assert_eq!(
2179 state_of(
2180 "src/present.rs",
2181 crate::anchor::AnchorProvenanceClass::Derived
2182 ),
2183 Some(AnchorState::Drifted),
2184 "recorded hash mismatches the observed prepared form on a stable medium → drifted"
2185 );
2186 assert_eq!(
2187 state_of(
2188 "src/present.rs",
2189 crate::anchor::AnchorProvenanceClass::InformedBy
2190 ),
2191 Some(AnchorState::Resolves),
2192 "present non-hash anchor resolves on existence"
2193 );
2194 assert_eq!(
2195 state_of(
2196 "src/gone.rs",
2197 crate::anchor::AnchorProvenanceClass::Anchored
2198 ),
2199 Some(AnchorState::Orphaned),
2200 "absent artifact is orphaned"
2201 );
2202 }
2203
2204 #[test]
2209 fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
2210 use crate::workspace_store::WorkspaceStoreAdapter;
2211
2212 let tmp = TempDir::new().unwrap();
2213 let mem_dir = tmp.path().join("mem");
2214 std::fs::create_dir_all(&mem_dir).unwrap();
2215 let memstead = tmp.path().join(".memstead");
2216 std::fs::create_dir_all(&memstead).unwrap();
2217 std::fs::write(
2218 memstead.join("workspace.toml"),
2219 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2220 )
2221 .unwrap();
2222 crate::FileWorkspaceStore::new()
2223 .save_state(
2224 tmp.path(),
2225 &crate::workspace::Workspace {
2226 mounts: vec![folder_mount("specs", mem_dir)],
2227 settings: crate::workspace::WorkspaceSettings::default(),
2228 },
2229 )
2230 .unwrap();
2231
2232 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2233 assert!(engine.pipeline_configs().bindings.is_empty());
2234
2235 engine
2237 .add_projection_json(
2238 "specs",
2239 "graph",
2240 r#"{
2241 "sources": [{ "name": "src", "type": "codebase", "pointer": "..",
2242 "scope": [{ "path": "**/*.rs", "mode": "allow" }] }],
2243 "destination_mem": "specs"
2244 }"#,
2245 None,
2246 )
2247 .unwrap();
2248 assert_eq!(engine.pipeline_configs().bindings.len(), 1);
2250 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph");
2251 assert_eq!(
2252 engine.pipeline_configs().bindings[0].config.sources[0].name,
2253 "src"
2254 );
2255
2256 let err = engine
2258 .add_projection_json("specs", "bad", "{ not json", None)
2259 .unwrap_err();
2260 assert!(
2261 matches!(
2262 err,
2263 crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
2264 ),
2265 "got {err:?}"
2266 );
2267
2268 engine
2270 .update_projection_json("specs", "graph", r#"{"intent":"i2"}"#, None)
2271 .unwrap();
2272 assert_eq!(
2273 engine.pipeline_configs().bindings[0]
2274 .config
2275 .intent
2276 .as_deref(),
2277 Some("i2")
2278 );
2279
2280 engine
2282 .rename_projection("specs", "graph", "graph2", None)
2283 .unwrap();
2284 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph2");
2285
2286 let json = engine.pipeline_configs_json();
2289 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2290 assert!(parsed.get("mediums").is_none(), "no mediums key: {json}");
2291 assert!(parsed.get("facets").is_none(), "no facets key: {json}");
2292 let bindings = parsed["bindings"].as_array().unwrap();
2293 assert_eq!(bindings.len(), 1);
2294 assert_eq!(bindings[0]["name"], "graph2");
2295 assert_eq!(bindings[0]["config"]["sources"][0]["type"], "codebase");
2296
2297 engine.delete_projection("specs", "graph2", None).unwrap();
2298 assert!(engine.pipeline_configs().bindings.is_empty());
2299 }
2300
2301 #[test]
2307 fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
2308 use crate::engine::test_helpers::write_schema_files_with_default_type;
2309
2310 let tmp = TempDir::new().unwrap();
2311 let mem_dir = tmp.path().join("mem");
2312 std::fs::create_dir_all(&mem_dir).unwrap();
2313
2314 let authored_dir = tmp.path().join(".memstead").join("schemas");
2316 let manifest = r#"name: authored
2317version: 0.1.0
2318description: an authored-in-workspace test schema
2319when_to_use: tests
2320types:
2321 - doc
2322relationships:
2323 mode: strict
2324 definitions:
2325 - name: _default
2326 description: fallback
2327 default_weight: 1.0
2328community:
2329 resolution: 1.0
2330 seed: 42
2331"#;
2332 write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
2333
2334 let memstead = tmp.path().join(".memstead");
2336 std::fs::create_dir_all(&memstead).unwrap();
2337 std::fs::write(
2338 memstead.join("workspace.toml"),
2339 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2340 )
2341 .unwrap();
2342 let mount = Mount {
2343 mem: "specs".to_string(),
2344 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2345 storage: MountStorage::Folder { path: mem_dir },
2346 capability: MountCapability::Write,
2347 lifecycle: MountLifecycle::Eager,
2348 cross_linkable: true,
2349 migration_target: None,
2350 };
2351 use crate::workspace_store::WorkspaceStoreAdapter;
2352 crate::FileWorkspaceStore::new()
2353 .save_state(
2354 tmp.path(),
2355 &crate::workspace::Workspace {
2356 mounts: vec![mount],
2357 settings: crate::workspace::WorkspaceSettings::default(),
2358 },
2359 )
2360 .unwrap();
2361
2362 let engine = Engine::from_workspace_root(tmp.path())
2365 .expect("authored schema at .memstead/schemas/ must resolve at boot");
2366 assert_eq!(engine.mem_names(), vec!["specs"]);
2367 }
2368
2369 #[test]
2375 fn health_reports_authoring_drift_for_stamped_schemas_only() {
2376 use crate::engine::test_helpers::write_schema_files_with_default_type;
2377
2378 let tmp = TempDir::new().unwrap();
2379 let mem_dir = tmp.path().join("mem");
2380 std::fs::create_dir_all(&mem_dir).unwrap();
2381 let manifest = r#"name: authored
2382version: 0.1.0
2383description: an authored-in-workspace test schema
2384when_to_use: tests
2385types:
2386 - doc
2387relationships:
2388 mode: strict
2389 definitions:
2390 - name: _default
2391 description: fallback
2392 default_weight: 1.0
2393community:
2394 resolution: 1.0
2395 seed: 42
2396"#;
2397 let sealed_root = tmp.path().join(".memstead").join("schemas");
2400 write_schema_files_with_default_type(&sealed_root, "authored@0.1.0", manifest, &["doc"]);
2401 let author_root = tmp.path().join("author");
2402 write_schema_files_with_default_type(&author_root, "authored@0.1.0", manifest, &["doc"]);
2403 let authoring_dir = author_root.join("authored@0.1.0");
2404 let sealed_dir = sealed_root.join("authored@0.1.0");
2405 let stamp_path = sealed_dir.join(memstead_schema::INSTALL_PROVENANCE_FILE);
2407 std::fs::write(
2408 &stamp_path,
2409 serde_json::to_vec_pretty(&serde_json::json!({
2410 "authoring_path": authoring_dir.display().to_string(),
2411 }))
2412 .unwrap(),
2413 )
2414 .unwrap();
2415
2416 let memstead = tmp.path().join(".memstead");
2417 std::fs::write(
2418 memstead.join("workspace.toml"),
2419 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2420 )
2421 .unwrap();
2422 let mount = Mount {
2423 mem: "specs".to_string(),
2424 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2425 storage: MountStorage::Folder { path: mem_dir },
2426 capability: MountCapability::Write,
2427 lifecycle: MountLifecycle::Eager,
2428 cross_linkable: true,
2429 migration_target: None,
2430 };
2431 use crate::workspace_store::WorkspaceStoreAdapter;
2432 crate::FileWorkspaceStore::new()
2433 .save_state(
2434 tmp.path(),
2435 &crate::workspace::Workspace {
2436 mounts: vec![mount],
2437 settings: crate::workspace::WorkspaceSettings::default(),
2438 },
2439 )
2440 .unwrap();
2441 let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
2442 let drift_codes = |e: &Engine| -> Vec<String> {
2443 e.health()
2444 .warnings
2445 .iter()
2446 .filter(|w| w.code().starts_with("SCHEMA_AUTHORING_SOURCE_"))
2447 .map(|w| w.code().to_string())
2448 .collect()
2449 };
2450
2451 let sealed_before = std::fs::read(sealed_dir.join("schema.yaml")).unwrap();
2454 let author_before = std::fs::read(authoring_dir.join("schema.yaml")).unwrap();
2455 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2456 assert_eq!(
2457 std::fs::read(sealed_dir.join("schema.yaml")).unwrap(),
2458 sealed_before,
2459 "health must not touch the sealed copy"
2460 );
2461 assert_eq!(
2462 std::fs::read(authoring_dir.join("schema.yaml")).unwrap(),
2463 author_before,
2464 "health must not touch the authoring copy"
2465 );
2466
2467 std::fs::write(
2471 authoring_dir.join("schema.yaml"),
2472 format!(
2473 "# yaml-language-server: $schema=../../.memstead/meta-schemas/schema-manifest.json\n# cosmetic comment\n{manifest}"
2474 ),
2475 )
2476 .unwrap();
2477 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2478
2479 std::fs::write(
2482 authoring_dir.join("schema.yaml"),
2483 manifest.replace(
2484 "an authored-in-workspace test schema",
2485 "a semantically different description",
2486 ),
2487 )
2488 .unwrap();
2489 let warnings = engine.health().warnings;
2490 let diverged = warnings
2491 .iter()
2492 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED")
2493 .expect("semantic change must surface as DIVERGED");
2494 let d = serde_json::to_value(diverged).unwrap();
2495 assert_eq!(d["details"]["schema_ref"], "authored@0.1.0");
2496 assert_eq!(d["details"]["mems"], serde_json::json!(["specs"]));
2497
2498 std::fs::remove_dir_all(&authoring_dir).unwrap();
2500 let warnings = engine.health().warnings;
2501 let missing = warnings
2502 .iter()
2503 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_MISSING")
2504 .expect("vanished authoring package must surface as MISSING");
2505 let m = serde_json::to_value(missing).unwrap();
2506 assert_eq!(m["details"]["schema_ref"], "authored@0.1.0");
2507 assert_eq!(m["details"]["mems"], serde_json::json!(["specs"]));
2508 assert_eq!(
2509 m["details"]["stamped_path"],
2510 authoring_dir.display().to_string()
2511 );
2512 assert!(
2513 !warnings
2514 .iter()
2515 .any(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED"),
2516 "missing and diverged are distinct findings"
2517 );
2518
2519 std::fs::remove_file(&stamp_path).unwrap();
2521 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2522 }
2523
2524 #[test]
2529 fn full_refresh_is_additive_and_reports_skipped_removals() {
2530 use crate::engine::test_helpers::write_schema_files_with_default_type;
2531 use crate::workspace_store::WorkspaceStoreAdapter;
2532
2533 let tmp = TempDir::new().unwrap();
2534 let root = tmp.path();
2535 let mem_a = root.join("mem-a");
2536 std::fs::create_dir_all(&mem_a).unwrap();
2537 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2538 std::fs::write(
2539 root.join(".memstead").join("workspace.toml"),
2540 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2541 )
2542 .unwrap();
2543 let mount = |mem: &str, dir: &Path, schema: &str, version: semver::Version| Mount {
2544 mem: mem.to_string(),
2545 schema: Some(SchemaRef::new(schema, version)),
2546 storage: MountStorage::Folder {
2547 path: dir.to_path_buf(),
2548 },
2549 capability: MountCapability::Write,
2550 lifecycle: MountLifecycle::Eager,
2551 cross_linkable: true,
2552 migration_target: None,
2553 };
2554 let save = |mounts: Vec<Mount>| {
2555 crate::FileWorkspaceStore::new()
2556 .save_state(
2557 root,
2558 &crate::workspace::Workspace {
2559 mounts,
2560 settings: crate::workspace::WorkspaceSettings::default(),
2561 },
2562 )
2563 .unwrap();
2564 };
2565 save(vec![mount(
2566 "specs",
2567 &mem_a,
2568 "default",
2569 semver::Version::new(1, 0, 0),
2570 )]);
2571 let mut engine = Engine::from_workspace_root(root).expect("workspace boots");
2572 assert_eq!(engine.mem_names(), vec!["specs"]);
2573
2574 let manifest = r#"name: authored
2577version: 0.1.0
2578description: an out-of-band installed schema
2579when_to_use: tests
2580types:
2581 - doc
2582relationships:
2583 mode: strict
2584 definitions:
2585 - name: _default
2586 description: fallback
2587 default_weight: 1.0
2588community:
2589 resolution: 1.0
2590 seed: 42
2591"#;
2592 write_schema_files_with_default_type(
2593 &root.join(".memstead").join("schemas"),
2594 "authored@0.1.0",
2595 manifest,
2596 &["doc"],
2597 );
2598 let mem_b = root.join("mem-b");
2599 std::fs::create_dir_all(&mem_b).unwrap();
2600 save(vec![
2601 mount("specs", &mem_a, "default", semver::Version::new(1, 0, 0)),
2602 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2603 ]);
2604
2605 let (actor, client) = cli_actor();
2608 let mut pre = crate::engine::test_helpers::empty_create_args("notes", "Too Early");
2609 pre.entity_type = "doc".to_string();
2610 pre.sections = indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2611 let err = engine
2612 .create_entity(pre.clone(), actor, Some(&client), None)
2613 .unwrap_err();
2614 assert_eq!(err.code(), "UNKNOWN_MEM", "{err:?}");
2615 assert!(
2616 !engine
2617 .workspace_schemas()
2618 .iter()
2619 .any(|s| s.id().0 == "authored"),
2620 "schema catalogue is fixed pre-refresh"
2621 );
2622
2623 let report = engine.full_refresh();
2625 assert_eq!(report.schemas_added, vec!["authored@0.1.0".to_string()]);
2626 assert_eq!(report.mems_mounted, vec!["notes".to_string()]);
2627 assert!(report.schema_removals_skipped.is_empty(), "{report:?}");
2628 assert!(report.mem_removals_skipped.is_empty(), "{report:?}");
2629 assert!(report.failures.is_empty(), "{report:?}");
2630 engine
2631 .create_entity(pre, actor, Some(&client), None)
2632 .expect("newly mounted mem accepts writes after the refresh");
2633
2634 std::fs::remove_dir_all(
2637 root.join(".memstead")
2638 .join("schemas")
2639 .join("authored@0.1.0"),
2640 )
2641 .unwrap();
2642 save(vec![mount(
2643 "notes",
2644 &mem_b,
2645 "authored",
2646 semver::Version::new(0, 1, 0),
2647 )]);
2648 let report = engine.full_refresh();
2649 assert_eq!(report.mem_removals_skipped, vec!["specs".to_string()]);
2650 assert_eq!(
2651 report.schema_removals_skipped,
2652 vec!["authored@0.1.0".to_string()]
2653 );
2654 assert!(report.schemas_added.is_empty());
2655 assert!(report.mems_mounted.is_empty());
2656 engine
2659 .create_entity(
2660 crate::engine::test_helpers::empty_create_args("specs", "Still Here"),
2661 actor,
2662 Some(&client),
2663 None,
2664 )
2665 .expect("skipped-removal mem stays writable");
2666 let mut into_notes =
2667 crate::engine::test_helpers::empty_create_args("notes", "Still Resolvable");
2668 into_notes.entity_type = "doc".to_string();
2669 into_notes.sections =
2670 indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2671 engine
2672 .create_entity(into_notes, actor, Some(&client), None)
2673 .expect("removed-from-source schema stays resolvable");
2674
2675 let broken = root.join("broken-mem");
2678 std::fs::write(&broken, b"not a directory").unwrap();
2679 save(vec![
2680 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2681 mount("broken", &broken, "default", semver::Version::new(1, 0, 0)),
2682 ]);
2683 let report = engine.full_refresh();
2684 assert!(
2685 report.failures.iter().any(|f| f.item == "mount:broken"),
2686 "failed mount must be reported per-item: {report:?}"
2687 );
2688 assert!(
2689 !report.mems_mounted.contains(&"broken".to_string()),
2690 "a failed mount never surfaces as newly available"
2691 );
2692 assert!(
2693 engine
2694 .get_entity(&crate::EntityId::new("broken", "anything"))
2695 .is_none()
2696 && engine.mem_names().contains(&"notes"),
2697 "other mounts unaffected"
2698 );
2699 }
2700
2701 #[test]
2702 fn from_workspace_root_propagates_mem_management_settings() {
2703 let tmp = TempDir::new().unwrap();
2708 let mem_dir = tmp.path().join("mem");
2709 std::fs::create_dir_all(&mem_dir).unwrap();
2710
2711 let memstead = tmp.path().join(".memstead");
2712 std::fs::create_dir_all(&memstead).unwrap();
2713 std::fs::write(
2714 memstead.join("workspace.toml"),
2715 r#"format = "memstead-git-branch-2"
2716
2717[persistence_adapter]
2718name = "file-two-layer"
2719
2720[[mem_management.create]]
2721pattern = "exec-*"
2722schemas = ["default@1.0.0"]
2723
2724[[mem_management.delete]]
2725pattern = "exec-*"
2726"#,
2727 )
2728 .unwrap();
2729 use crate::workspace_store::WorkspaceStoreAdapter;
2730 let store = crate::FileWorkspaceStore::new();
2731 store
2732 .save_state(
2733 tmp.path(),
2734 &crate::workspace::Workspace {
2735 mounts: vec![folder_mount("specs", mem_dir)],
2736 settings: crate::workspace::WorkspaceSettings::default(),
2737 },
2738 )
2739 .unwrap();
2740
2741 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2742 let s = engine.settings();
2743 assert_eq!(s.mem_create_rules.len(), 1);
2744 assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
2745 assert_eq!(
2746 s.mem_create_rules[0].schemas,
2747 vec!["default@1.0.0".to_string()]
2748 );
2749 assert_eq!(s.mem_delete_rules.len(), 1);
2750 assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
2751 }
2752
2753 #[test]
2761 fn from_mounts_quarantines_unknown_schema_pin() {
2762 let tmp = TempDir::new().unwrap();
2763 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
2764 let mount = Mount {
2765 mem: "specs".to_string(),
2766 schema: Some(SchemaRef::new(
2767 "totally-not-a-schema",
2768 semver::Version::new(1, 0, 0),
2769 )),
2770 storage: MountStorage::Folder {
2771 path: tmp.path().to_path_buf(),
2772 },
2773 capability: MountCapability::Write,
2774 lifecycle: MountLifecycle::Eager,
2775 cross_linkable: true,
2776 migration_target: None,
2777 };
2778 let engine = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2779 .expect("a broken mem quarantines, never fails the workspace");
2780
2781 let roster = engine.quarantined_mems();
2782 assert_eq!(roster.len(), 1);
2783 assert_eq!(roster[0].mount.mem, "specs");
2784 assert_eq!(roster[0].reason_code, "SCHEMA_NOT_FOUND");
2785 assert!(
2786 roster[0].reason_message.contains("totally-not-a-schema"),
2787 "reason carries the failing pin: {}",
2788 roster[0].reason_message
2789 );
2790 assert!(engine.mounts().iter().all(|m| m.mem != "specs"));
2792 let err = engine.unknown_mem_error("specs");
2795 assert_eq!(err.code(), "MEM_QUARANTINED");
2796 assert!(
2797 err.to_string().contains("SCHEMA_NOT_FOUND"),
2798 "quarantine refusal carries the underlying reason: {err}"
2799 );
2800 let health = engine.health();
2802 assert_eq!(health.quarantined.len(), 1);
2803 assert_eq!(health.quarantined[0].reason_code, "SCHEMA_NOT_FOUND");
2804 }
2805
2806 #[test]
2812 fn reload_returns_repaired_mem_from_quarantine() {
2813 let tmp = TempDir::new().unwrap();
2814 let dir = tmp.path().to_path_buf();
2815 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2816 let config_path = dir.join(".memstead").join("config.json");
2817 std::fs::write(&config_path, r#"{ "schema": "ghost@1.0.0" }"#).unwrap();
2818 let writer = FilesystemMemWriter::new(dir.clone());
2819 let mount = Mount {
2820 mem: "specs".to_string(),
2821 schema: None,
2822 storage: MountStorage::Folder { path: dir },
2823 capability: MountCapability::Write,
2824 lifecycle: MountLifecycle::Eager,
2825 cross_linkable: true,
2826 migration_target: None,
2827 };
2828 let mut engine =
2829 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2830 assert_eq!(engine.quarantined_mems().len(), 1);
2831 let err = engine.reload_one_mem("specs").unwrap_err();
2834 assert_eq!(err.code(), "MEM_QUARANTINED");
2835 assert_eq!(engine.quarantined_mems().len(), 1);
2836
2837 std::fs::write(&config_path, r#"{ "schema": "default@1.0.0" }"#).unwrap();
2840 engine
2841 .reload_one_mem("specs")
2842 .expect("repaired mem re-attaches on reload");
2843 assert!(
2844 engine.quarantined_mems().is_empty(),
2845 "roster entry disappears after re-attach"
2846 );
2847 let mut sections = indexmap::IndexMap::new();
2849 sections.insert("identity".to_string(), "back".to_string());
2850 sections.insert("purpose".to_string(), "post-repair service".to_string());
2851 engine
2852 .create_entity_with_ctx(
2853 crate::engine::CreateEntityArgs {
2854 anchors: Vec::new(),
2855 mem: "specs".to_string(),
2856 title: "Back".to_string(),
2857 entity_type: "spec".to_string(),
2858 sections,
2859 metadata: indexmap::IndexMap::new(),
2860 relations: Vec::new(),
2861 dry_run: false,
2862 },
2863 &crate::vcs::CommitContext::internal(),
2864 )
2865 .expect("reattached mem serves writes");
2866 }
2867
2868 #[test]
2873 fn cross_mem_link_into_quarantined_mem_degrades_without_cascade() {
2874 let tmp = TempDir::new().unwrap();
2875 let healthy_dir = tmp.path().join("healthy");
2876 std::fs::create_dir_all(&healthy_dir).unwrap();
2877 std::fs::write(
2878 healthy_dir.join("linker.md"),
2879 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n\
2880 # Linker\n\n## Identity\n\nsee [[badpin:target]] for detail.\n",
2881 )
2882 .unwrap();
2883 let badpin_dir = tmp.path().join("badpin");
2884 std::fs::create_dir_all(&badpin_dir).unwrap();
2885 let mount = |mem: &str, dir: std::path::PathBuf, pin: &str| {
2886 (
2887 Mount {
2888 mem: mem.to_string(),
2889 schema: Some(SchemaRef::new(pin, semver::Version::new(1, 0, 0))),
2890 storage: MountStorage::Folder { path: dir.clone() },
2891 capability: MountCapability::Write,
2892 lifecycle: MountLifecycle::Eager,
2893 cross_linkable: true,
2894 migration_target: None,
2895 },
2896 Box::new(FilesystemMemWriter::new(dir)) as Box<dyn MemBackend>,
2897 )
2898 };
2899 let engine = Engine::from_mounts(vec![
2900 mount("healthy", healthy_dir, "default"),
2901 mount("badpin", badpin_dir, "ghost"),
2902 ])
2903 .expect("boot survives the cross-mem link into the quarantined mem");
2904 assert_eq!(engine.quarantined_mems().len(), 1);
2905 let linker = engine
2909 .get_entity(&crate::EntityId::new("healthy", "linker"))
2910 .expect("linking entity loads");
2911 assert_eq!(linker.entity_type, "spec");
2912 assert!(
2913 engine
2914 .get_entity(&crate::EntityId::new("badpin", "target"))
2915 .is_none_or(|e| e.stub),
2916 "the quarantined-side target is at most a stub, never real data"
2917 );
2918 }
2919
2920 #[test]
2926 fn old_key_authored_schema_quarantines_pinning_mem_never_workspace() {
2927 let tmp = TempDir::new().unwrap();
2928 let root = tmp.path();
2929 std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
2931 std::fs::write(
2932 root.join(".memstead").join("workspace.toml"),
2933 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2934 )
2935 .unwrap();
2936 for m in ["healthy", "oldkey"] {
2937 std::fs::create_dir_all(root.join(m)).unwrap();
2938 }
2939 std::fs::write(
2940 root.join(".memstead").join("state").join("mounts.json"),
2941 r#"{ "format": "memstead-mounts-3", "mounts": [
2942 { "mem": "healthy", "schema": "default@1.1.0", "storage": { "type": "folder", "path": "healthy" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true },
2943 { "mem": "oldkey", "schema": "fieldschema@0.1.0", "storage": { "type": "folder", "path": "oldkey" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
2944 ] }"#,
2945 )
2946 .unwrap();
2947 let pkg = root
2949 .join(".memstead")
2950 .join("schemas")
2951 .join("fieldschema@0.1.0");
2952 std::fs::create_dir_all(pkg.join("types")).unwrap();
2953 std::fs::write(
2954 pkg.join("schema.yaml"),
2955 "name: fieldschema\nversion: 0.1.0\ndescription: field schema\nwhen_to_use: tests\ntypes:\n - thing\nrelationships:\n mode: strict\n definitions:\n - name: PART_OF\n description: h\n default_weight: 1.0\n acyclic: true\n - name: _default\n description: f\n default_weight: 1.0\ncommunity:\n resolution: 1.0\n seed: 42\n",
2956 )
2957 .unwrap();
2958 std::fs::write(
2959 pkg.join("types").join("thing.yaml"),
2960 "name: thing\ndescription: t\nwhen_to_use: h\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\npropagating_relationships: []\nupdatable_fields:\n - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n",
2961 )
2962 .unwrap();
2963
2964 let engine = Engine::from_workspace_root(root)
2965 .expect("the old-key schema quarantines its mem, never the workspace");
2966 assert!(engine.mounts().iter().any(|m| m.mem == "healthy"));
2967 let q = engine
2968 .quarantine_reason("oldkey")
2969 .expect("oldkey mem is quarantined");
2970 assert_eq!(q.reason_code, "SCHEMA_LOAD_FAILED");
2971 assert!(
2972 q.reason_message.contains("no_self_loop_relationships"),
2973 "quarantine reason is the rename error naming the new key: {}",
2974 q.reason_message
2975 );
2976 }
2977
2978 #[test]
2983 fn ingest_0_3_entries_are_leaves_prior_version_unchanged() {
2984 let entry_md = "---\ntype: coverage_gap\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nstatus: open\n---\n# Gap\n\n## Area\n\nan uncovered area.\n";
2985 let boot = |pin: &str| {
2986 let tmp = TempDir::new().unwrap();
2987 let dir = tmp.path().to_path_buf();
2988 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2989 std::fs::write(
2990 dir.join(".memstead").join("config.json"),
2991 format!("{{ \"schema\": \"{pin}\" }}"),
2992 )
2993 .unwrap();
2994 std::fs::write(dir.join("gap.md"), entry_md).unwrap();
2995 let writer = FilesystemMemWriter::new(dir.clone());
2996 let mount = Mount {
2997 mem: "proc".to_string(),
2998 schema: None,
2999 storage: MountStorage::Folder { path: dir },
3000 capability: MountCapability::Write,
3001 lifecycle: MountLifecycle::Eager,
3002 cross_linkable: true,
3003 migration_target: None,
3004 };
3005 let engine =
3006 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
3007 .unwrap();
3008 (engine.health(), tmp)
3009 };
3010
3011 let (health_new, _t1) = boot("ingest@0.3.0");
3012 assert_eq!(
3013 health_new.orphan_count, 0,
3014 "0.3.0 entry types are leaves — zero false orphans"
3015 );
3016 assert_eq!(
3017 health_new
3018 .leaf_entities_by_type
3019 .get("ingest@0.3.0:coverage_gap"),
3020 Some(&1),
3021 "the population stays visible: {:?}",
3022 health_new.leaf_entities_by_type
3023 );
3024
3025 let (health_old, _t2) = boot("ingest@0.2.0");
3026 assert_eq!(
3027 health_old.orphan_count, 1,
3028 "the prior version's behaviour is unchanged"
3029 );
3030 assert!(health_old.leaf_entities_by_type.is_empty());
3031 }
3032
3033 #[test]
3039 fn broken_mem_quarantines_while_healthy_siblings_serve() {
3040 let tmp = TempDir::new().unwrap();
3041 let make_mount = |mem: &str, pin: Option<SchemaRef>| {
3042 let dir = tmp.path().join(mem);
3043 std::fs::create_dir_all(&dir).unwrap();
3044 let writer = FilesystemMemWriter::new(dir.clone());
3045 (
3046 Mount {
3047 mem: mem.to_string(),
3048 schema: pin,
3049 storage: MountStorage::Folder { path: dir },
3050 capability: MountCapability::Write,
3051 lifecycle: MountLifecycle::Eager,
3052 cross_linkable: true,
3053 migration_target: None,
3054 },
3055 Box::new(writer) as Box<dyn MemBackend>,
3056 )
3057 };
3058 let healthy = make_mount(
3059 "healthy",
3060 Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3061 );
3062 let bad_pin = make_mount(
3063 "badpin",
3064 Some(SchemaRef::new("ghost", semver::Version::new(1, 0, 0))),
3065 );
3066 let missing_pin = make_mount("nopin", None);
3067 let bad_io_path = tmp.path().join("badio");
3070 std::fs::write(&bad_io_path, "not a directory").unwrap();
3071 let bad_io = (
3072 Mount {
3073 mem: "badio".to_string(),
3074 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3075 storage: MountStorage::Folder {
3076 path: bad_io_path.clone(),
3077 },
3078 capability: MountCapability::Write,
3079 lifecycle: MountLifecycle::Eager,
3080 cross_linkable: true,
3081 migration_target: None,
3082 },
3083 Box::new(FilesystemMemWriter::new(bad_io_path)) as Box<dyn MemBackend>,
3084 );
3085 let mut engine = Engine::from_mounts(vec![healthy, bad_pin, missing_pin, bad_io])
3086 .expect("mixed workspace boots");
3087
3088 let codes: std::collections::HashMap<String, String> = engine
3090 .quarantined_mems()
3091 .iter()
3092 .map(|q| (q.mount.mem.clone(), q.reason_code.clone()))
3093 .collect();
3094 assert_eq!(
3095 codes.get("badpin").map(String::as_str),
3096 Some("SCHEMA_NOT_FOUND")
3097 );
3098 assert_eq!(
3099 codes.get("nopin").map(String::as_str),
3100 Some("MEM_CONFIG_INCOMPLETE")
3101 );
3102 assert!(
3103 codes.contains_key("badio"),
3104 "backend read failure quarantines too: {codes:?}"
3105 );
3106
3107 let mut sections = indexmap::IndexMap::new();
3109 sections.insert("identity".to_string(), "alive".to_string());
3110 sections.insert("purpose".to_string(), "proof of service".to_string());
3111 let created = engine
3112 .create_entity_with_ctx(
3113 crate::engine::CreateEntityArgs {
3114 anchors: Vec::new(),
3115 mem: "healthy".to_string(),
3116 title: "Alive".to_string(),
3117 entity_type: "spec".to_string(),
3118 sections,
3119 metadata: indexmap::IndexMap::new(),
3120 relations: Vec::new(),
3121 dry_run: false,
3122 },
3123 &crate::vcs::CommitContext::internal(),
3124 )
3125 .expect("healthy mem serves writes");
3126 assert_eq!(created.id.to_string(), "healthy--alive");
3127
3128 let mut sections = indexmap::IndexMap::new();
3130 sections.insert("identity".to_string(), "x".to_string());
3131 let err = engine
3132 .create_entity_with_ctx(
3133 crate::engine::CreateEntityArgs {
3134 anchors: Vec::new(),
3135 mem: "badpin".to_string(),
3136 title: "Nope".to_string(),
3137 entity_type: "spec".to_string(),
3138 sections,
3139 metadata: indexmap::IndexMap::new(),
3140 relations: Vec::new(),
3141 dry_run: false,
3142 },
3143 &crate::vcs::CommitContext::internal(),
3144 )
3145 .unwrap_err();
3146 assert_eq!(err.code(), "MEM_QUARANTINED");
3147 }
3148
3149 #[test]
3156 fn mem_config_schema_is_authoritative_over_mount_pin() {
3157 let tmp = TempDir::new().unwrap();
3158 let mem_dir = tmp.path().to_path_buf();
3159 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3160 std::fs::write(
3161 mem_dir.join(".memstead").join("config.json"),
3162 r#"{"schema":"software@0.1.0"}"#,
3163 )
3164 .unwrap();
3165 let writer = FilesystemMemWriter::new(mem_dir.clone());
3166 let mount = Mount {
3167 mem: "specs".to_string(),
3168 schema: Some(SchemaRef::new(
3169 "totally-not-a-schema",
3170 semver::Version::new(9, 9, 9),
3171 )),
3172 storage: MountStorage::Folder { path: mem_dir },
3173 capability: MountCapability::Write,
3174 lifecycle: MountLifecycle::Eager,
3175 cross_linkable: true,
3176 migration_target: None,
3177 };
3178 let engine = Engine::from_mounts(vec![(
3179 mount,
3180 Box::new(writer) as Box<dyn MemBackend>,
3181 )])
3182 .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
3183
3184 let mismatch = engine
3185 .load_warnings()
3186 .iter()
3187 .find_map(|w| match w {
3188 WarningHint::SchemaPinMismatch {
3189 mem,
3190 config_pin,
3191 mount_pin,
3192 } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
3193 _ => None,
3194 })
3195 .expect("SchemaPinMismatch warning must surface naming both pins");
3196 assert_eq!(mismatch.0, "specs");
3197 assert_eq!(mismatch.1, "software@0.1.0");
3198 assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
3199 }
3200
3201 #[test]
3202 fn from_workspace_root_quarantines_git_branch_mount_on_lean() {
3203 let tmp = TempDir::new().unwrap();
3204 let memstead = tmp.path().join(".memstead");
3205 std::fs::create_dir_all(&memstead).unwrap();
3206 std::fs::write(
3207 memstead.join("workspace.toml"),
3208 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3209 )
3210 .unwrap();
3211 let state_dir = memstead.join("state");
3214 std::fs::create_dir_all(&state_dir).unwrap();
3215 std::fs::write(
3216 state_dir.join("mounts.json"),
3217 r#"{
3218 "format": "memstead-mounts-3",
3219 "mounts": [
3220 {
3221 "mem": "specs",
3222 "schema": "default@1.0.0",
3223 "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
3224 "capability": "write",
3225 "lifecycle": "eager",
3226 "cross_linkable": true
3227 }
3228 ]
3229 }"#,
3230 )
3231 .unwrap();
3232 let engine = Engine::from_workspace_root(tmp.path())
3240 .expect("lean boot quarantines the git-branch mount, never fails the workspace");
3241 let roster = engine.quarantined_mems();
3242 assert_eq!(roster.len(), 1);
3243 assert_eq!(roster[0].mount.mem, "specs");
3244 assert_eq!(roster[0].reason_code, "UNSUPPORTED_WORKSPACE_SHAPE");
3245 assert_eq!(engine.unknown_mem_error("specs").code(), "MEM_QUARANTINED");
3246 }
3247
3248 #[test]
3249 fn from_workspace_root_roots_standalone_folder_mem() {
3250 let tmp = TempDir::new().unwrap();
3254 let root = tmp.path();
3255 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3256 std::fs::write(
3257 root.join(".memstead").join("config.json"),
3258 r#"{"schema":"default@1.0.0"}"#,
3259 )
3260 .unwrap();
3261 std::fs::write(
3263 root.join("hello.md"),
3264 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3265 )
3266 .unwrap();
3267
3268 let engine = Engine::from_workspace_root(root)
3269 .expect("a bare folder mem must root as a one-mount workspace");
3270 assert_eq!(engine.status().mem_count, 1, "exactly one mount");
3271 assert!(
3272 engine.status().entity_count >= 1,
3273 "the standalone mem's entity must load"
3274 );
3275 }
3276
3277 #[test]
3278 fn booting_a_workspace_writes_nothing_into_it() {
3279 let tmp = TempDir::new().unwrap();
3288 let root = tmp.path();
3289 std::fs::create_dir_all(root.join(".memstead").join("meta-schemas")).unwrap();
3290 std::fs::write(
3291 root.join(".memstead").join("config.json"),
3292 r#"{"schema":"default@1.0.0"}"#,
3293 )
3294 .unwrap();
3295 let stale = root
3298 .join(".memstead")
3299 .join("meta-schemas")
3300 .join("type-definition.schema.json");
3301 std::fs::write(&stale, "{\"title\":\"stale, from an older binary\"}").unwrap();
3302 std::fs::write(
3303 root.join("hello.md"),
3304 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3305 )
3306 .unwrap();
3307
3308 fn snapshot(dir: &Path) -> std::collections::BTreeMap<std::path::PathBuf, Vec<u8>> {
3309 let mut out = std::collections::BTreeMap::new();
3310 let mut stack = vec![dir.to_path_buf()];
3311 while let Some(d) = stack.pop() {
3312 for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
3313 let p = entry.path();
3314 if p.is_dir() {
3315 stack.push(p);
3316 } else if let Ok(bytes) = std::fs::read(&p) {
3317 out.insert(p, bytes);
3318 }
3319 }
3320 }
3321 out
3322 }
3323
3324 let before = snapshot(root);
3325 let engine = Engine::from_workspace_root(root).expect("the fixture must boot");
3326 assert_eq!(engine.status().mem_count, 1, "fixture sanity: one mount");
3327 let after = snapshot(root);
3328
3329 assert_eq!(
3330 before.keys().collect::<Vec<_>>(),
3331 after.keys().collect::<Vec<_>>(),
3332 "boot created or removed a file in the workspace it read"
3333 );
3334 for (path, bytes) in &before {
3335 assert_eq!(
3336 bytes,
3337 after.get(path).unwrap(),
3338 "boot rewrote {} in the workspace it read",
3339 path.display()
3340 );
3341 }
3342 }
3343
3344 #[test]
3345 fn from_workspace_root_still_rejects_truly_empty_dir() {
3346 let tmp = TempDir::new().unwrap();
3350 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
3351 assert!(
3352 matches!(err, crate::BootError::NotInitialised(_)),
3353 "got {err:?}"
3354 );
3355 }
3356
3357 #[test]
3364 fn boot_keeps_loading_violating_schema_and_surfaces_health_finding() {
3365 let tmp = TempDir::new().unwrap();
3366 let schemas_dir = tmp.path().join("schemas");
3367 let pkg = schemas_dir.join("debate");
3368 std::fs::create_dir_all(pkg.join("types")).unwrap();
3369 std::fs::write(
3370 pkg.join("schema.yaml"),
3371 r#"name: debate
3372version: 0.1.0
3373description: sealed-violator fixture
3374when_to_use: tests
3375types:
3376 - question
3377relationships:
3378 mode: strict
3379 definitions:
3380 - name: PART_OF
3381 description: hier
3382 default_weight: 3.0
3383 - name: _default
3384 description: fallback
3385 default_weight: 1.0
3386community:
3387 resolution: 1.0
3388 seed: 42
3389"#,
3390 )
3391 .unwrap();
3392 std::fs::write(
3393 pkg.join("types").join("question.yaml"),
3394 r#"name: question
3395description: t
3396when_to_use: tests
3397sections:
3398 - key: answers
3399 heading: Answers argued
3400 required: true
3401 search_weight: 10.0
3402 write_rules: []
3403 - key: notes
3404 heading: Notes
3405 required: false
3406 search_weight: 3.0
3407 catch_all: true
3408 write_rules: []
3409metadata_fields: []
3410title_weight: 100.0
3411text_fields:
3412 - answers
3413 - notes
3414hierarchy_relationship: PART_OF
3415no_self_loop_relationships: []
3416updatable_fields:
3417 - title
3418 - answers
3419 - notes
3420health_required_fields:
3421 - answers
3422staleness_threshold_days: 90
3423write_rules: []
3424"#,
3425 )
3426 .unwrap();
3427
3428 let mem_dir = tmp.path().join("mem");
3429 std::fs::create_dir_all(&mem_dir).unwrap();
3430 std::fs::write(
3431 mem_dir.join("q.md"),
3432 "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n",
3433 )
3434 .unwrap();
3435
3436 let writer = FilesystemMemWriter::new(mem_dir.clone());
3437 let mount = Mount {
3438 mem: "debate-mem".to_string(),
3439 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3440 storage: MountStorage::Folder { path: mem_dir },
3441 capability: MountCapability::Write,
3442 lifecycle: MountLifecycle::Eager,
3443 cross_linkable: true,
3444 migration_target: None,
3445 };
3446 let engine = Engine::from_mounts_with_schemas_dir(
3447 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3448 Some(&schemas_dir),
3449 )
3450 .expect("a violating sealed schema must keep loading, never refuse boot");
3451
3452 assert!(
3454 engine.status().entity_count >= 1,
3455 "entities load despite the schema violation"
3456 );
3457
3458 let hits: Vec<_> = engine
3460 .load_warnings()
3461 .iter()
3462 .filter_map(|w| match w {
3463 WarningHint::SchemaHeadingRoundtripViolation {
3464 mem,
3465 schema_ref,
3466 violations,
3467 } => Some((mem.clone(), schema_ref.clone(), violations.clone())),
3468 _ => None,
3469 })
3470 .collect();
3471 assert_eq!(
3472 hits.len(),
3473 1,
3474 "exactly one schema-level finding; all warnings = {:?}",
3475 engine.load_warnings()
3476 );
3477 let (mem, schema_ref, violations) = &hits[0];
3478 assert_eq!(mem, "debate-mem");
3479 assert_eq!(schema_ref, "debate@0.1.0");
3480 assert_eq!(violations.len(), 1);
3481 assert_eq!(violations[0].type_name, "question");
3482 assert_eq!(violations[0].key, "answers");
3483 assert_eq!(violations[0].heading, "Answers argued");
3484 assert_eq!(violations[0].derived_key, "answers_argued");
3485 }
3486
3487 #[test]
3495 fn sealed_violator_mem_still_serves_writes() {
3496 let tmp = TempDir::new().unwrap();
3498 let schemas_dir = tmp.path().join("schemas");
3499 let pkg = schemas_dir.join("debate");
3500 std::fs::create_dir_all(pkg.join("types")).unwrap();
3501 std::fs::write(
3502 pkg.join("schema.yaml"),
3503 r#"name: debate
3504version: 0.1.0
3505description: sealed-violator fixture
3506when_to_use: tests
3507types:
3508 - question
3509relationships:
3510 mode: strict
3511 definitions:
3512 - name: PART_OF
3513 description: hier
3514 default_weight: 3.0
3515 - name: _default
3516 description: fallback
3517 default_weight: 1.0
3518community:
3519 resolution: 1.0
3520 seed: 42
3521"#,
3522 )
3523 .unwrap();
3524 std::fs::write(
3525 pkg.join("types").join("question.yaml"),
3526 r#"name: question
3527description: t
3528when_to_use: tests
3529sections:
3530 - key: answers
3531 heading: Answers argued
3532 required: true
3533 search_weight: 10.0
3534 write_rules: []
3535 - key: notes
3536 heading: Notes
3537 required: false
3538 search_weight: 3.0
3539 catch_all: true
3540 write_rules: []
3541metadata_fields: []
3542title_weight: 100.0
3543text_fields:
3544 - answers
3545 - notes
3546hierarchy_relationship: PART_OF
3547no_self_loop_relationships: []
3548updatable_fields:
3549 - title
3550 - answers
3551 - notes
3552health_required_fields:
3553 - answers
3554staleness_threshold_days: 90
3555write_rules: []
3556"#,
3557 )
3558 .unwrap();
3559
3560 let mem_dir = tmp.path().join("mem");
3561 std::fs::create_dir_all(&mem_dir).unwrap();
3562 std::fs::write(
3566 mem_dir.join("q.md"),
3567 "---\ntype: question\n---\n# Q\n\n## Answers\n\nTwo answers.\n",
3568 )
3569 .unwrap();
3570
3571 let writer = FilesystemMemWriter::new(mem_dir.clone());
3572 let mount = Mount {
3573 mem: "debate-mem".to_string(),
3574 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3575 storage: MountStorage::Folder { path: mem_dir },
3576 capability: MountCapability::Write,
3577 lifecycle: MountLifecycle::Eager,
3578 cross_linkable: true,
3579 migration_target: None,
3580 };
3581 let mut engine = Engine::from_mounts_with_schemas_dir(
3582 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3583 Some(&schemas_dir),
3584 )
3585 .expect("a violating sealed schema must keep loading");
3586
3587 let id = crate::EntityId::new("debate-mem", "q");
3588 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3589 let mut sections = indexmap::IndexMap::new();
3590 sections.insert("answers".to_string(), "Updated answers body.".to_string());
3591 let outcome = engine
3592 .update_entity(
3593 crate::engine::UpdateEntityArgs {
3594 anchors: Vec::new(),
3595 anchors_unset: Vec::new(),
3596 id: id.clone(),
3597 expected_hash: Some(hash),
3598 sections,
3599 append_sections: indexmap::IndexMap::new(),
3600 patch_sections: indexmap::IndexMap::new(),
3601 metadata: indexmap::IndexMap::new(),
3602 metadata_unset: Vec::new(),
3603 declare_relations: Vec::new(),
3604 dry_run: false,
3605 relations_unset: Vec::new(),
3606 },
3607 crate::vcs::Actor::Cli,
3608 None,
3609 None,
3610 )
3611 .expect("a write against a sealed-violator mem must NOT be refused");
3612 assert!(!outcome.write_id.is_empty(), "the write commits");
3613 assert!(
3614 outcome
3615 .warnings
3616 .iter()
3617 .any(|w| w.code() == "SECTION_HEADING_DIVERGENCE"),
3618 "the write-path divergence warning fires where its condition holds: {:?}",
3619 outcome.warnings
3620 );
3621
3622 assert!(
3624 engine.load_warnings().iter().any(|w| matches!(
3625 w,
3626 WarningHint::SchemaHeadingRoundtripViolation { mem, .. } if mem == "debate-mem"
3627 )),
3628 "the health finding persists across writes"
3629 );
3630 let entity = engine.get_entity(&id).unwrap();
3639 assert!(
3640 entity
3641 .sections
3642 .values()
3643 .any(|s| s.contains("Updated answers body.")),
3644 "written content survives the round-trip (in the catch-all): {:?}",
3645 entity.sections
3646 );
3647 }
3648
3649 #[test]
3656 fn search_mem_filter_gates_against_visible_roster() {
3657 use crate::vcs::Actor;
3658 use crate::workspace_store::WorkspaceStoreAdapter;
3659 use indexmap::IndexMap;
3660
3661 let tmp = TempDir::new().unwrap();
3662 let mem_dir = tmp.path().join("mem");
3663 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3664 std::fs::write(
3665 mem_dir.join(".memstead").join("config.json"),
3666 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3667 )
3668 .unwrap();
3669 let memstead = tmp.path().join(".memstead");
3670 std::fs::create_dir_all(&memstead).unwrap();
3671 std::fs::write(
3672 memstead.join("workspace.toml"),
3673 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3674 )
3675 .unwrap();
3676 crate::FileWorkspaceStore::new()
3677 .save_state(
3678 tmp.path(),
3679 &crate::workspace::Workspace {
3680 mounts: vec![folder_mount("specs", mem_dir.clone())],
3681 settings: crate::workspace::WorkspaceSettings::default(),
3682 },
3683 )
3684 .unwrap();
3685 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
3686 let mut sections = IndexMap::new();
3687 sections.insert(
3688 "identity".to_string(),
3689 "Zebra searching fixture.".to_string(),
3690 );
3691 sections.insert("purpose".to_string(), "Search gate test.".to_string());
3692 engine
3693 .create_entity(
3694 crate::CreateEntityArgs {
3695 mem: "specs".to_string(),
3696 title: "Zebra".to_string(),
3697 entity_type: "spec".to_string(),
3698 sections,
3699 metadata: IndexMap::new(),
3700 relations: Vec::new(),
3701 anchors: Vec::new(),
3702 dry_run: false,
3703 },
3704 Actor::Agent,
3705 None,
3706 None,
3707 )
3708 .unwrap();
3709
3710 let scope = |mem: Option<&str>, term: &str| crate::ops::SearchScope {
3711 query: Some(crate::ops::Query {
3712 any: vec![term.to_string()],
3713 ..Default::default()
3714 }),
3715 mem: mem.map(str::to_string),
3716 ..Default::default()
3717 };
3718
3719 let err = engine
3721 .search(&scope(Some("no-such-mem"), "zebra"))
3722 .expect_err("a nonexistent mem filter must refuse");
3723 assert_eq!(err.code(), "UNKNOWN_MEM", "got {err:?}");
3724
3725 let hit = engine.search(&scope(Some("specs"), "zebra")).unwrap();
3727 assert!(hit.total >= 1, "the fixture entity matches: {hit:?}");
3728
3729 let none = engine
3732 .search(&scope(Some("specs"), "quixotic-nonword"))
3733 .unwrap();
3734 assert_eq!(none.total, 0, "{none:?}");
3735 }
3736
3737 fn lazy_folder_mount(mem: &str, path: std::path::PathBuf) -> Mount {
3742 Mount {
3743 lifecycle: MountLifecycle::Lazy,
3744 ..folder_mount(mem, path)
3745 }
3746 }
3747
3748 fn write_spec(dir: &Path, slug: &str, title: &str, extra: &str) {
3749 std::fs::write(
3750 dir.join(format!("{slug}.md")),
3751 format!("---\ntype: spec\n---\n# {title}\n\n## Identity\n\nBody.\n{extra}"),
3752 )
3753 .unwrap();
3754 }
3755
3756 fn two_mem_dirs(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
3757 let eager_dir = tmp.path().join("eag");
3758 let lazy_dir = tmp.path().join("laz");
3759 std::fs::create_dir_all(&eager_dir).unwrap();
3760 std::fs::create_dir_all(&lazy_dir).unwrap();
3761 write_spec(&eager_dir, "alpha", "Alpha", "");
3762 write_spec(&lazy_dir, "omega", "Omega", "");
3763 (eager_dir, lazy_dir)
3764 }
3765
3766 fn mixed_engine(eager_dir: &Path, lazy_dir: &Path) -> Engine {
3767 Engine::from_mounts(vec![
3768 (
3769 folder_mount("eag", eager_dir.to_path_buf()),
3770 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
3771 ),
3772 (
3773 lazy_folder_mount("laz", lazy_dir.to_path_buf()),
3774 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
3775 ),
3776 ])
3777 .unwrap()
3778 }
3779
3780 #[test]
3786 fn lazy_mount_defers_and_first_read_loads() {
3787 let tmp = TempDir::new().unwrap();
3788 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3789 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3790
3791 assert!(
3793 engine
3794 .get_entity(&crate::EntityId::new("eag", "alpha"))
3795 .is_some()
3796 );
3797 assert_eq!(engine.deferred_mems(), vec!["laz"]);
3798 assert!(engine.mem_is_deferred("laz"));
3799 assert!(
3800 engine
3801 .get_entity(&crate::EntityId::new("laz", "omega"))
3802 .is_none(),
3803 "deferred mem's entities are not in the store yet"
3804 );
3805 assert!(engine.schema_for("laz").is_some());
3807
3808 engine.reload_if_stale(Some("laz"));
3810 assert!(engine.deferred_mems().is_empty());
3811 let omega = engine
3812 .get_entity(&crate::EntityId::new("laz", "omega"))
3813 .expect("first read loads the mem");
3814 assert_eq!(omega.title, "Omega");
3815
3816 let (actor, client) = cli_actor();
3818 engine
3819 .create_entity(
3820 empty_create_args("laz", "Later"),
3821 actor,
3822 Some(&client),
3823 None,
3824 )
3825 .unwrap();
3826 assert!(
3827 engine
3828 .get_entity(&crate::EntityId::new("laz", "later"))
3829 .is_some()
3830 );
3831 }
3832
3833 #[test]
3837 fn scoped_operation_never_loads_lazy_sibling() {
3838 let tmp = TempDir::new().unwrap();
3839 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3840 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3841
3842 engine.reload_if_stale(Some("eag"));
3843 assert_eq!(
3844 engine.deferred_mems(),
3845 vec!["laz"],
3846 "an eager-scoped operation must not load the lazy sibling"
3847 );
3848
3849 engine.reload_if_stale(None);
3850 assert!(
3851 engine.deferred_mems().is_empty(),
3852 "a workspace-scoped pass loads every deferred mem"
3853 );
3854 }
3855
3856 #[test]
3860 fn lazy_load_failure_quarantines_typed() {
3861 let tmp = TempDir::new().unwrap();
3862 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3863 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3864
3865 std::fs::remove_dir_all(&lazy_dir).unwrap();
3869 std::fs::write(&lazy_dir, b"not a directory").unwrap();
3870 engine.reload_if_stale(Some("laz"));
3871
3872 let q = engine
3873 .quarantine_reason("laz")
3874 .expect("failed deferred load quarantines, never serves empty");
3875 assert!(!q.reason_code.is_empty());
3876 assert!(
3877 !engine.mem_names().contains(&"laz"),
3878 "a quarantined mem leaves the serving roster"
3879 );
3880 assert!(engine.deferred_mems().is_empty());
3881 }
3882
3883 #[test]
3891 fn quarantine_entry_invalidates_both_memos() {
3892 let tmp = TempDir::new().unwrap();
3893 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3894 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3895
3896 let _ = engine.communities();
3898 let _ = engine.search_indexes();
3899 assert!(engine.search_indexes_memo.get().is_some());
3900
3901 std::fs::remove_dir_all(&lazy_dir).unwrap();
3903 std::fs::write(&lazy_dir, b"not a directory").unwrap();
3904 engine.reload_if_stale(Some("laz"));
3905 assert!(engine.quarantine_reason("laz").is_some());
3906 assert!(
3907 engine.search_indexes_memo.get().is_none(),
3908 "quarantine entry bumps the schemas epoch — the search memo must clear"
3909 );
3910 assert!(
3911 engine.community_memo.get().is_none(),
3912 "quarantine entry must clear the community memo too"
3913 );
3914 let _ = engine.search_indexes();
3916
3917 let _ = engine.communities();
3919 let _ = engine.search_indexes();
3920 let _ = engine.reload_one_mem("laz");
3921 assert!(engine.quarantine_reason("laz").is_some());
3922 assert!(
3923 engine.search_indexes_memo.get().is_none(),
3924 "a failed reattach bumps the epoch — the search memo must clear"
3925 );
3926 let _ = engine.search_indexes();
3927 }
3928
3929 #[test]
3934 fn quarantine_reattach_invalidates_both_memos() {
3935 let tmp = TempDir::new().unwrap();
3936 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3937 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3938
3939 std::fs::remove_dir_all(&lazy_dir).unwrap();
3941 std::fs::write(&lazy_dir, b"not a directory").unwrap();
3942 engine.reload_if_stale(Some("laz"));
3943 assert!(engine.quarantine_reason("laz").is_some());
3944
3945 let _ = engine.communities();
3947 let _ = engine.search_indexes();
3948 assert!(engine.community_memo.get().is_some());
3949 assert!(engine.search_indexes_memo.get().is_some());
3950
3951 std::fs::remove_file(&lazy_dir).unwrap();
3953 std::fs::create_dir_all(&lazy_dir).unwrap();
3954 write_spec(&lazy_dir, "omega", "Omega", "");
3955 engine
3956 .reload_one_mem("laz")
3957 .expect("reattach succeeds once the backend is back");
3958 assert!(engine.quarantine_reason("laz").is_none());
3959
3960 assert!(
3963 engine.community_memo.get().is_none(),
3964 "reattach must invalidate the community memo"
3965 );
3966 assert!(
3967 engine.search_indexes_memo.get().is_none(),
3968 "reattach must invalidate the search memo"
3969 );
3970 }
3971
3972 #[test]
3977 fn lazy_load_runs_the_same_validation_gauntlet() {
3978 let tmp = TempDir::new().unwrap();
3979 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3980 std::fs::write(
3982 lazy_dir.join("bad.md"),
3983 "---\ntype: spec\n---\n# Bad\n\n## Identity\n\nx.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[laz--omega]]\n",
3984 )
3985 .unwrap();
3986
3987 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3988 let warned_before = engine
3989 .load_warnings()
3990 .iter()
3991 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
3992 engine.reload_if_stale(Some("laz"));
3993 let warned_after = engine
3994 .load_warnings()
3995 .iter()
3996 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
3997 assert!(
3998 !warned_before && warned_after,
3999 "the gauntlet fires at load time: before={warned_before} after={warned_after}, \
4000 warnings: {:?}",
4001 engine.load_warnings()
4002 );
4003 let bad = engine
4005 .get_entity(&crate::EntityId::new("laz", "bad"))
4006 .unwrap();
4007 assert!(bad.relationships.is_empty());
4008 }
4009
4010 #[test]
4018 fn delete_guard_sees_referrers_in_deferred_mems() {
4019 use crate::engine::{DeleteEntityArgs, RelateEntityArgs};
4020
4021 let tmp = TempDir::new().unwrap();
4022 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4023 let (actor, client) = cli_actor();
4024
4025 {
4029 let mut authoring = Engine::from_mounts(vec![
4030 (
4031 folder_mount("eag", eager_dir.to_path_buf()),
4032 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf()))
4033 as Box<dyn MemBackend>,
4034 ),
4035 (
4036 folder_mount("laz", lazy_dir.to_path_buf()),
4037 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf()))
4038 as Box<dyn MemBackend>,
4039 ),
4040 ])
4041 .unwrap();
4042 let mut settings = crate::workspace::WorkspaceSettings::default();
4043 settings.cross_mem_links.insert(
4044 "laz".to_string(),
4045 memstead_schema::workspace_config::CrossLinkValue::List(vec!["eag".to_string()]),
4046 );
4047 authoring.set_settings(settings);
4048 authoring
4049 .relate_entity(
4050 RelateEntityArgs {
4051 source: crate::EntityId::new("laz", "omega"),
4052 expected_hash: None,
4053 rel_type: "USES".to_string(),
4054 target: crate::EntityId::new("eag", "alpha"),
4055 remove: false,
4056 description: None,
4057 dry_run: false,
4058 },
4059 actor,
4060 Some(&client),
4061 None,
4062 )
4063 .expect("cross-mem relate lands under the grant");
4064 }
4065
4066 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4070 assert!(engine.mem_is_deferred("laz"));
4071 let err = engine
4072 .delete_entity(
4073 DeleteEntityArgs {
4074 id: crate::EntityId::new("eag", "alpha"),
4075 expected_hash: None,
4076 },
4077 actor,
4078 Some(&client),
4079 None,
4080 )
4081 .expect_err("a referenced entity must refuse deletion, lazy referrer or not");
4082 assert!(
4083 matches!(err, EngineError::HasIncomingRefs { .. }),
4084 "expected HAS_INCOMING_REFS, got {err:?}"
4085 );
4086 assert!(
4087 engine
4088 .get_entity(&crate::EntityId::new("eag", "alpha"))
4089 .is_some(),
4090 "the entity survives"
4091 );
4092 }
4093
4094 #[test]
4105 fn acyclicity_guard_sees_edges_in_deferred_mems() {
4106 use crate::engine::RelateEntityArgs;
4107 use memstead_schema::workspace_config::CrossLinkValue;
4108
4109 let tmp = TempDir::new().unwrap();
4110 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4111 .iter()
4112 .map(|m| {
4113 let d = tmp.path().join(m);
4114 std::fs::create_dir_all(&d).unwrap();
4115 d
4116 })
4117 .collect();
4118 write_spec(&dirs[0], "node", "Node A", "");
4119 write_spec(&dirs[1], "node", "Node B", "");
4120 write_spec(&dirs[2], "node", "Node C", "");
4121
4122 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4123 ["ma", "mb", "mc"]
4124 .iter()
4125 .zip(dirs.iter())
4126 .map(|(m, d)| {
4127 let mut mount = folder_mount(m, d.clone());
4128 if lazy_mid && *m == "ma" {
4129 mount.lifecycle = MountLifecycle::Lazy;
4130 }
4131 (
4132 mount,
4133 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4134 )
4135 })
4136 .collect()
4137 };
4138 let grants = || {
4139 let mut settings = crate::workspace::WorkspaceSettings::default();
4140 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4141 settings
4142 .cross_mem_links
4143 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4144 }
4145 settings
4146 };
4147 let relate = |engine: &mut Engine, from: &str, to: &str| {
4148 let (actor, client) = cli_actor();
4149 engine.relate_entity(
4150 RelateEntityArgs {
4151 source: crate::EntityId::new(from, "node"),
4152 expected_hash: None,
4153 rel_type: "DEPENDS_ON".to_string(),
4154 target: crate::EntityId::new(to, "node"),
4155 remove: false,
4156 description: None,
4157 dry_run: false,
4158 },
4159 actor,
4160 Some(&client),
4161 None,
4162 )
4163 };
4164
4165 {
4167 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4168 authoring.set_settings(grants());
4169 relate(&mut authoring, "mb", "ma").expect("mb→ma lands");
4170 relate(&mut authoring, "ma", "mc").expect("ma→mc lands");
4171 }
4172
4173 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4176 engine.set_settings(grants());
4177 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4178 let err = relate(&mut engine, "mc", "mb")
4179 .expect_err("a cycle through a deferred mem must refuse, as eager refuses");
4180 assert!(
4181 matches!(err, EngineError::RelationshipCycle { .. }),
4182 "expected RELATIONSHIP_CYCLE, got {err:?}"
4183 );
4184 }
4185
4186 #[test]
4194 fn batch_acyclicity_guard_sees_edges_in_deferred_mems() {
4195 use crate::engine::RelateEntityArgs;
4196 use memstead_schema::workspace_config::CrossLinkValue;
4197
4198 let tmp = TempDir::new().unwrap();
4199 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4200 .iter()
4201 .map(|m| {
4202 let d = tmp.path().join(m);
4203 std::fs::create_dir_all(&d).unwrap();
4204 d
4205 })
4206 .collect();
4207 write_spec(&dirs[0], "node", "Node A", "");
4208 write_spec(&dirs[1], "node", "Node B", "");
4209 write_spec(&dirs[2], "node", "Node C", "");
4210 write_spec(&dirs[2], "node2", "Node C2", "");
4215
4216 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4217 ["ma", "mb", "mc"]
4218 .iter()
4219 .zip(dirs.iter())
4220 .map(|(m, d)| {
4221 let mut mount = folder_mount(m, d.clone());
4222 if lazy_mid && *m == "ma" {
4223 mount.lifecycle = MountLifecycle::Lazy;
4224 }
4225 (
4226 mount,
4227 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4228 )
4229 })
4230 .collect()
4231 };
4232 let grants = || {
4233 let mut settings = crate::workspace::WorkspaceSettings::default();
4234 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4235 settings
4236 .cross_mem_links
4237 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4238 }
4239 settings
4240 };
4241 let relate_args = |from: &str, to: &str| RelateEntityArgs {
4242 source: crate::EntityId::new(from, "node"),
4243 expected_hash: None,
4244 rel_type: "DEPENDS_ON".to_string(),
4245 target: crate::EntityId::new(to, "node"),
4246 remove: false,
4247 description: None,
4248 dry_run: false,
4249 };
4250
4251 {
4253 let (actor, client) = cli_actor();
4254 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4255 authoring.set_settings(grants());
4256 authoring
4257 .relate_entity(relate_args("mb", "ma"), actor, Some(&client), None)
4258 .expect("mb→ma lands");
4259 let (actor2, client2) = cli_actor();
4260 authoring
4261 .relate_entity(relate_args("ma", "mc"), actor2, Some(&client2), None)
4262 .expect("ma→mc lands");
4263 }
4264
4265 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4269 engine.set_settings(grants());
4270 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4271 let (actor, client) = cli_actor();
4272 let result = engine
4273 .batch_relate(
4274 vec![
4275 (relate_args("mc", "mb"), None),
4276 (
4282 RelateEntityArgs {
4283 source: crate::EntityId::new("mc", "node2"),
4284 expected_hash: None,
4285 rel_type: "DEPENDS_ON".to_string(),
4286 target: crate::EntityId::new("mc", "node"),
4287 remove: false,
4288 description: None,
4289 dry_run: false,
4290 },
4291 None,
4292 ),
4293 ],
4294 actor,
4295 Some(&client),
4296 false,
4297 )
4298 .expect("the batch call itself returns a report-all envelope");
4299 assert!(
4300 !result.applied,
4301 "a batch closing a cycle through a deferred mem must refuse, as eager refuses; got applied with {} succeeded",
4302 result.succeeded
4303 );
4304 }
4305
4306 #[test]
4314 fn relate_into_deferred_mem_verifies_against_storage_without_load() {
4315 use crate::engine::RelateEntityArgs;
4316 use memstead_schema::workspace_config::CrossLinkValue;
4317
4318 let tmp = TempDir::new().unwrap();
4319 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4320 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4321 let mut settings = crate::workspace::WorkspaceSettings::default();
4322 settings.cross_mem_links.insert(
4323 "eag".to_string(),
4324 CrossLinkValue::List(vec!["laz".to_string()]),
4325 );
4326 engine.set_settings(settings);
4327
4328 let relate = |engine: &mut Engine, to: &str| {
4329 let (actor, client) = cli_actor();
4330 engine.relate_entity(
4331 RelateEntityArgs {
4332 source: crate::EntityId::new("eag", "alpha"),
4333 expected_hash: None,
4334 rel_type: "SUPPORTS".to_string(),
4335 target: crate::EntityId::new("laz", to),
4336 remove: false,
4337 description: None,
4338 dry_run: false,
4339 },
4340 actor,
4341 Some(&client),
4342 None,
4343 )
4344 };
4345
4346 let outcome = relate(&mut engine, "omega").expect("verified target admits");
4348 assert!(
4349 engine.mem_is_deferred("laz"),
4350 "verification never loads the mem"
4351 );
4352 assert!(
4353 !outcome
4354 .warnings
4355 .iter()
4356 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4357 "a storage-verified target is not an auto-stub case: {:?}",
4358 outcome.warnings
4359 );
4360 let stub = engine
4361 .store()
4362 .get(&crate::EntityId::new("laz", "omega"))
4363 .expect("until-load stub present");
4364 assert!(stub.stub);
4365 assert_eq!(
4366 stub.stub_kind,
4367 Some(crate::entity::StubKind::LoadTime),
4368 "verified-in-storage stub carries the load-time kind"
4369 );
4370
4371 let outcome = relate(&mut engine, "missing").expect("absent Write-mem target auto-stubs");
4373 assert!(engine.mem_is_deferred("laz"), "still no load");
4374 assert!(
4375 outcome
4376 .warnings
4377 .iter()
4378 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4379 "absent target keeps the auto-stub warning: {:?}",
4380 outcome.warnings
4381 );
4382 let stub = engine
4383 .store()
4384 .get(&crate::EntityId::new("laz", "missing"))
4385 .expect("forward-reference stub present");
4386 assert_eq!(
4387 stub.stub_kind,
4388 Some(crate::entity::StubKind::ForwardReference)
4389 );
4390 }
4391
4392 #[test]
4398 fn readonly_deferred_target_answers_from_storage() {
4399 use crate::engine::RelateEntityArgs;
4400 use memstead_schema::workspace_config::CrossLinkValue;
4401
4402 let tmp = TempDir::new().unwrap();
4403 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4404 let mut ro_mount = lazy_folder_mount("laz", lazy_dir.to_path_buf());
4405 ro_mount.capability = crate::workspace::MountCapability::ReadOnly;
4406 let mut engine = Engine::from_mounts(vec![
4407 (
4408 folder_mount("eag", eager_dir.to_path_buf()),
4409 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4410 ),
4411 (
4412 ro_mount,
4413 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4414 ),
4415 ])
4416 .unwrap();
4417 let mut settings = crate::workspace::WorkspaceSettings::default();
4418 settings.cross_mem_links.insert(
4419 "eag".to_string(),
4420 CrossLinkValue::List(vec!["laz".to_string()]),
4421 );
4422 engine.set_settings(settings);
4423
4424 let relate = |engine: &mut Engine, to: &str| {
4425 let (actor, client) = cli_actor();
4426 engine.relate_entity(
4427 RelateEntityArgs {
4428 source: crate::EntityId::new("eag", "alpha"),
4429 expected_hash: None,
4430 rel_type: "SUPPORTS".to_string(),
4431 target: crate::EntityId::new("laz", to),
4432 remove: false,
4433 description: None,
4434 dry_run: false,
4435 },
4436 actor,
4437 Some(&client),
4438 None,
4439 )
4440 };
4441
4442 relate(&mut engine, "omega").expect("present-in-storage RO target admits");
4443 assert!(
4444 engine.mem_is_deferred("laz"),
4445 "the admit never loads the mem"
4446 );
4447
4448 let err =
4449 relate(&mut engine, "missing").expect_err("absent RO target keeps the typed refusal");
4450 assert!(
4451 matches!(err, EngineError::CrossMemTargetNotFound { .. }),
4452 "expected CROSS_MEM_TARGET_NOT_FOUND, got {err:?}"
4453 );
4454 assert!(
4455 engine.mem_is_deferred("laz"),
4456 "the refusal never loads the mem either"
4457 );
4458 }
4459
4460 #[test]
4465 fn cross_mem_link_into_lazy_mem_resolves_on_load() {
4466 let tmp = TempDir::new().unwrap();
4467 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4468 write_spec(
4469 &eager_dir,
4470 "linker",
4471 "Linker",
4472 "\nSee [[laz--omega]] for detail.\n",
4473 );
4474
4475 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4476 let target = crate::EntityId::new("laz", "omega");
4477 assert!(
4478 engine.get_entity(&target).is_none_or(|e| e.stub),
4479 "before the lazy load the cross-mem target is at most a stub, never real"
4480 );
4481
4482 engine.reload_if_stale(Some("laz"));
4483 let resolved = engine.get_entity(&target).expect("target loaded");
4484 assert!(!resolved.stub, "after the load the target is real");
4485 assert!(
4486 !engine.load_warnings().iter().any(
4487 |w| matches!(w, WarningHint::SuspiciousNestedPrefix { resolved_id, .. } if resolved_id.as_ref() == target.as_ref())
4488 ),
4489 "no lingering nested-prefix warning for a resolved cross-mem link: {:?}",
4490 engine.load_warnings()
4491 );
4492 }
4493}