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