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 embedded = embedded_archive_schemas(&m.mount);
277 let resolved = if embedded.is_empty() {
278 SchemaResolver::new(&builtin_schemas).resolve(effective_pin)
279 } else {
280 let mut per_mount: Vec<Arc<Schema>> = Vec::with_capacity(
281 workspace_schemas.len() + embedded.len() + builtin_schemas_only.len(),
282 );
283 per_mount.extend(workspace_schemas.iter().cloned());
284 per_mount.extend(embedded);
285 per_mount.extend(builtin_schemas_only.iter().cloned());
286 SchemaResolver::new(&per_mount).resolve(effective_pin)
287 };
288 let schema = match resolved {
289 Ok(schema) => schema,
290 Err(sources) => {
291 let failed = failed_schema_packages.iter().find(|f| {
299 f.name.as_deref() == Some(effective_pin.name.as_str())
300 && f.version
301 .as_deref()
302 .is_none_or(|v| v == effective_pin.version.to_string())
303 });
304 let (reason_code, reason_message) = match failed {
305 Some(f) => (
306 "SCHEMA_LOAD_FAILED".to_string(),
307 format!(
308 "schema package at {} failed to load: {}",
309 f.path.display(),
310 f.error
311 ),
312 ),
313 None => {
314 let e = EngineError::SchemaNotFound {
315 mem: m.mount.mem.clone(),
316 pin: effective_pin.as_display(),
317 sources,
318 install_hint: None,
319 };
320 (e.code().to_string(), e.to_string())
321 }
322 };
323 quarantined.push(crate::engine::QuarantinedMem {
324 mount: m.mount.clone(),
325 reason_code,
326 reason_message,
327 });
328 quarantined_idx.insert(m_idx);
329 continue;
330 }
331 };
332 schemas.insert(m.mount.mem.clone(), schema.clone());
333
334 let locally_installed = workspace_schemas.iter().any(|s| {
344 s.manifest.name == effective_pin.name && s.version == effective_pin.version
345 });
346 let is_builtin = builtin_schemas_only.iter().any(|s| {
347 s.manifest.name == effective_pin.name && s.version == effective_pin.version
348 });
349 if !locally_installed
350 && is_builtin
351 && let Some(newest) =
352 newest_builtin_version(&effective_pin.name, &builtin_schemas_only)
353 && *newest > effective_pin.version
354 {
355 load_warnings.push(WarningHint::SchemaGenerationsBehind {
356 mem: m.mount.mem.clone(),
357 pinned: effective_pin.as_display(),
358 newest: newest.to_string(),
359 });
360 }
361
362 if let Err(memstead_schema::SchemaLoadError::SectionHeadingMismatch { violations }) =
367 memstead_schema::check_section_heading_roundtrip(&schema)
368 {
369 let (name, version) = schema.id();
370 load_warnings.push(WarningHint::SchemaHeadingRoundtripViolation {
371 mem: m.mount.mem.clone(),
372 schema_ref: format!("{name}@{version}"),
373 violations: violations.iter().map(Into::into).collect(),
374 });
375 }
376
377 if m.mount.lifecycle == crate::workspace::MountLifecycle::Lazy {
383 if let Some(w) = unbacked_mount_warning(&m.mount, m.backend.as_ref(), None) {
384 load_warnings.push(w);
385 }
386 deferred_idx.insert(m_idx);
387 continue;
388 }
389
390 let (entries, read_errors) = match collect_source_entries(m.backend.as_ref()) {
391 Ok(pair) => pair,
392 Err(e) => {
393 quarantined.push(crate::engine::QuarantinedMem {
396 mount: m.mount.clone(),
397 reason_code: e.code().to_string(),
398 reason_message: e.to_string(),
399 });
400 quarantined_idx.insert(m_idx);
401 schemas.remove(&m.mount.mem);
402 continue;
403 }
404 };
405 let path_backed = matches!(
432 m.mount.storage,
433 crate::workspace::MountStorage::Folder { .. }
434 | crate::workspace::MountStorage::Archive { .. }
435 );
436 if path_backed && !m.backend.storage_present().unwrap_or(true) {
437 let location = match &m.mount.storage {
438 crate::workspace::MountStorage::GitBranch { branch, .. } => branch.clone(),
439 crate::workspace::MountStorage::Folder { path }
440 | crate::workspace::MountStorage::Archive { path } => {
441 path.display().to_string()
442 }
443 crate::workspace::MountStorage::InMemory => String::new(),
444 };
445 quarantined.push(crate::engine::QuarantinedMem {
446 mount: m.mount.clone(),
447 reason_code: "MOUNT_UNBACKED".to_string(),
448 reason_message: format!(
449 "the mount's storage is gone ({location}); it is configured but cannot \
450 serve, so it is held out of the roster rather than answering reads \
451 with an empty graph"
452 ),
453 });
454 quarantined_idx.insert(m_idx);
455 schemas.remove(&m.mount.mem);
456 continue;
457 }
458 if let Some(w) =
462 unbacked_mount_warning(&m.mount, m.backend.as_ref(), Some(entries.len()))
463 {
464 load_warnings.push(w);
465 }
466 let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
467 push_entities_into_store(
473 &mut store,
474 load_result.entities,
475 fallback.as_ref(),
476 Some(crate::entity::store_builder::LoadCollector {
477 warnings: &mut load_warnings,
478 known_suffixes: &known_suffixes,
479 mem_names: &mem_names,
480 }),
481 );
482 if let crate::workspace::MountStorage::Folder { path } = &m.mount.storage {
487 let root = path.clone();
488 load_errors.extend(load_result.errors.into_iter().map(|(p, msg)| {
489 let abs = if p.is_relative() { root.join(&p) } else { p };
490 (abs, msg)
491 }));
492 } else {
493 load_errors.extend(load_result.errors);
494 }
495 }
496
497 for idx in &deferred_idx {
500 mounted[*idx].deferred = true;
501 }
502
503 if !quarantined_idx.is_empty() {
508 let mut keep_idx = 0usize;
509 mounted.retain(|_| {
510 let keep = !quarantined_idx.contains(&keep_idx);
511 keep_idx += 1;
512 keep
513 });
514 }
515
516 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
524 mounted
525 .iter()
526 .map(|m| (m.mount.mem.clone(), m.mount.capability))
527 .collect();
528 crate::entity::store_builder::validate_loaded_relations(
529 &mut store,
530 &schemas,
531 &mount_caps,
532 &mut load_warnings,
533 );
534
535 crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
540
541 load_warnings.retain(|w| match w {
550 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
551 store.get(resolved_id).is_none_or(|e| e.stub)
552 }
553 _ => true,
554 });
555
556 let mem_router = build_mem_router_from_mounts(&mounted);
562
563 let mounts_baseline =
569 std::cell::RefCell::new(mounted.iter().map(|m| m.mount.clone()).collect::<Vec<_>>());
570
571 Ok(Self {
572 mounts: mounted,
573 mounts_baseline,
574 store,
575 schemas,
576 workspace_schemas,
577 builtin_schemas: builtin_schemas_only,
578 load_errors,
579 community_memo: OnceCell::new(),
580 labelling_memo: OnceCell::new(),
581 #[cfg(not(target_arch = "wasm32"))]
582 search_indexes_memo: OnceCell::new(),
583 settings: WorkspaceSettings::default(),
584 create_rule_set_memo: OnceCell::new(),
585 declared_origins: HashMap::new(),
586 workspace_root: None,
587 load_warnings,
588 quarantined,
589 boot_diagnosis: None,
590 pipeline_configs: crate::pipeline_store::BindingConfigs::default(),
591 mem_router: Arc::new(mem_router),
592 backend_factory: crate::workspace_store::instantiate_lean_backend,
593 unmounted_storage_prober: None,
594 schemas_epoch: 0,
595 git_branch_ops: None,
596 event_subscribers: Arc::new(std::sync::Mutex::new(
597 crate::engine::events::SubscriberRegistry::new(),
598 )),
599 pending_mem_changed: Vec::new(),
600 roster_fingerprint: None,
601 roster_subscribers: Arc::new(std::sync::Mutex::new((0, Vec::new()))),
602 recently_unmounted: std::collections::HashSet::new(),
603 #[cfg(test)]
604 inject_unmount_failure: None,
605 mutation_clock: Arc::new(crate::engine::mutation::wall_clock_now),
606 current_role: crate::vcs::Role::Unspecified,
607 current_identity: None,
608 })
609 }
610
611 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
629 use crate::workspace_store::{
630 FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
631 instantiate_lean_backend,
632 };
633
634 let workspace = match detect_layout(workspace_root) {
635 Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
639 Some(ws) => ws,
640 None => {
641 return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
642 }
643 },
644 Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
645 };
646
647 let settings = workspace.settings.clone();
648 let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
649 Vec::with_capacity(workspace.mounts.len());
650 let mut instantiate_quarantine: Vec<crate::engine::QuarantinedMem> = Vec::new();
654 for mount in workspace.mounts {
655 match instantiate_lean_backend(&mount) {
656 Ok(backend) => mounts.push((mount, backend)),
657 Err(e) => instantiate_quarantine.push(crate::engine::QuarantinedMem {
658 reason_code: e.code().to_string(),
659 reason_message: e.to_string(),
660 mount,
661 }),
662 }
663 }
664 let fixed_dir = workspace_root.join(".memstead").join("schemas");
673 let (local, failed) = load_workspace_schemas_with_failures(Some(fixed_dir.as_path()));
674 let mut engine = Engine::from_mounts_inner(mounts, local, failed)
677 .map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
678 engine.quarantined.extend(instantiate_quarantine);
679 engine.set_settings(settings);
680 engine.workspace_root = Some(workspace_root.to_path_buf());
681 engine.capture_roster_fingerprint();
682 engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
693 workspace_root,
694 )?);
695 Ok(engine)
706 }
707}
708
709pub(crate) fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
742 let mut router = MemRouterSnapshot::new();
743 for m in mounts {
744 match m.mount.capability {
745 MountCapability::Write => {
746 let dir: Option<PathBuf> = match &m.mount.storage {
747 MountStorage::Folder { path } => Some(path.clone()),
748 MountStorage::GitBranch { .. } => None,
749 MountStorage::Archive { .. } => None,
750 MountStorage::InMemory => None,
754 };
755 router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
756 }
757 MountCapability::ReadOnly => match &m.mount.storage {
758 MountStorage::Archive { path } => {
759 router.add_read_only(m.mount.mem.clone(), path.clone());
760 }
761 MountStorage::Folder { path } => {
762 router.add_read_only(m.mount.mem.clone(), path.clone());
763 }
764 MountStorage::GitBranch { gitdir, .. } => {
765 router.add_read_only(m.mount.mem.clone(), gitdir.clone());
766 }
767 MountStorage::InMemory => {}
773 },
774 }
775 }
776 router
777}
778
779pub fn resolve_builtin_schema_pin_pub(
784 pin: &memstead_schema::SchemaRef,
785 catalogue: &[Arc<memstead_schema::Schema>],
786) -> Option<Arc<memstead_schema::Schema>> {
787 resolve_builtin_schema_pin(pin, catalogue)
788}
789
790fn newest_builtin_version<'a>(
795 name: &str,
796 builtins: &'a [Arc<memstead_schema::Schema>],
797) -> Option<&'a semver::Version> {
798 builtins
799 .iter()
800 .filter(|s| s.manifest.name == name)
801 .map(|s| &s.version)
802 .max()
803}
804
805pub struct SchemaResolver<'a> {
820 catalogue: &'a [Arc<memstead_schema::Schema>],
821}
822
823impl<'a> SchemaResolver<'a> {
824 pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
827 Self { catalogue }
828 }
829
830 pub fn resolve(
834 &self,
835 pin: &memstead_schema::SchemaRef,
836 ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
837 {
838 resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
839 crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
840 &pin.name,
841 &pin.version,
842 self.catalogue,
843 )
844 })
845 }
846}
847
848pub fn load_workspace_schemas(
856 schemas_dir: Option<&Path>,
857) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
858 Ok(load_workspace_schemas_with_failures(schemas_dir).0)
859}
860
861#[derive(Debug, Clone)]
868pub struct FailedSchemaPackage {
869 pub path: PathBuf,
870 pub name: Option<String>,
871 pub version: Option<String>,
872 pub error: String,
874}
875
876pub(crate) fn embedded_archive_schemas(mount: &Mount) -> Vec<Arc<Schema>> {
886 let crate::workspace::MountStorage::Archive { path } = &mount.storage else {
887 return Vec::new();
888 };
889 if path.as_os_str().is_empty() || !path.is_file() {
890 return Vec::new();
891 }
892 use crate::schema_source::SchemaSource as _;
893 crate::schema_source::ArchiveSchemaSource::from_path(path)
894 .ok()
895 .and_then(|source| source.read_schemas().ok())
896 .unwrap_or_default()
897}
898
899pub fn load_workspace_schemas_with_failures(
905 schemas_dir: Option<&Path>,
906) -> (Vec<Arc<memstead_schema::Schema>>, Vec<FailedSchemaPackage>) {
907 let Some(dir) = schemas_dir else {
908 return (Vec::new(), Vec::new());
909 };
910 if !dir.is_dir() {
911 return (Vec::new(), Vec::new());
912 }
913 let entries = match std::fs::read_dir(dir) {
914 Ok(e) => e,
915 Err(_) => return (Vec::new(), Vec::new()),
916 };
917 let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
918 let mut failures: Vec<FailedSchemaPackage> = Vec::new();
919 for entry in entries.flatten() {
920 let path = entry.path();
921 if !path.is_dir() {
922 continue;
923 }
924 if !path.join("schema.yaml").is_file() {
925 continue;
926 }
927 match memstead_schema::load_schema_from_dir(&path) {
928 Ok(schema) => schemas.push(Arc::new(schema)),
929 Err(e) => {
930 let header = std::fs::read_to_string(path.join("schema.yaml")).unwrap_or_default();
934 let peek = |k: &str| {
935 header
936 .lines()
937 .find_map(|l| l.strip_prefix(&format!("{k}:")))
938 .map(|v| v.trim().trim_matches('"').to_string())
939 .filter(|v| !v.is_empty())
940 };
941 failures.push(FailedSchemaPackage {
942 path: path.clone(),
943 name: peek("name"),
944 version: peek("version"),
945 error: e.to_string(),
946 });
947 }
948 }
949 }
950 (schemas, failures)
951}
952
953pub(super) fn resolve_builtin_schema_pin(
954 pin: &memstead_schema::SchemaRef,
955 catalogue: &[Arc<memstead_schema::Schema>],
956) -> Option<Arc<memstead_schema::Schema>> {
957 catalogue
958 .iter()
959 .find(|s| {
960 let id = s.id();
961 id.0 == pin.name && id.1 == pin.version
962 })
963 .cloned()
964}
965
966pub(super) fn unbacked_mount_warning(
974 mount: &crate::workspace::Mount,
975 backend: &dyn MemBackend,
976 entity_count: Option<usize>,
977) -> Option<WarningHint> {
978 use crate::ops::MountUnbackedReason;
979 use crate::workspace::MountStorage;
980 let (location, missing_reason) = match &mount.storage {
981 MountStorage::GitBranch { branch, .. } => (branch.clone(), MountUnbackedReason::MissingRef),
982 MountStorage::Folder { path } => {
983 (path.display().to_string(), MountUnbackedReason::MissingPath)
984 }
985 MountStorage::Archive { path } => {
986 (path.display().to_string(), MountUnbackedReason::MissingPath)
987 }
988 MountStorage::InMemory => return None,
989 };
990 if !backend.storage_present().unwrap_or(true) {
991 return Some(WarningHint::MountUnbacked {
992 mem: mount.mem.clone(),
993 reason: missing_reason,
994 location,
995 });
996 }
997 if entity_count == Some(0) {
998 return Some(WarningHint::MountUnbacked {
999 mem: mount.mem.clone(),
1000 reason: MountUnbackedReason::Empty,
1001 location,
1002 });
1003 }
1004 None
1005}
1006
1007pub(super) fn collect_source_entries(
1008 backend: &dyn MemBackend,
1009) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
1010 let paths = backend.list_entities()?;
1011 let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
1012 let mut errors: Vec<SourceReadError> = Vec::new();
1013 for path in paths {
1014 match backend.read_entity(&path) {
1015 Ok(Some(bytes)) => match String::from_utf8(bytes) {
1016 Ok(content) => entries.push(SourceEntry {
1017 relative_path: path.to_string_lossy().into_owned(),
1018 source_path: path.clone(),
1019 content,
1020 }),
1021 Err(e) => errors.push(SourceReadError {
1022 source_path: path,
1023 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
1024 }),
1025 },
1026 Ok(None) => {
1027 }
1029 Err(e) => errors.push(SourceReadError {
1030 source_path: path,
1031 error: std::io::Error::other(e.to_string()),
1032 }),
1033 }
1034 }
1035 Ok((entries, errors))
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040
1041 use std::path::Path;
1042
1043 use memstead_schema::SchemaRef;
1044 use tempfile::TempDir;
1045
1046 use crate::backend::MemBackend;
1047 use crate::engine::test_helpers::*;
1048 use crate::engine::{Engine, EngineError};
1049 use crate::ops::WarningHint;
1050 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
1051 use crate::vcs::CommitContext;
1052 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1053
1054 #[test]
1059 fn unbacked_mount_probe_classes_missing_path_empty_and_present() {
1060 use crate::engine::boot::unbacked_mount_warning;
1061 use crate::ops::MountUnbackedReason;
1062 let tmp = TempDir::new().unwrap();
1063 let mount = |path: std::path::PathBuf| Mount {
1064 mem: "probe".into(),
1065 schema: None,
1066 storage: MountStorage::Folder { path },
1067 capability: MountCapability::Write,
1068 lifecycle: MountLifecycle::Eager,
1069 cross_linkable: true,
1070 migration_target: None,
1071 };
1072
1073 let gone = tmp.path().join("gone");
1074 let backend = FilesystemMemWriter::new(gone.clone());
1075 let w = unbacked_mount_warning(&mount(gone.clone()), &backend, Some(0))
1076 .expect("a missing folder is unbacked");
1077 match &w {
1078 WarningHint::MountUnbacked {
1079 mem,
1080 reason,
1081 location,
1082 } => {
1083 assert_eq!(mem, "probe");
1084 assert_eq!(*reason, MountUnbackedReason::MissingPath);
1085 assert_eq!(location, &gone.display().to_string());
1086 }
1087 other => panic!("unexpected variant: {other:?}"),
1088 }
1089 assert_eq!(w.code(), "MOUNT_UNBACKED");
1090 let json = serde_json::to_value(&w).unwrap();
1091 assert_eq!(json["details"]["reason"], "missing_path");
1092 assert!(unbacked_mount_warning(&mount(gone), &backend, None).is_some());
1094
1095 let hollow = tmp.path().join("hollow");
1096 std::fs::create_dir_all(&hollow).unwrap();
1097 let backend = FilesystemMemWriter::new(hollow.clone());
1098 let w = unbacked_mount_warning(&mount(hollow.clone()), &backend, Some(0))
1099 .expect("an entity-less folder is unbacked");
1100 assert_eq!(
1101 serde_json::to_value(&w).unwrap()["details"]["reason"],
1102 "empty"
1103 );
1104 assert!(unbacked_mount_warning(&mount(hollow.clone()), &backend, None).is_none());
1106 assert!(unbacked_mount_warning(&mount(hollow), &backend, Some(1)).is_none());
1108 }
1109
1110 #[test]
1114 fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
1115 let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
1116 let resolver = super::SchemaResolver::new(&catalogue);
1117
1118 let ok: SchemaRef = "default@1.0.0".parse().unwrap();
1119 assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
1120
1121 let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
1122 let sources = resolver.resolve(&miss).unwrap_err();
1123 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
1124 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1125 assert!(sources.iter().all(|s| !s.pinned_version_match));
1126 }
1127
1128 #[test]
1129 fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
1130 let engine = Engine::from_mounts(Vec::new()).unwrap();
1131 assert!(engine.mem_names().is_empty());
1132 match engine.list_entities("missing") {
1133 Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
1134 other => panic!("expected UnknownMem, got {other:?}"),
1135 }
1136 }
1137
1138 #[test]
1139 fn duplicate_mem_names_rejected_at_construction() {
1140 let tmp = TempDir::new().unwrap();
1141 let writer1: Box<dyn MemBackend> =
1142 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1143 let writer2: Box<dyn MemBackend> =
1144 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1145 let err = Engine::from_mounts(vec![
1146 (folder_mount("specs", tmp.path().to_path_buf()), writer1),
1147 (folder_mount("specs", tmp.path().to_path_buf()), writer2),
1148 ])
1149 .unwrap_err();
1150 assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
1151 }
1152
1153 #[test]
1154 fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
1155 let tmp = TempDir::new().unwrap();
1161 let mem_dir = tmp.path().to_path_buf();
1162 let body =
1163 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1164 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1165
1166 let writer = FilesystemMemWriter::new(mem_dir.clone());
1167 let engine = Engine::from_mounts(vec![(
1168 folder_mount("specs", mem_dir),
1169 Box::new(writer) as Box<dyn MemBackend>,
1170 )])
1171 .unwrap();
1172
1173 let warnings = engine.load_warnings();
1174 assert!(
1175 warnings
1176 .iter()
1177 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1178 "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
1179 );
1180 }
1181
1182 #[test]
1190 fn generation_behind_hint_fires_for_old_builtin_pin_only() {
1191 let tmp = TempDir::new().unwrap();
1193 let mem_dir = tmp.path().to_path_buf();
1194 let writer = FilesystemMemWriter::new(mem_dir.clone());
1195 let mut engine = Engine::from_mounts(vec![(
1196 folder_mount("specs", mem_dir),
1197 Box::new(writer) as Box<dyn MemBackend>,
1198 )])
1199 .unwrap();
1200 let behind: Vec<_> = engine
1201 .load_warnings()
1202 .iter()
1203 .filter_map(|w| match w {
1204 WarningHint::SchemaGenerationsBehind {
1205 mem,
1206 pinned,
1207 newest,
1208 } => Some((mem.clone(), pinned.clone(), newest.clone())),
1209 _ => None,
1210 })
1211 .collect();
1212 assert_eq!(
1213 behind,
1214 vec![(
1215 "specs".to_string(),
1216 "default@1.0.0".to_string(),
1217 "1.3.0".to_string()
1218 )],
1219 "old built-in pin must surface the generation-behind hint"
1220 );
1221 assert!(
1222 engine
1223 .health()
1224 .warnings
1225 .iter()
1226 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1227 "the hint rides health without an include gate"
1228 );
1229 engine
1231 .create_entity_with_ctx(
1232 crate::engine::CreateEntityArgs {
1233 anchors: Vec::new(),
1234 mem: "specs".to_string(),
1235 title: "Still writable".to_string(),
1236 entity_type: "spec".to_string(),
1237 sections: indexmap::IndexMap::from_iter([
1238 ("identity".to_string(), "i".to_string()),
1239 ("purpose".to_string(), "p".to_string()),
1240 ]),
1241 metadata: indexmap::IndexMap::new(),
1242 relations: Vec::new(),
1243 dry_run: false,
1244 },
1245 &crate::vcs::CommitContext::internal(),
1246 )
1247 .expect("generation-behind hint must never block mutations");
1248
1249 let tmp = TempDir::new().unwrap();
1251 let mem_dir = tmp.path().to_path_buf();
1252 let writer = FilesystemMemWriter::new(mem_dir.clone());
1253 let mut mount = folder_mount("specs", mem_dir);
1254 mount.schema = Some("default@1.3.0".parse().unwrap());
1255 let engine =
1256 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1257 assert!(
1258 !engine
1259 .load_warnings()
1260 .iter()
1261 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1262 "newest built-in pin must stay silent: {:?}",
1263 engine.load_warnings()
1264 );
1265 let health_json = serde_json::to_string(&engine.health().warnings).unwrap();
1266 assert!(
1267 !health_json.contains("SCHEMA_GENERATIONS_BEHIND"),
1268 "newest pin: health output carries no generation hint"
1269 );
1270 }
1271
1272 #[test]
1276 fn newest_builtin_version_orders_by_semver_not_string() {
1277 let manifest = |version: &str| {
1278 format!(
1279 r#"name: gen-test
1280version: {version}
1281description: test
1282when_to_use: test
1283types:
1284 - note
1285relationships:
1286 mode: strict
1287 definitions:
1288 - name: _default
1289 description: default
1290 default_weight: 1.0
1291 - name: PART_OF
1292 description: hier
1293 default_weight: 3.0
1294community:
1295 resolution: 1.0
1296 seed: 42
1297"#
1298 )
1299 };
1300 let type_yaml = r#"name: note
1301description: test
1302when_to_use: test
1303sections:
1304 - key: body
1305 heading: Body
1306 required: true
1307 search_weight: 10.0
1308 catch_all: true
1309metadata_fields: []
1310title_weight: 1.0
1311text_fields: [body]
1312hierarchy_relationship: PART_OF
1313no_self_loop_relationships: []
1314updatable_fields: [title, body]
1315health_required_fields: [body]
1316staleness_threshold_days: 30
1317write_rules: []
1318"#;
1319 let types = vec![("note".to_string(), type_yaml.to_string())];
1320 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = ["0.9.0", "0.10.0", "0.2.0"]
1321 .iter()
1322 .map(|v| {
1323 std::sync::Arc::new(
1324 memstead_schema::load_schema_from_memory(&manifest(v), &types)
1325 .expect("fixture schema loads"),
1326 )
1327 })
1328 .collect();
1329 let newest = super::newest_builtin_version("gen-test", &catalogue)
1330 .expect("name present in catalogue");
1331 assert_eq!(newest.to_string(), "0.10.0", "semver, not string, ordering");
1332 assert!(super::newest_builtin_version("absent", &catalogue).is_none());
1333 }
1334
1335 #[test]
1341 fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
1342 let tmp = TempDir::new().unwrap();
1343 let mem_dir = tmp.path().to_path_buf();
1344 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1347 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1348 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1349 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1350
1351 let writer = FilesystemMemWriter::new(mem_dir.clone());
1352 let engine = Engine::from_mounts(vec![(
1353 folder_mount("specs", mem_dir),
1354 Box::new(writer) as Box<dyn MemBackend>,
1355 )])
1356 .unwrap();
1357
1358 let source_id = crate::entity::EntityId::new("specs", "source");
1359 let target_id = crate::entity::EntityId::new("specs", "target");
1360 let source_entity = engine.get_entity(&source_id).expect("source loaded");
1361 assert!(
1364 source_entity.relationships.is_empty(),
1365 "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
1366 source_entity.relationships,
1367 );
1368 let outgoing: Vec<_> = engine
1370 .store()
1371 .outgoing(&source_id)
1372 .iter()
1373 .filter(|e| e.rel_type == "MADE_UP_TYPE")
1374 .collect();
1375 assert!(
1376 outgoing.is_empty(),
1377 "MADE_UP_TYPE edge must be dropped from the store"
1378 );
1379 let parsed_invalid: Vec<_> = engine
1381 .load_warnings()
1382 .iter()
1383 .filter_map(|w| match w {
1384 WarningHint::ParsedRelationInvalid {
1385 entity_id,
1386 rel_type,
1387 target,
1388 reason,
1389 origin,
1390 recovery,
1391 } => Some((
1392 entity_id.clone(),
1393 rel_type.clone(),
1394 target.clone(),
1395 reason.clone(),
1396 origin.clone(),
1397 recovery.clone(),
1398 )),
1399 _ => None,
1400 })
1401 .collect();
1402 assert_eq!(
1403 parsed_invalid.len(),
1404 1,
1405 "expected one warning, got {parsed_invalid:?}"
1406 );
1407 assert_eq!(parsed_invalid[0].0, source_id);
1408 assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
1409 assert_eq!(parsed_invalid[0].2, target_id);
1410 assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
1411 assert_eq!(parsed_invalid[0].4, "writable");
1412 let recovery = parsed_invalid[0]
1414 .5
1415 .as_ref()
1416 .expect("writable-origin warning must carry recovery");
1417 assert_eq!(
1418 recovery.kind,
1419 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1420 );
1421 assert_eq!(recovery.source_id, parsed_invalid[0].0);
1422 assert_eq!(recovery.target_id, parsed_invalid[0].2);
1423 assert_eq!(recovery.rel_type, parsed_invalid[0].1);
1424 }
1425
1426 #[test]
1434 fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
1435 let tmp = TempDir::new().unwrap();
1436 let mem_dir = tmp.path().to_path_buf();
1437 let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
1441 let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
1442 std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
1443 std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
1444
1445 let writer = FilesystemMemWriter::new(mem_dir.clone());
1446 let engine = Engine::from_mounts(vec![(
1447 folder_mount("specs", mem_dir),
1448 Box::new(writer) as Box<dyn MemBackend>,
1449 )])
1450 .unwrap();
1451
1452 let alpha_id = crate::entity::EntityId::new("specs", "alpha");
1453 let beta_id = crate::entity::EntityId::new("specs", "beta");
1454
1455 assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
1458 assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
1459
1460 let surviving: Vec<_> = engine
1463 .store()
1464 .all_entities()
1465 .flat_map(|e| {
1466 engine
1467 .store()
1468 .outgoing(&e.id)
1469 .iter()
1470 .filter(|edge| edge.rel_type == "PART_OF")
1471 .map(|edge| (e.id.clone(), edge.target.clone()))
1472 .collect::<Vec<_>>()
1473 })
1474 .collect();
1475 assert_eq!(
1476 surviving.len(),
1477 1,
1478 "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
1479 );
1480
1481 let cycle_drops: Vec<_> = engine
1484 .load_warnings()
1485 .iter()
1486 .filter_map(|w| match w {
1487 WarningHint::ParsedRelationInvalid {
1488 entity_id,
1489 rel_type,
1490 target,
1491 reason,
1492 ..
1493 } if reason == "cycle" => {
1494 Some((entity_id.clone(), rel_type.clone(), target.clone()))
1495 }
1496 _ => None,
1497 })
1498 .collect();
1499 assert_eq!(
1500 cycle_drops.len(),
1501 1,
1502 "exactly one cycle warning must fire, got {cycle_drops:?}",
1503 );
1504 let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
1506 assert_eq!(dropped_rel_type, "PART_OF");
1507 let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
1508 let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
1509 assert!(
1510 is_alpha_to_beta || is_beta_to_alpha,
1511 "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
1512 );
1513 assert_ne!(
1515 (&surviving[0].0, &surviving[0].1),
1516 (dropped_from, dropped_to),
1517 "surviving edge must differ from the dropped one",
1518 );
1519 }
1520
1521 #[test]
1529 fn nested_prefix_warning_exempts_real_cross_mem_target_loaded_later() {
1530 let tmp = TempDir::new().unwrap();
1531 let project_dir = tmp.path().join("project");
1532 let registry_dir = tmp.path().join("registry");
1533 std::fs::create_dir_all(&project_dir).unwrap();
1534 std::fs::create_dir_all(®istry_dir).unwrap();
1535
1536 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nReal: [[registry--registry-service]]. Drifted: [[registry--never-created]].\n";
1539 std::fs::write(project_dir.join("source.md"), source).unwrap();
1540
1541 let service = "---\ntype: spec\n---\n# Registry Service\n\n## Identity\n\nA real entity.\n";
1544 std::fs::write(registry_dir.join("registry-service.md"), service).unwrap();
1545
1546 let engine = Engine::from_mounts(vec![
1547 (
1548 folder_mount("project", project_dir.clone()),
1549 Box::new(FilesystemMemWriter::new(project_dir)) as Box<dyn MemBackend>,
1550 ),
1551 (
1552 folder_mount("registry", registry_dir.clone()),
1553 Box::new(FilesystemMemWriter::new(registry_dir)) as Box<dyn MemBackend>,
1554 ),
1555 ])
1556 .unwrap();
1557
1558 let nested: Vec<_> = engine
1559 .load_warnings()
1560 .iter()
1561 .filter_map(|w| match w {
1562 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1563 Some(resolved_id.to_string())
1564 }
1565 _ => None,
1566 })
1567 .collect();
1568 assert!(
1569 !nested.contains(&"registry--registry-service".to_string()),
1570 "a valid cross-mem id resolving to a real entity must not warn, got {nested:?}",
1571 );
1572 assert!(
1573 nested.contains(&"registry--never-created".to_string()),
1574 "a genuinely unresolved nested-prefix link must keep its warning, got {nested:?}",
1575 );
1576 }
1577
1578 #[test]
1588 fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
1589 use crate::engine::test_helpers::write_schema_files_with_default_type;
1590
1591 let tmp = TempDir::new().unwrap();
1592 let schemas_dir = tmp.path().join("schemas");
1593 std::fs::create_dir_all(&schemas_dir).unwrap();
1594 let manifest = r#"name: shape-test
1598version: 0.1.0
1599description: shape-constraint schema
1600when_to_use: tests
1601types:
1602 - doc
1603 - actor
1604relationships:
1605 mode: strict
1606 definitions:
1607 - name: OWNS
1608 description: actor owns doc
1609 default_weight: 1.0
1610 source_types: [actor]
1611 target_types: [doc]
1612 - name: _default
1613 description: fallback
1614 default_weight: 1.0
1615community:
1616 resolution: 1.0
1617 seed: 42
1618"#;
1619 write_schema_files_with_default_type(
1620 &schemas_dir,
1621 "shape-test",
1622 manifest,
1623 &["doc", "actor"],
1624 );
1625
1626 let mem_dir = tmp.path().join("mem");
1627 std::fs::create_dir_all(&mem_dir).unwrap();
1628 let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
1632 let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
1633 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1634 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1635
1636 let writer = FilesystemMemWriter::new(mem_dir.clone());
1637 let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
1638 let mount = Mount {
1639 mem: "specs".to_string(),
1640 schema: Some(pin),
1641 storage: MountStorage::Folder { path: mem_dir },
1642 capability: MountCapability::Write,
1643 lifecycle: MountLifecycle::Eager,
1644 cross_linkable: true,
1645 migration_target: None,
1646 };
1647 let engine = Engine::from_mounts_with_schemas_dir(
1648 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1649 Some(&schemas_dir),
1650 )
1651 .unwrap();
1652
1653 let source_id = crate::entity::EntityId::new("specs", "source");
1654 let target_id = crate::entity::EntityId::new("specs", "target");
1655
1656 let shape_drops: Vec<_> = engine
1657 .load_warnings()
1658 .iter()
1659 .filter_map(|w| match w {
1660 WarningHint::ParsedRelationInvalid {
1661 entity_id,
1662 rel_type,
1663 target,
1664 reason,
1665 origin,
1666 recovery,
1667 } if reason == "shape" => Some((
1668 entity_id.clone(),
1669 rel_type.clone(),
1670 target.clone(),
1671 origin.clone(),
1672 recovery.clone(),
1673 )),
1674 _ => None,
1675 })
1676 .collect();
1677 assert_eq!(
1678 shape_drops.len(),
1679 1,
1680 "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
1681 engine.load_warnings(),
1682 );
1683 let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
1684 shape_drops.into_iter().next().unwrap();
1685 assert_eq!(drop_from, source_id);
1686 assert_eq!(drop_type, "OWNS");
1687 assert_eq!(drop_to, target_id);
1688 assert_eq!(drop_origin, "writable");
1689 let recovery = drop_recovery.expect("writable origin must carry recovery");
1692 assert_eq!(
1693 recovery.kind,
1694 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1695 );
1696 assert_eq!(recovery.source_id, source_id);
1697 assert_eq!(recovery.target_id, target_id);
1698 assert_eq!(recovery.rel_type, "OWNS");
1699 }
1700
1701 #[test]
1707 fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
1708 let tmp = TempDir::new().unwrap();
1709 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1713 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1714 let archive_path = build_archive(
1715 tmp.path(),
1716 "ext",
1717 &[
1718 ("target.md", target.as_bytes()),
1719 ("source.md", source.as_bytes()),
1720 ],
1721 );
1722
1723 let engine = Engine::from_mounts(vec![(
1724 archive_mount("external", archive_path.clone()),
1725 Box::new(ArchiveBackend::new(archive_path)),
1726 )])
1727 .unwrap();
1728
1729 let invalid: Vec<_> = engine
1730 .load_warnings()
1731 .iter()
1732 .filter_map(|w| match w {
1733 WarningHint::ParsedRelationInvalid {
1734 rel_type,
1735 reason,
1736 origin,
1737 recovery,
1738 ..
1739 } => Some((
1740 rel_type.clone(),
1741 reason.clone(),
1742 origin.clone(),
1743 recovery.clone(),
1744 )),
1745 _ => None,
1746 })
1747 .collect();
1748 assert_eq!(
1749 invalid.len(),
1750 1,
1751 "expected one parse-time drop on the readonly mount, got {invalid:?}",
1752 );
1753 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1754 assert_eq!(invalid[0].1, "unknown_rel_type");
1755 assert_eq!(invalid[0].2, "readonly");
1756 assert!(
1757 invalid[0].3.is_none(),
1758 "readonly-origin warning must omit the recovery hint, got {:?}",
1759 invalid[0].3,
1760 );
1761 }
1762
1763 #[test]
1764 fn load_on_init_populates_store_from_folder_mount() {
1765 let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1768
1769 let tmp = TempDir::new().unwrap();
1770 let mem_dir = tmp.path().to_path_buf();
1771 let writer = FilesystemMemWriter::new(mem_dir.clone());
1772 <FilesystemMemWriter as MemWriter>::write_entity(
1773 &writer,
1774 Path::new("hello.md"),
1775 body.as_bytes(),
1776 )
1777 .unwrap();
1778 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1779 .unwrap();
1780
1781 let engine = Engine::from_mounts(vec![(
1782 folder_mount("specs", mem_dir),
1783 Box::new(writer) as Box<dyn MemBackend>,
1784 )])
1785 .unwrap();
1786
1787 assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1789 let id = crate::EntityId::new("specs", "hello");
1790 let entity = engine.get_entity(&id).expect("entity must be present");
1791 assert_eq!(entity.title, "Hello");
1792 assert_eq!(entity.entity_type, "spec");
1793 assert!(engine.load_errors().is_empty());
1794 assert_eq!(engine.schemas().len(), 1);
1796 assert!(engine.schemas().contains_key("specs"));
1797 }
1798
1799 #[test]
1800 fn load_on_init_populates_store_from_archive_mount() {
1801 let body =
1802 "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1803
1804 let tmp = TempDir::new().unwrap();
1805 let archive_path =
1806 build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1807
1808 let engine = Engine::from_mounts(vec![(
1809 archive_mount("external", archive_path.clone()),
1810 Box::new(ArchiveBackend::new(archive_path)),
1811 )])
1812 .unwrap();
1813
1814 let id = crate::EntityId::new("external", "from-archive");
1815 let entity = engine.get_entity(&id).expect("entity must be present");
1816 assert_eq!(entity.title, "From Archive");
1817 assert!(engine.load_errors().is_empty());
1818 }
1819
1820 #[test]
1821 fn load_on_init_populates_store_from_heterogeneous_mounts() {
1822 let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1823 let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1824
1825 let tmp = TempDir::new().unwrap();
1826
1827 let folder_dir = tmp.path().join("folder-mem");
1828 std::fs::create_dir_all(&folder_dir).unwrap();
1829 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1830 <FilesystemMemWriter as MemWriter>::write_entity(
1831 &folder_writer,
1832 Path::new("local.md"),
1833 folder_body.as_bytes(),
1834 )
1835 .unwrap();
1836 <FilesystemMemWriter as MemWriter>::commit(
1837 &folder_writer,
1838 "seed",
1839 &CommitContext::internal(),
1840 )
1841 .unwrap();
1842
1843 let archive_path = build_archive(
1844 tmp.path(),
1845 "external",
1846 &[("external.md", archive_body.as_bytes())],
1847 );
1848
1849 let engine = Engine::from_mounts(vec![
1850 (
1851 folder_mount("local", folder_dir),
1852 Box::new(folder_writer) as Box<dyn MemBackend>,
1853 ),
1854 (
1855 archive_mount("external", archive_path.clone()),
1856 Box::new(ArchiveBackend::new(archive_path)),
1857 ),
1858 ])
1859 .unwrap();
1860
1861 assert_eq!(engine.store().len(), 2);
1863 assert!(
1864 engine
1865 .get_entity(&crate::EntityId::new("local", "local"))
1866 .is_some()
1867 );
1868 assert!(
1869 engine
1870 .get_entity(&crate::EntityId::new("external", "external"))
1871 .is_some()
1872 );
1873 }
1874
1875 #[test]
1876 fn load_on_init_collects_per_file_parse_errors_without_failing() {
1877 let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1881 let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1882
1883 let tmp = TempDir::new().unwrap();
1884 let mem_dir = tmp.path().to_path_buf();
1885 let writer = FilesystemMemWriter::new(mem_dir.clone());
1886 <FilesystemMemWriter as MemWriter>::write_entity(
1887 &writer,
1888 Path::new("good.md"),
1889 good.as_bytes(),
1890 )
1891 .unwrap();
1892 <FilesystemMemWriter as MemWriter>::write_entity(
1893 &writer,
1894 Path::new("bad.md"),
1895 bad.as_bytes(),
1896 )
1897 .unwrap();
1898 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1899 .unwrap();
1900
1901 let engine = Engine::from_mounts(vec![(
1902 folder_mount("specs", mem_dir),
1903 Box::new(writer) as Box<dyn MemBackend>,
1904 )])
1905 .unwrap();
1906
1907 assert!(
1909 engine
1910 .get_entity(&crate::EntityId::new("specs", "good"))
1911 .is_some(),
1912 "good.md must parse and reach the store"
1913 );
1914 let bad_known_to_engine = engine
1919 .get_entity(&crate::EntityId::new("specs", "bad"))
1920 .is_some()
1921 || !engine.load_errors().is_empty();
1922 assert!(
1923 bad_known_to_engine,
1924 "bad.md must either parse or surface in load_errors"
1925 );
1926 }
1927
1928 #[test]
1929 fn empty_mount_list_yields_empty_store() {
1930 let engine = Engine::from_mounts(Vec::new()).unwrap();
1931 assert!(engine.store().is_empty());
1932 assert!(engine.schemas().is_empty());
1933 assert!(engine.load_errors().is_empty());
1934 }
1935
1936 #[test]
1939 fn from_workspace_root_errors_for_empty_layout() {
1940 let tmp = TempDir::new().unwrap();
1941 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1942 match err {
1943 crate::BootError::NotInitialised(p) => {
1944 assert_eq!(p, tmp.path());
1945 }
1946 other => panic!("expected NotInitialised, got {other:?}"),
1947 }
1948 }
1949
1950 #[test]
1951 fn from_workspace_root_loads_new_two_layer_layout() {
1952 let tmp = TempDir::new().unwrap();
1953 let mem_dir = tmp.path().join("mem");
1954 std::fs::create_dir_all(&mem_dir).unwrap();
1955 std::fs::write(
1956 mem_dir.join("hello.md"),
1957 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1958 )
1959 .unwrap();
1960
1961 let memstead = tmp.path().join(".memstead");
1962 std::fs::create_dir_all(&memstead).unwrap();
1963 std::fs::write(
1964 memstead.join("workspace.toml"),
1965 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1966 )
1967 .unwrap();
1968 use crate::workspace_store::WorkspaceStoreAdapter;
1971 let store = crate::FileWorkspaceStore::new();
1972 store
1973 .save_state(
1974 tmp.path(),
1975 &crate::workspace::Workspace {
1976 mounts: vec![folder_mount("specs", mem_dir)],
1977 settings: crate::workspace::WorkspaceSettings::default(),
1978 },
1979 )
1980 .unwrap();
1981
1982 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1983 assert_eq!(engine.mem_names(), vec!["specs"]);
1984 let entity = engine
1985 .get_entity(&crate::EntityId::new("specs", "hello"))
1986 .expect("seeded entity must load through from_workspace_root");
1987 assert_eq!(entity.title, "Hello");
1988 }
1989
1990 #[test]
1995 fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1996 use crate::pipeline::{MediumType, Projection};
1997 let tmp = TempDir::new().unwrap();
1998 let mem_dir = tmp.path().join("mem");
1999 std::fs::create_dir_all(&mem_dir).unwrap();
2000
2001 let memstead = tmp.path().join(".memstead");
2002 std::fs::create_dir_all(&memstead).unwrap();
2003 std::fs::write(
2004 memstead.join("workspace.toml"),
2005 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2006 )
2007 .unwrap();
2008 use crate::workspace_store::WorkspaceStoreAdapter;
2009 crate::FileWorkspaceStore::new()
2010 .save_state(
2011 tmp.path(),
2012 &crate::workspace::Workspace {
2013 mounts: vec![folder_mount("specs", mem_dir)],
2014 settings: crate::workspace::WorkspaceSettings::default(),
2015 },
2016 )
2017 .unwrap();
2018
2019 crate::pipeline_store::write_binding(
2021 tmp.path(),
2022 "specs",
2023 "graph",
2024 &sample_v2_binding("specs"),
2025 )
2026 .unwrap();
2027
2028 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2029 let pc = engine.pipeline_configs();
2030 assert_eq!(pc.bindings.len(), 1, "one binding enumerated");
2031 assert_eq!(pc.bindings[0].mem, "specs");
2032 assert_eq!(pc.bindings[0].name, "graph");
2033 assert_eq!(pc.bindings[0].config.destination_mem, "specs");
2034 assert_eq!(
2035 pc.bindings[0].config.sources[0].medium_type,
2036 MediumType::Codebase
2037 );
2038
2039 crate::pipeline_store::write_projection(
2046 tmp.path(),
2047 "specs",
2048 "legacy",
2049 &Projection {
2050 intent: None,
2051 source_facets: vec!["view".to_string()],
2052 reference_mems: Vec::new(),
2053 destination_mem: "specs".to_string(),
2054 rules: None,
2055 },
2056 )
2057 .unwrap();
2058 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2059 let pc = engine.pipeline_configs();
2060 assert_eq!(pc.bindings.len(), 1, "the healthy binding still serves");
2061 assert_eq!(pc.quarantined.len(), 1, "the legacy one quarantines");
2062 assert_eq!(pc.quarantined[0].name, "legacy");
2063 assert_eq!(pc.quarantined[0].reason_code, "PROJECTION_STORE_LEGACY");
2064 assert!(
2065 pc.quarantined[0]
2066 .reason_message
2067 .contains("memstead projection migrate"),
2068 "quarantine reason names the migrate command, got: {}",
2069 pc.quarantined[0].reason_message
2070 );
2071 }
2072
2073 fn sample_v2_binding(dest: &str) -> crate::binding::Binding {
2076 v2_binding_with_pointer(dest, "..")
2077 }
2078
2079 fn v2_binding_with_pointer(dest: &str, pointer: &str) -> crate::binding::Binding {
2080 use crate::binding::{BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations};
2081 use crate::pipeline::{IngestTrigger, MediumType, Source};
2082 Binding {
2083 version: BINDING_VERSION,
2084 intent: None,
2085 sources: vec![Source {
2086 name: "src".to_string(),
2087 medium_type: MediumType::Codebase,
2088 pointer: pointer.to_string(),
2089 change_detection: None,
2090 scope: Vec::new(),
2091 engagement: None,
2092 preparation: None,
2093 }],
2094 reference_mems: Vec::new(),
2095 destination_mem: dest.to_string(),
2096 deny_paths: Vec::new(),
2097 coverage_semantics: None,
2098 rules: None,
2099 prune: None,
2100 operations: Operations {
2101 build: Some(BuildOperation {
2102 mode: BuildMode::Discovery,
2103 trigger: IngestTrigger::Loop,
2104 batch_size: 10,
2105 post_actions: None,
2106 }),
2107 sync: None,
2108 verify: None,
2109 },
2110 }
2111 }
2112
2113 #[test]
2122 fn url_anchors_adjudicate_from_supplied_observations_and_age() {
2123 use crate::anchor::{
2124 AnchorInput, AnchorState, SuppliedObservationInput, validate_supplied_observations,
2125 };
2126 use crate::vcs::Actor;
2127 use crate::workspace_store::WorkspaceStoreAdapter;
2128 use indexmap::IndexMap;
2129
2130 let tmp = TempDir::new().unwrap();
2131 let mem_dir = tmp.path().join("mem");
2132 std::fs::create_dir_all(&mem_dir).unwrap();
2133 std::fs::write(
2134 mem_dir.join("hello.md"),
2135 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
2136 )
2137 .unwrap();
2138
2139 let memstead = tmp.path().join(".memstead");
2140 std::fs::create_dir_all(&memstead).unwrap();
2141 std::fs::write(
2142 memstead.join("workspace.toml"),
2143 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2144 )
2145 .unwrap();
2146 crate::FileWorkspaceStore::new()
2147 .save_state(
2148 tmp.path(),
2149 &crate::workspace::Workspace {
2150 mounts: vec![folder_mount("specs", mem_dir.clone())],
2151 settings: crate::workspace::WorkspaceSettings::default(),
2152 },
2153 )
2154 .unwrap();
2155 crate::pipeline_store::write_binding(
2158 tmp.path(),
2159 "specs",
2160 "graph",
2161 &v2_binding_with_pointer("specs", "src"),
2162 )
2163 .unwrap();
2164 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2165
2166 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2167 let clock_now = "2026-09-02T12:00:00Z";
2168 let pinned = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_788_350_400);
2169 engine.set_mutation_clock(std::sync::Arc::new(move || pinned));
2170 assert_eq!(
2171 engine.now_iso(),
2172 clock_now,
2173 "clock pin is the date the ages count from"
2174 );
2175
2176 let url = |artifact: &str, stability: &str, content: &str| AnchorInput {
2177 artifact: Some(artifact.to_string()),
2178 grain: Some("url".to_string()),
2179 class: Some("anchored".to_string()),
2180 content: Some(content.to_string()),
2181 hash_stability: Some(stability.to_string()),
2182 ..Default::default()
2183 };
2184 let mut sections = IndexMap::new();
2185 sections.insert("identity".to_string(), "Cites the web.".to_string());
2186 sections.insert("purpose".to_string(), "Four url anchors.".to_string());
2187 let created = engine
2188 .create_entity(
2189 crate::CreateEntityArgs {
2190 mem: "specs".to_string(),
2191 title: "Cites".to_string(),
2192 entity_type: "spec".to_string(),
2193 sections,
2194 metadata: IndexMap::new(),
2195 relations: Vec::new(),
2196 anchors: vec![
2197 url("https://w.test/stable", "stable", "immutable text"),
2198 url("https://w.test/living", "unstable", "page v1"),
2199 url("https://w.test/gone", "unstable", "was here"),
2200 url("https://w.test/never", "unstable", "never observed again"),
2201 ],
2202 dry_run: false,
2203 },
2204 Actor::Agent,
2205 None,
2206 None,
2207 )
2208 .expect("url anchors beside a single path source are legal");
2209 let before = engine.entity_anchors_resolved(&created.id);
2210 assert_eq!(before.len(), 4);
2211 assert!(
2212 before
2213 .iter()
2214 .all(|r| r.state.is_none() && r.observed_at.is_none()),
2215 "no observation yet: every url row unobserved"
2216 );
2217 assert!(
2218 before
2219 .iter()
2220 .all(|r| r.anchor.hash_source == Some(crate::anchor::AnchorHashSource::Author)),
2221 "content supplied at write: author baseline"
2222 );
2223
2224 let rows = vec![
2227 SuppliedObservationInput {
2228 artifact: Some("https://w.test/stable".into()),
2229 hash: Some(crate::anchor::prepared_content_hash(b"immutable text")),
2230 ..Default::default()
2231 },
2232 SuppliedObservationInput {
2233 artifact: Some("https://w.test/living".into()),
2234 content: Some("page v2".into()),
2235 observed_at: Some("2026-08-03T09:00:00Z".into()),
2236 ..Default::default()
2237 },
2238 SuppliedObservationInput {
2239 artifact: Some("https://w.test/gone".into()),
2240 absent: Some(true),
2241 ..Default::default()
2242 },
2243 SuppliedObservationInput {
2244 artifact: Some("https://elsewhere.test/unrelated".into()),
2245 hash: Some("zz".into()),
2246 ..Default::default()
2247 },
2248 ];
2249 let supplied = validate_supplied_observations(&rows, clock_now).unwrap();
2250 let report = engine.verify_mem_anchors_with("specs", &supplied).unwrap();
2251 let state_of = |artifact: &str| {
2252 report
2253 .anchors
2254 .iter()
2255 .find(|a| a.artifact == artifact)
2256 .map(|a| {
2257 (
2258 a.state.clone(),
2259 a.unobserved_for_days,
2260 a.observation_supplied,
2261 )
2262 })
2263 .unwrap()
2264 };
2265 assert_eq!(
2266 state_of("https://w.test/stable"),
2267 ("resolves".into(), Some(0), true)
2268 );
2269 assert_eq!(
2270 state_of("https://w.test/living"),
2271 ("recheck".into(), Some(30), true)
2272 );
2273 assert_eq!(
2274 state_of("https://w.test/gone"),
2275 ("recheck".into(), Some(0), true)
2276 );
2277 assert_eq!(
2278 state_of("https://w.test/never"),
2279 ("unobserved".into(), None, false)
2280 );
2281 assert_eq!(
2282 report.unmatched_observations,
2283 vec!["https://elsewhere.test/unrelated"]
2284 );
2285 assert_eq!(report.recordable_observations.len(), 3);
2286 assert_eq!(
2287 (report.resolves, report.recheck, report.unobserved),
2288 (1, 2, 1)
2289 );
2290
2291 assert!(
2293 engine
2294 .entity_anchors_resolved(&created.id)
2295 .iter()
2296 .all(|r| r.state.is_none()),
2297 "verify writes nothing"
2298 );
2299
2300 let written = engine
2303 .record_anchor_observations("specs", &report.recordable_observations, None)
2304 .unwrap();
2305 assert_eq!(written, 3);
2306 let after = engine.entity_anchors_resolved(&created.id);
2307 let row = |artifact: &str| {
2308 after
2309 .iter()
2310 .find(|r| r.anchor.artifact == artifact)
2311 .unwrap()
2312 };
2313 assert_eq!(
2314 row("https://w.test/stable").state,
2315 Some(AnchorState::Resolves)
2316 );
2317 assert_eq!(
2318 row("https://w.test/living").state,
2319 Some(AnchorState::Recheck)
2320 );
2321 assert_eq!(
2322 row("https://w.test/living").observed_at.as_deref(),
2323 Some("2026-08-03T09:00:00Z")
2324 );
2325 assert_eq!(row("https://w.test/gone").state, Some(AnchorState::Recheck));
2326 assert_eq!(row("https://w.test/never").state, None, "still unobserved");
2327 let sidecar_bytes = std::fs::read(mem_dir.join(".memstead").join("anchors.json")).unwrap();
2328 let sidecar: serde_json::Value = serde_json::from_slice(&sidecar_bytes).unwrap();
2329 assert_eq!(sidecar["version"], 2);
2330 assert_eq!(
2332 engine
2333 .record_anchor_observations("specs", &report.recordable_observations, None)
2334 .unwrap(),
2335 0
2336 );
2337
2338 let report2 = engine.verify_mem_anchors("specs").unwrap();
2343 let aged = report2
2344 .anchors
2345 .iter()
2346 .find(|a| a.artifact == "https://w.test/living")
2347 .unwrap();
2348 assert_eq!(aged.unobserved_for_days, Some(30));
2349 assert!(!aged.observation_supplied);
2350 let axis = crate::ops::health::health_anchors_axis(&engine, None);
2351 let aging = axis["specs"]["aging"].as_array().unwrap();
2352 assert!(
2353 aging
2354 .iter()
2355 .any(|a| a["artifact"] == "https://w.test/living"
2356 && a["note"] == "unobserved for 30 days"),
2357 "{axis}"
2358 );
2359 let open = crate::ops::health::health_open_questions_axis(&engine, Some("specs"));
2360 assert!(
2361 open["specs"]["anchors_aging"]["items"]
2362 .as_array()
2363 .is_some_and(|items| items.iter().any(|i| i["note"] == "unobserved for 30 days")),
2364 "{open}"
2365 );
2366
2367 let drift_rows = vec![SuppliedObservationInput {
2368 artifact: Some("https://w.test/stable".into()),
2369 content: Some("immutable text, edited after all".into()),
2370 ..Default::default()
2371 }];
2372 let supplied = validate_supplied_observations(&drift_rows, clock_now).unwrap();
2373 let report3 = engine.verify_mem_anchors_with("specs", &supplied).unwrap();
2374 assert_eq!(
2375 state_of_report(&report3, "https://w.test/stable"),
2376 "drifted"
2377 );
2378 assert_eq!(report3.drifted, 1);
2379 }
2380
2381 #[test]
2387 fn sidecar_v1_fixture_loads_and_next_anchor_write_rewrites_v2_while_v3_refuses() {
2388 use crate::anchor::AnchorInput;
2389 use crate::vcs::Actor;
2390 use crate::workspace_store::WorkspaceStoreAdapter;
2391 use indexmap::IndexMap;
2392
2393 let tmp = TempDir::new().unwrap();
2394 let mem_dir = tmp.path().join("mem");
2395 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2396 std::fs::write(
2397 mem_dir.join("hello.md"),
2398 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
2399 )
2400 .unwrap();
2401 let v1 = r#"{"version":1,"entities":{"specs--hello":[{"artifact":"https://fixture.test/doc","grain":"url","class":"anchored","hash":"author-pinned","hash_stability":"stable","hash_source":"author"}]}}"#;
2402 std::fs::write(mem_dir.join(".memstead").join("anchors.json"), v1).unwrap();
2403 let memstead = tmp.path().join(".memstead");
2404 std::fs::create_dir_all(&memstead).unwrap();
2405 std::fs::write(
2406 memstead.join("workspace.toml"),
2407 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2408 )
2409 .unwrap();
2410 crate::FileWorkspaceStore::new()
2411 .save_state(
2412 tmp.path(),
2413 &crate::workspace::Workspace {
2414 mounts: vec![folder_mount("specs", mem_dir.clone())],
2415 settings: crate::workspace::WorkspaceSettings::default(),
2416 },
2417 )
2418 .unwrap();
2419 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2420
2421 let rows = engine.entity_anchors_resolved(&crate::EntityId::canonical("specs--hello"));
2422 assert_eq!(rows.len(), 1, "the version-1 row loads");
2423 assert_eq!(rows[0].anchor.hash.as_deref(), Some("author-pinned"));
2424 assert!(rows[0].anchor.last_observed.is_none());
2425 assert!(rows[0].state.is_none(), "never observed: unobserved");
2426 let on_disk =
2427 std::fs::read_to_string(mem_dir.join(".memstead").join("anchors.json")).unwrap();
2428 assert_eq!(on_disk, v1, "reading rewrites nothing");
2429
2430 let mut sections = IndexMap::new();
2433 sections.insert("identity".to_string(), "Second.".to_string());
2434 sections.insert("purpose".to_string(), "Second.".to_string());
2435 engine
2436 .create_entity(
2437 crate::CreateEntityArgs {
2438 mem: "specs".to_string(),
2439 title: "Second".to_string(),
2440 entity_type: "spec".to_string(),
2441 sections,
2442 metadata: IndexMap::new(),
2443 relations: Vec::new(),
2444 anchors: vec![AnchorInput {
2445 artifact: Some("https://fixture.test/other".to_string()),
2446 grain: Some("url".to_string()),
2447 class: Some("informed-by".to_string()),
2448 ..Default::default()
2449 }],
2450 dry_run: false,
2451 },
2452 Actor::Agent,
2453 None,
2454 None,
2455 )
2456 .unwrap();
2457 let after: serde_json::Value = serde_json::from_slice(
2458 &std::fs::read(mem_dir.join(".memstead").join("anchors.json")).unwrap(),
2459 )
2460 .unwrap();
2461 assert_eq!(after["version"], 2);
2462 assert_eq!(
2463 after["entities"]["specs--hello"][0]["hash"],
2464 "author-pinned"
2465 );
2466 assert!(
2467 after["entities"]["specs--hello"][0]
2468 .get("last_observed")
2469 .is_none()
2470 );
2471
2472 std::fs::write(
2474 mem_dir.join(".memstead").join("anchors.json"),
2475 r#"{"version":3,"entities":{}}"#,
2476 )
2477 .unwrap();
2478 let mut sections = IndexMap::new();
2479 sections.insert("identity".to_string(), "Third.".to_string());
2480 sections.insert("purpose".to_string(), "Third.".to_string());
2481 let err = engine
2482 .create_entity(
2483 crate::CreateEntityArgs {
2484 mem: "specs".to_string(),
2485 title: "Third".to_string(),
2486 entity_type: "spec".to_string(),
2487 sections,
2488 metadata: IndexMap::new(),
2489 relations: Vec::new(),
2490 anchors: vec![AnchorInput {
2491 artifact: Some("https://fixture.test/third".to_string()),
2492 grain: Some("url".to_string()),
2493 class: Some("informed-by".to_string()),
2494 ..Default::default()
2495 }],
2496 dry_run: false,
2497 },
2498 Actor::Agent,
2499 None,
2500 None,
2501 )
2502 .expect_err("a version-3 sidecar refuses");
2503 assert!(
2504 err.to_string()
2505 .contains("unsupported anchors sidecar version 3"),
2506 "{err}"
2507 );
2508 }
2509
2510 fn state_of_report(
2511 report: &crate::engine::query::MemAnchorVerification,
2512 artifact: &str,
2513 ) -> String {
2514 report
2515 .anchors
2516 .iter()
2517 .find(|a| a.artifact == artifact)
2518 .map(|a| a.state.clone())
2519 .unwrap()
2520 }
2521
2522 #[test]
2530 fn entity_anchors_resolve_live_state_for_path_medium() {
2531 use crate::anchor::{AnchorInput, AnchorState};
2532 use crate::vcs::Actor;
2533 use crate::workspace_store::WorkspaceStoreAdapter;
2534 use indexmap::IndexMap;
2535
2536 let tmp = TempDir::new().unwrap();
2537 let mem_dir = tmp.path().join("mem");
2538 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2539 std::fs::write(
2540 mem_dir.join(".memstead").join("config.json"),
2541 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2542 )
2543 .unwrap();
2544
2545 let memstead = tmp.path().join(".memstead");
2546 std::fs::create_dir_all(&memstead).unwrap();
2547 std::fs::write(
2548 memstead.join("workspace.toml"),
2549 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2550 )
2551 .unwrap();
2552 crate::FileWorkspaceStore::new()
2553 .save_state(
2554 tmp.path(),
2555 &crate::workspace::Workspace {
2556 mounts: vec![folder_mount("specs", mem_dir.clone())],
2557 settings: crate::workspace::WorkspaceSettings::default(),
2558 },
2559 )
2560 .unwrap();
2561
2562 crate::pipeline_store::write_binding(
2568 tmp.path(),
2569 "specs",
2570 "graph",
2571 &v2_binding_with_pointer("specs", "src"),
2572 )
2573 .unwrap();
2574 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2575 std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
2576 std::fs::write(tmp.path().join("src").join("gone.rs"), "fn gone() {}").unwrap();
2579
2580 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2581
2582 let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
2583 artifact: Some(artifact.to_string()),
2584 grain: Some("file".to_string()),
2585 class: Some(class.to_string()),
2586 hash: hash.map(str::to_string),
2587 hash_stability: Some("stable".to_string()),
2588 ..Default::default()
2589 };
2590 let mut sections = IndexMap::new();
2591 sections.insert("identity".to_string(), "Covers src.".to_string());
2592 sections.insert("purpose".to_string(), "Track sources.".to_string());
2593 let present_hash = crate::anchor::prepared_content_hash(
2596 &std::fs::read(tmp.path().join("src").join("present.rs")).unwrap(),
2597 );
2598 let created = engine
2599 .create_entity(
2600 crate::CreateEntityArgs {
2601 mem: "specs".to_string(),
2602 title: "Covers".to_string(),
2603 entity_type: "spec".to_string(),
2604 sections,
2605 metadata: IndexMap::new(),
2606 relations: Vec::new(),
2607 anchors: vec![
2608 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")), ],
2613 dry_run: false,
2614 },
2615 Actor::Agent,
2616 None,
2617 None,
2618 )
2619 .unwrap();
2620
2621 std::fs::remove_file(tmp.path().join("src").join("gone.rs")).unwrap();
2622 let resolved = engine.entity_anchors_resolved(&created.id);
2623 assert_eq!(resolved.len(), 4);
2624 let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
2625 resolved
2626 .iter()
2627 .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
2628 .and_then(|r| r.state)
2629 };
2630 assert_eq!(
2631 state_of(
2632 "src/present.rs",
2633 crate::anchor::AnchorProvenanceClass::Anchored
2634 ),
2635 Some(AnchorState::Resolves),
2636 "recorded hash matches the observed prepared form → resolves"
2637 );
2638 assert_eq!(
2639 state_of(
2640 "src/present.rs",
2641 crate::anchor::AnchorProvenanceClass::Derived
2642 ),
2643 Some(AnchorState::Drifted),
2644 "recorded hash mismatches the observed prepared form on a stable medium → drifted"
2645 );
2646 assert_eq!(
2647 state_of(
2648 "src/present.rs",
2649 crate::anchor::AnchorProvenanceClass::InformedBy
2650 ),
2651 Some(AnchorState::Resolves),
2652 "present non-hash anchor resolves on existence"
2653 );
2654 assert_eq!(
2655 state_of(
2656 "src/gone.rs",
2657 crate::anchor::AnchorProvenanceClass::Anchored
2658 ),
2659 Some(AnchorState::Orphaned),
2660 "absent artifact is orphaned"
2661 );
2662 }
2663
2664 #[test]
2669 fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
2670 use crate::workspace_store::WorkspaceStoreAdapter;
2671
2672 let tmp = TempDir::new().unwrap();
2673 let mem_dir = tmp.path().join("mem");
2674 std::fs::create_dir_all(&mem_dir).unwrap();
2675 let memstead = tmp.path().join(".memstead");
2676 std::fs::create_dir_all(&memstead).unwrap();
2677 std::fs::write(
2678 memstead.join("workspace.toml"),
2679 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2680 )
2681 .unwrap();
2682 crate::FileWorkspaceStore::new()
2683 .save_state(
2684 tmp.path(),
2685 &crate::workspace::Workspace {
2686 mounts: vec![folder_mount("specs", mem_dir)],
2687 settings: crate::workspace::WorkspaceSettings::default(),
2688 },
2689 )
2690 .unwrap();
2691
2692 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2693 assert!(engine.pipeline_configs().bindings.is_empty());
2694
2695 engine
2697 .add_projection_json(
2698 "specs",
2699 "graph",
2700 r#"{
2701 "sources": [{ "name": "src", "type": "codebase", "pointer": "..",
2702 "scope": [{ "path": "**/*.rs", "mode": "allow" }] }],
2703 "destination_mem": "specs"
2704 }"#,
2705 None,
2706 )
2707 .unwrap();
2708 assert_eq!(engine.pipeline_configs().bindings.len(), 1);
2710 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph");
2711 assert_eq!(
2712 engine.pipeline_configs().bindings[0].config.sources[0].name,
2713 "src"
2714 );
2715
2716 let err = engine
2718 .add_projection_json("specs", "bad", "{ not json", None)
2719 .unwrap_err();
2720 assert!(
2721 matches!(
2722 err,
2723 crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
2724 ),
2725 "got {err:?}"
2726 );
2727
2728 engine
2730 .update_projection_json("specs", "graph", r#"{"intent":"i2"}"#, None)
2731 .unwrap();
2732 assert_eq!(
2733 engine.pipeline_configs().bindings[0]
2734 .config
2735 .intent
2736 .as_deref(),
2737 Some("i2")
2738 );
2739
2740 engine
2742 .rename_projection("specs", "graph", "graph2", None)
2743 .unwrap();
2744 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph2");
2745
2746 let json = engine.pipeline_configs_json();
2749 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2750 assert!(parsed.get("mediums").is_none(), "no mediums key: {json}");
2751 assert!(parsed.get("facets").is_none(), "no facets key: {json}");
2752 let bindings = parsed["bindings"].as_array().unwrap();
2753 assert_eq!(bindings.len(), 1);
2754 assert_eq!(bindings[0]["name"], "graph2");
2755 assert_eq!(bindings[0]["config"]["sources"][0]["type"], "codebase");
2756
2757 engine.delete_projection("specs", "graph2", None).unwrap();
2758 assert!(engine.pipeline_configs().bindings.is_empty());
2759 }
2760
2761 #[test]
2767 fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
2768 use crate::engine::test_helpers::write_schema_files_with_default_type;
2769
2770 let tmp = TempDir::new().unwrap();
2771 let mem_dir = tmp.path().join("mem");
2772 std::fs::create_dir_all(&mem_dir).unwrap();
2773
2774 let authored_dir = tmp.path().join(".memstead").join("schemas");
2776 let manifest = r#"name: authored
2777version: 0.1.0
2778description: an authored-in-workspace test schema
2779when_to_use: tests
2780types:
2781 - doc
2782relationships:
2783 mode: strict
2784 definitions:
2785 - name: _default
2786 description: fallback
2787 default_weight: 1.0
2788community:
2789 resolution: 1.0
2790 seed: 42
2791"#;
2792 write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
2793
2794 let memstead = tmp.path().join(".memstead");
2796 std::fs::create_dir_all(&memstead).unwrap();
2797 std::fs::write(
2798 memstead.join("workspace.toml"),
2799 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2800 )
2801 .unwrap();
2802 let mount = Mount {
2803 mem: "specs".to_string(),
2804 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2805 storage: MountStorage::Folder { path: mem_dir },
2806 capability: MountCapability::Write,
2807 lifecycle: MountLifecycle::Eager,
2808 cross_linkable: true,
2809 migration_target: None,
2810 };
2811 use crate::workspace_store::WorkspaceStoreAdapter;
2812 crate::FileWorkspaceStore::new()
2813 .save_state(
2814 tmp.path(),
2815 &crate::workspace::Workspace {
2816 mounts: vec![mount],
2817 settings: crate::workspace::WorkspaceSettings::default(),
2818 },
2819 )
2820 .unwrap();
2821
2822 let engine = Engine::from_workspace_root(tmp.path())
2825 .expect("authored schema at .memstead/schemas/ must resolve at boot");
2826 assert_eq!(engine.mem_names(), vec!["specs"]);
2827 }
2828
2829 #[test]
2835 fn health_reports_authoring_drift_for_stamped_schemas_only() {
2836 use crate::engine::test_helpers::write_schema_files_with_default_type;
2837
2838 let tmp = TempDir::new().unwrap();
2839 let mem_dir = tmp.path().join("mem");
2840 std::fs::create_dir_all(&mem_dir).unwrap();
2841 let manifest = r#"name: authored
2842version: 0.1.0
2843description: an authored-in-workspace test schema
2844when_to_use: tests
2845types:
2846 - doc
2847relationships:
2848 mode: strict
2849 definitions:
2850 - name: _default
2851 description: fallback
2852 default_weight: 1.0
2853community:
2854 resolution: 1.0
2855 seed: 42
2856"#;
2857 let sealed_root = tmp.path().join(".memstead").join("schemas");
2860 write_schema_files_with_default_type(&sealed_root, "authored@0.1.0", manifest, &["doc"]);
2861 let author_root = tmp.path().join("author");
2862 write_schema_files_with_default_type(&author_root, "authored@0.1.0", manifest, &["doc"]);
2863 let authoring_dir = author_root.join("authored@0.1.0");
2864 let sealed_dir = sealed_root.join("authored@0.1.0");
2865 let stamp_path = sealed_dir.join(memstead_schema::INSTALL_PROVENANCE_FILE);
2867 std::fs::write(
2868 &stamp_path,
2869 serde_json::to_vec_pretty(&serde_json::json!({
2870 "authoring_path": authoring_dir.display().to_string(),
2871 }))
2872 .unwrap(),
2873 )
2874 .unwrap();
2875
2876 let memstead = tmp.path().join(".memstead");
2877 std::fs::write(
2878 memstead.join("workspace.toml"),
2879 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2880 )
2881 .unwrap();
2882 let mount = Mount {
2883 mem: "specs".to_string(),
2884 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2885 storage: MountStorage::Folder { path: mem_dir },
2886 capability: MountCapability::Write,
2887 lifecycle: MountLifecycle::Eager,
2888 cross_linkable: true,
2889 migration_target: None,
2890 };
2891 use crate::workspace_store::WorkspaceStoreAdapter;
2892 crate::FileWorkspaceStore::new()
2893 .save_state(
2894 tmp.path(),
2895 &crate::workspace::Workspace {
2896 mounts: vec![mount],
2897 settings: crate::workspace::WorkspaceSettings::default(),
2898 },
2899 )
2900 .unwrap();
2901 let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
2902 let drift_codes = |e: &Engine| -> Vec<String> {
2903 e.health()
2904 .warnings
2905 .iter()
2906 .filter(|w| w.code().starts_with("SCHEMA_AUTHORING_SOURCE_"))
2907 .map(|w| w.code().to_string())
2908 .collect()
2909 };
2910
2911 let sealed_before = std::fs::read(sealed_dir.join("schema.yaml")).unwrap();
2914 let author_before = std::fs::read(authoring_dir.join("schema.yaml")).unwrap();
2915 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2916 assert_eq!(
2917 std::fs::read(sealed_dir.join("schema.yaml")).unwrap(),
2918 sealed_before,
2919 "health must not touch the sealed copy"
2920 );
2921 assert_eq!(
2922 std::fs::read(authoring_dir.join("schema.yaml")).unwrap(),
2923 author_before,
2924 "health must not touch the authoring copy"
2925 );
2926
2927 std::fs::write(
2931 authoring_dir.join("schema.yaml"),
2932 format!(
2933 "# yaml-language-server: $schema=../../.memstead/meta-schemas/schema-manifest.json\n# cosmetic comment\n{manifest}"
2934 ),
2935 )
2936 .unwrap();
2937 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2938
2939 std::fs::write(
2942 authoring_dir.join("schema.yaml"),
2943 manifest.replace(
2944 "an authored-in-workspace test schema",
2945 "a semantically different description",
2946 ),
2947 )
2948 .unwrap();
2949 let warnings = engine.health().warnings;
2950 let diverged = warnings
2951 .iter()
2952 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED")
2953 .expect("semantic change must surface as DIVERGED");
2954 let d = serde_json::to_value(diverged).unwrap();
2955 assert_eq!(d["details"]["schema_ref"], "authored@0.1.0");
2956 assert_eq!(d["details"]["mems"], serde_json::json!(["specs"]));
2957
2958 std::fs::remove_dir_all(&authoring_dir).unwrap();
2960 let warnings = engine.health().warnings;
2961 let missing = warnings
2962 .iter()
2963 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_MISSING")
2964 .expect("vanished authoring package must surface as MISSING");
2965 let m = serde_json::to_value(missing).unwrap();
2966 assert_eq!(m["details"]["schema_ref"], "authored@0.1.0");
2967 assert_eq!(m["details"]["mems"], serde_json::json!(["specs"]));
2968 assert_eq!(
2969 m["details"]["stamped_path"],
2970 authoring_dir.display().to_string()
2971 );
2972 assert!(
2973 !warnings
2974 .iter()
2975 .any(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED"),
2976 "missing and diverged are distinct findings"
2977 );
2978
2979 std::fs::remove_file(&stamp_path).unwrap();
2981 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2982 }
2983
2984 #[test]
2989 fn full_refresh_is_additive_and_reports_skipped_removals() {
2990 use crate::engine::test_helpers::write_schema_files_with_default_type;
2991 use crate::workspace_store::WorkspaceStoreAdapter;
2992
2993 let tmp = TempDir::new().unwrap();
2994 let root = tmp.path();
2995 let mem_a = root.join("mem-a");
2996 std::fs::create_dir_all(&mem_a).unwrap();
2997 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2998 std::fs::write(
2999 root.join(".memstead").join("workspace.toml"),
3000 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3001 )
3002 .unwrap();
3003 let mount = |mem: &str, dir: &Path, schema: &str, version: semver::Version| Mount {
3004 mem: mem.to_string(),
3005 schema: Some(SchemaRef::new(schema, version)),
3006 storage: MountStorage::Folder {
3007 path: dir.to_path_buf(),
3008 },
3009 capability: MountCapability::Write,
3010 lifecycle: MountLifecycle::Eager,
3011 cross_linkable: true,
3012 migration_target: None,
3013 };
3014 let save = |mounts: Vec<Mount>| {
3015 crate::FileWorkspaceStore::new()
3016 .save_state(
3017 root,
3018 &crate::workspace::Workspace {
3019 mounts,
3020 settings: crate::workspace::WorkspaceSettings::default(),
3021 },
3022 )
3023 .unwrap();
3024 };
3025 save(vec![mount(
3026 "specs",
3027 &mem_a,
3028 "default",
3029 semver::Version::new(1, 0, 0),
3030 )]);
3031 let mut engine = Engine::from_workspace_root(root).expect("workspace boots");
3032 assert_eq!(engine.mem_names(), vec!["specs"]);
3033
3034 let manifest = r#"name: authored
3037version: 0.1.0
3038description: an out-of-band installed schema
3039when_to_use: tests
3040types:
3041 - doc
3042relationships:
3043 mode: strict
3044 definitions:
3045 - name: _default
3046 description: fallback
3047 default_weight: 1.0
3048community:
3049 resolution: 1.0
3050 seed: 42
3051"#;
3052 write_schema_files_with_default_type(
3053 &root.join(".memstead").join("schemas"),
3054 "authored@0.1.0",
3055 manifest,
3056 &["doc"],
3057 );
3058 let mem_b = root.join("mem-b");
3059 std::fs::create_dir_all(&mem_b).unwrap();
3060 save(vec![
3061 mount("specs", &mem_a, "default", semver::Version::new(1, 0, 0)),
3062 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
3063 ]);
3064
3065 let (actor, client) = cli_actor();
3069 let mut pre = crate::engine::test_helpers::empty_create_args("notes", "Too Early");
3070 pre.entity_type = "doc".to_string();
3071 pre.sections = indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
3072
3073 let report = engine.full_refresh();
3075 assert_eq!(report.schemas_added, vec!["authored@0.1.0".to_string()]);
3076 assert_eq!(report.mems_mounted, vec!["notes".to_string()]);
3077 assert!(report.schema_removals_skipped.is_empty(), "{report:?}");
3078 assert!(report.mems_unmounted.is_empty(), "{report:?}");
3079 assert!(report.failures.is_empty(), "{report:?}");
3080 engine
3081 .create_entity(pre, actor, Some(&client), None)
3082 .expect("newly mounted mem accepts writes after the refresh");
3083
3084 std::fs::remove_dir_all(
3087 root.join(".memstead")
3088 .join("schemas")
3089 .join("authored@0.1.0"),
3090 )
3091 .unwrap();
3092 save(vec![mount(
3093 "notes",
3094 &mem_b,
3095 "authored",
3096 semver::Version::new(0, 1, 0),
3097 )]);
3098 let report = engine.full_refresh();
3099 assert_eq!(report.mems_unmounted, vec!["specs".to_string()]);
3102 assert_eq!(engine.mem_names(), vec!["notes"]);
3103 assert_eq!(
3104 report.schema_removals_skipped,
3105 vec!["authored@0.1.0".to_string()]
3106 );
3107 assert!(report.schemas_added.is_empty());
3108 assert!(report.mems_mounted.is_empty());
3109 let gone = engine
3112 .create_entity(
3113 crate::engine::test_helpers::empty_create_args("specs", "Still Here"),
3114 actor,
3115 Some(&client),
3116 None,
3117 )
3118 .expect_err("an unmounted mem refuses writes");
3119 assert_eq!(gone.code(), "MEM_UNMOUNTED", "{gone:?}");
3120 let mut into_notes =
3121 crate::engine::test_helpers::empty_create_args("notes", "Still Resolvable");
3122 into_notes.entity_type = "doc".to_string();
3123 into_notes.sections =
3124 indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
3125 engine
3126 .create_entity(into_notes, actor, Some(&client), None)
3127 .expect("removed-from-source schema stays resolvable");
3128
3129 let broken = root.join("broken-mem");
3132 std::fs::write(&broken, b"not a directory").unwrap();
3133 save(vec![
3134 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
3135 mount("broken", &broken, "default", semver::Version::new(1, 0, 0)),
3136 ]);
3137 let report = engine.full_refresh();
3138 assert!(
3139 report.mems_quarantined.contains(&"broken".to_string()),
3140 "a mount that cannot attach is quarantined per-item, as boot would: {report:?}"
3141 );
3142 assert!(engine.quarantine_reason("broken").is_some());
3143 assert!(
3144 !report.mems_mounted.contains(&"broken".to_string()),
3145 "a failed mount never surfaces as newly available"
3146 );
3147 assert!(
3148 engine
3149 .get_entity(&crate::EntityId::new("broken", "anything"))
3150 .is_none()
3151 && engine.mem_names().contains(&"notes"),
3152 "other mounts unaffected"
3153 );
3154 }
3155
3156 #[test]
3157 fn from_workspace_root_propagates_mem_management_settings() {
3158 let tmp = TempDir::new().unwrap();
3163 let mem_dir = tmp.path().join("mem");
3164 std::fs::create_dir_all(&mem_dir).unwrap();
3165
3166 let memstead = tmp.path().join(".memstead");
3167 std::fs::create_dir_all(&memstead).unwrap();
3168 std::fs::write(
3169 memstead.join("workspace.toml"),
3170 r#"format = "memstead-git-branch-2"
3171
3172[persistence_adapter]
3173name = "file-two-layer"
3174
3175[[mem_management.create]]
3176pattern = "exec-*"
3177schemas = ["default@1.0.0"]
3178
3179[[mem_management.delete]]
3180pattern = "exec-*"
3181"#,
3182 )
3183 .unwrap();
3184 use crate::workspace_store::WorkspaceStoreAdapter;
3185 let store = crate::FileWorkspaceStore::new();
3186 store
3187 .save_state(
3188 tmp.path(),
3189 &crate::workspace::Workspace {
3190 mounts: vec![folder_mount("specs", mem_dir)],
3191 settings: crate::workspace::WorkspaceSettings::default(),
3192 },
3193 )
3194 .unwrap();
3195
3196 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
3197 let s = engine.settings();
3198 assert_eq!(s.mem_create_rules.len(), 1);
3199 assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
3200 assert_eq!(
3201 s.mem_create_rules[0].schemas,
3202 vec!["default@1.0.0".to_string()]
3203 );
3204 assert_eq!(s.mem_delete_rules.len(), 1);
3205 assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
3206 }
3207
3208 #[test]
3216 fn from_mounts_quarantines_unknown_schema_pin() {
3217 let tmp = TempDir::new().unwrap();
3218 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
3219 let mount = Mount {
3220 mem: "specs".to_string(),
3221 schema: Some(SchemaRef::new(
3222 "totally-not-a-schema",
3223 semver::Version::new(1, 0, 0),
3224 )),
3225 storage: MountStorage::Folder {
3226 path: tmp.path().to_path_buf(),
3227 },
3228 capability: MountCapability::Write,
3229 lifecycle: MountLifecycle::Eager,
3230 cross_linkable: true,
3231 migration_target: None,
3232 };
3233 let engine = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
3234 .expect("a broken mem quarantines, never fails the workspace");
3235
3236 let roster = engine.quarantined_mems();
3237 assert_eq!(roster.len(), 1);
3238 assert_eq!(roster[0].mount.mem, "specs");
3239 assert_eq!(roster[0].reason_code, "SCHEMA_NOT_FOUND");
3240 assert!(
3241 roster[0].reason_message.contains("totally-not-a-schema"),
3242 "reason carries the failing pin: {}",
3243 roster[0].reason_message
3244 );
3245 assert!(engine.mounts().iter().all(|m| m.mem != "specs"));
3247 let err = engine.unknown_mem_error("specs");
3250 assert_eq!(err.code(), "MEM_QUARANTINED");
3251 assert!(
3252 err.to_string().contains("SCHEMA_NOT_FOUND"),
3253 "quarantine refusal carries the underlying reason: {err}"
3254 );
3255 let health = engine.health();
3257 assert_eq!(health.quarantined.len(), 1);
3258 assert_eq!(health.quarantined[0].reason_code, "SCHEMA_NOT_FOUND");
3259 }
3260
3261 #[test]
3267 fn reload_returns_repaired_mem_from_quarantine() {
3268 let tmp = TempDir::new().unwrap();
3269 let dir = tmp.path().to_path_buf();
3270 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
3271 let config_path = dir.join(".memstead").join("config.json");
3272 std::fs::write(&config_path, r#"{ "schema": "ghost@1.0.0" }"#).unwrap();
3273 let writer = FilesystemMemWriter::new(dir.clone());
3274 let mount = Mount {
3275 mem: "specs".to_string(),
3276 schema: None,
3277 storage: MountStorage::Folder { path: dir },
3278 capability: MountCapability::Write,
3279 lifecycle: MountLifecycle::Eager,
3280 cross_linkable: true,
3281 migration_target: None,
3282 };
3283 let mut engine =
3284 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3285 assert_eq!(engine.quarantined_mems().len(), 1);
3286 let err = engine.reload_one_mem("specs").unwrap_err();
3289 assert_eq!(err.code(), "MEM_QUARANTINED");
3290 assert_eq!(engine.quarantined_mems().len(), 1);
3291
3292 std::fs::write(&config_path, r#"{ "schema": "default@1.0.0" }"#).unwrap();
3295 engine
3296 .reload_one_mem("specs")
3297 .expect("repaired mem re-attaches on reload");
3298 assert!(
3299 engine.quarantined_mems().is_empty(),
3300 "roster entry disappears after re-attach"
3301 );
3302 let mut sections = indexmap::IndexMap::new();
3304 sections.insert("identity".to_string(), "back".to_string());
3305 sections.insert("purpose".to_string(), "post-repair service".to_string());
3306 engine
3307 .create_entity_with_ctx(
3308 crate::engine::CreateEntityArgs {
3309 anchors: Vec::new(),
3310 mem: "specs".to_string(),
3311 title: "Back".to_string(),
3312 entity_type: "spec".to_string(),
3313 sections,
3314 metadata: indexmap::IndexMap::new(),
3315 relations: Vec::new(),
3316 dry_run: false,
3317 },
3318 &crate::vcs::CommitContext::internal(),
3319 )
3320 .expect("reattached mem serves writes");
3321 }
3322
3323 #[test]
3328 fn cross_mem_link_into_quarantined_mem_degrades_without_cascade() {
3329 let tmp = TempDir::new().unwrap();
3330 let healthy_dir = tmp.path().join("healthy");
3331 std::fs::create_dir_all(&healthy_dir).unwrap();
3332 std::fs::write(
3333 healthy_dir.join("linker.md"),
3334 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n\
3335 # Linker\n\n## Identity\n\nsee [[badpin:target]] for detail.\n",
3336 )
3337 .unwrap();
3338 let badpin_dir = tmp.path().join("badpin");
3339 std::fs::create_dir_all(&badpin_dir).unwrap();
3340 let mount = |mem: &str, dir: std::path::PathBuf, pin: &str| {
3341 (
3342 Mount {
3343 mem: mem.to_string(),
3344 schema: Some(SchemaRef::new(pin, semver::Version::new(1, 0, 0))),
3345 storage: MountStorage::Folder { path: dir.clone() },
3346 capability: MountCapability::Write,
3347 lifecycle: MountLifecycle::Eager,
3348 cross_linkable: true,
3349 migration_target: None,
3350 },
3351 Box::new(FilesystemMemWriter::new(dir)) as Box<dyn MemBackend>,
3352 )
3353 };
3354 let engine = Engine::from_mounts(vec![
3355 mount("healthy", healthy_dir, "default"),
3356 mount("badpin", badpin_dir, "ghost"),
3357 ])
3358 .expect("boot survives the cross-mem link into the quarantined mem");
3359 assert_eq!(engine.quarantined_mems().len(), 1);
3360 let linker = engine
3364 .get_entity(&crate::EntityId::new("healthy", "linker"))
3365 .expect("linking entity loads");
3366 assert_eq!(linker.entity_type, "spec");
3367 assert!(
3368 engine
3369 .get_entity(&crate::EntityId::new("badpin", "target"))
3370 .is_none_or(|e| e.stub),
3371 "the quarantined-side target is at most a stub, never real data"
3372 );
3373 }
3374
3375 #[test]
3381 fn old_key_authored_schema_quarantines_pinning_mem_never_workspace() {
3382 let tmp = TempDir::new().unwrap();
3383 let root = tmp.path();
3384 std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
3386 std::fs::write(
3387 root.join(".memstead").join("workspace.toml"),
3388 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3389 )
3390 .unwrap();
3391 for m in ["healthy", "oldkey"] {
3392 std::fs::create_dir_all(root.join(m)).unwrap();
3393 }
3394 std::fs::write(
3395 root.join(".memstead").join("state").join("mounts.json"),
3396 r#"{ "format": "memstead-mounts-3", "mounts": [
3397 { "mem": "healthy", "schema": "default@1.1.0", "storage": { "type": "folder", "path": "healthy" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true },
3398 { "mem": "oldkey", "schema": "fieldschema@0.1.0", "storage": { "type": "folder", "path": "oldkey" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
3399 ] }"#,
3400 )
3401 .unwrap();
3402 let pkg = root
3404 .join(".memstead")
3405 .join("schemas")
3406 .join("fieldschema@0.1.0");
3407 std::fs::create_dir_all(pkg.join("types")).unwrap();
3408 std::fs::write(
3409 pkg.join("schema.yaml"),
3410 "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",
3411 )
3412 .unwrap();
3413 std::fs::write(
3414 pkg.join("types").join("thing.yaml"),
3415 "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",
3416 )
3417 .unwrap();
3418
3419 let engine = Engine::from_workspace_root(root)
3420 .expect("the old-key schema quarantines its mem, never the workspace");
3421 assert!(engine.mounts().iter().any(|m| m.mem == "healthy"));
3422 let q = engine
3423 .quarantine_reason("oldkey")
3424 .expect("oldkey mem is quarantined");
3425 assert_eq!(q.reason_code, "SCHEMA_LOAD_FAILED");
3426 assert!(
3427 q.reason_message.contains("no_self_loop_relationships"),
3428 "quarantine reason is the rename error naming the new key: {}",
3429 q.reason_message
3430 );
3431 }
3432
3433 #[test]
3438 fn ingest_0_3_entries_are_leaves_prior_version_unchanged() {
3439 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";
3440 let boot = |pin: &str| {
3441 let tmp = TempDir::new().unwrap();
3442 let dir = tmp.path().to_path_buf();
3443 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
3444 std::fs::write(
3445 dir.join(".memstead").join("config.json"),
3446 format!("{{ \"schema\": \"{pin}\" }}"),
3447 )
3448 .unwrap();
3449 std::fs::write(dir.join("gap.md"), entry_md).unwrap();
3450 let writer = FilesystemMemWriter::new(dir.clone());
3451 let mount = Mount {
3452 mem: "proc".to_string(),
3453 schema: None,
3454 storage: MountStorage::Folder { path: dir },
3455 capability: MountCapability::Write,
3456 lifecycle: MountLifecycle::Eager,
3457 cross_linkable: true,
3458 migration_target: None,
3459 };
3460 let engine =
3461 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
3462 .unwrap();
3463 (engine.health(), tmp)
3464 };
3465
3466 let (health_new, _t1) = boot("ingest@0.3.0");
3467 assert_eq!(
3468 health_new.orphan_count, 0,
3469 "0.3.0 entry types are leaves — zero false orphans"
3470 );
3471 assert_eq!(
3472 health_new
3473 .leaf_entities_by_type
3474 .get("ingest@0.3.0:coverage_gap"),
3475 Some(&1),
3476 "the population stays visible: {:?}",
3477 health_new.leaf_entities_by_type
3478 );
3479
3480 let (health_old, _t2) = boot("ingest@0.2.0");
3481 assert_eq!(
3482 health_old.orphan_count, 1,
3483 "the prior version's behaviour is unchanged"
3484 );
3485 assert!(health_old.leaf_entities_by_type.is_empty());
3486 }
3487
3488 #[test]
3494 fn broken_mem_quarantines_while_healthy_siblings_serve() {
3495 let tmp = TempDir::new().unwrap();
3496 let make_mount = |mem: &str, pin: Option<SchemaRef>| {
3497 let dir = tmp.path().join(mem);
3498 std::fs::create_dir_all(&dir).unwrap();
3499 let writer = FilesystemMemWriter::new(dir.clone());
3500 (
3501 Mount {
3502 mem: mem.to_string(),
3503 schema: pin,
3504 storage: MountStorage::Folder { path: dir },
3505 capability: MountCapability::Write,
3506 lifecycle: MountLifecycle::Eager,
3507 cross_linkable: true,
3508 migration_target: None,
3509 },
3510 Box::new(writer) as Box<dyn MemBackend>,
3511 )
3512 };
3513 let healthy = make_mount(
3514 "healthy",
3515 Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3516 );
3517 let bad_pin = make_mount(
3518 "badpin",
3519 Some(SchemaRef::new("ghost", semver::Version::new(1, 0, 0))),
3520 );
3521 let missing_pin = make_mount("nopin", None);
3522 let bad_io_path = tmp.path().join("badio");
3525 std::fs::write(&bad_io_path, "not a directory").unwrap();
3526 let bad_io = (
3527 Mount {
3528 mem: "badio".to_string(),
3529 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3530 storage: MountStorage::Folder {
3531 path: bad_io_path.clone(),
3532 },
3533 capability: MountCapability::Write,
3534 lifecycle: MountLifecycle::Eager,
3535 cross_linkable: true,
3536 migration_target: None,
3537 },
3538 Box::new(FilesystemMemWriter::new(bad_io_path)) as Box<dyn MemBackend>,
3539 );
3540 let mut engine = Engine::from_mounts(vec![healthy, bad_pin, missing_pin, bad_io])
3541 .expect("mixed workspace boots");
3542
3543 let codes: std::collections::HashMap<String, String> = engine
3545 .quarantined_mems()
3546 .iter()
3547 .map(|q| (q.mount.mem.clone(), q.reason_code.clone()))
3548 .collect();
3549 assert_eq!(
3550 codes.get("badpin").map(String::as_str),
3551 Some("SCHEMA_NOT_FOUND")
3552 );
3553 assert_eq!(
3554 codes.get("nopin").map(String::as_str),
3555 Some("MEM_CONFIG_INCOMPLETE")
3556 );
3557 assert!(
3558 codes.contains_key("badio"),
3559 "backend read failure quarantines too: {codes:?}"
3560 );
3561
3562 let mut sections = indexmap::IndexMap::new();
3564 sections.insert("identity".to_string(), "alive".to_string());
3565 sections.insert("purpose".to_string(), "proof of service".to_string());
3566 let created = engine
3567 .create_entity_with_ctx(
3568 crate::engine::CreateEntityArgs {
3569 anchors: Vec::new(),
3570 mem: "healthy".to_string(),
3571 title: "Alive".to_string(),
3572 entity_type: "spec".to_string(),
3573 sections,
3574 metadata: indexmap::IndexMap::new(),
3575 relations: Vec::new(),
3576 dry_run: false,
3577 },
3578 &crate::vcs::CommitContext::internal(),
3579 )
3580 .expect("healthy mem serves writes");
3581 assert_eq!(created.id.to_string(), "healthy--alive");
3582
3583 let mut sections = indexmap::IndexMap::new();
3585 sections.insert("identity".to_string(), "x".to_string());
3586 let err = engine
3587 .create_entity_with_ctx(
3588 crate::engine::CreateEntityArgs {
3589 anchors: Vec::new(),
3590 mem: "badpin".to_string(),
3591 title: "Nope".to_string(),
3592 entity_type: "spec".to_string(),
3593 sections,
3594 metadata: indexmap::IndexMap::new(),
3595 relations: Vec::new(),
3596 dry_run: false,
3597 },
3598 &crate::vcs::CommitContext::internal(),
3599 )
3600 .unwrap_err();
3601 assert_eq!(err.code(), "MEM_QUARANTINED");
3602 }
3603
3604 #[test]
3611 fn mem_config_schema_is_authoritative_over_mount_pin() {
3612 let tmp = TempDir::new().unwrap();
3613 let mem_dir = tmp.path().to_path_buf();
3614 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3615 std::fs::write(
3616 mem_dir.join(".memstead").join("config.json"),
3617 r#"{"schema":"software@0.1.0"}"#,
3618 )
3619 .unwrap();
3620 let writer = FilesystemMemWriter::new(mem_dir.clone());
3621 let mount = Mount {
3622 mem: "specs".to_string(),
3623 schema: Some(SchemaRef::new(
3624 "totally-not-a-schema",
3625 semver::Version::new(9, 9, 9),
3626 )),
3627 storage: MountStorage::Folder { path: mem_dir },
3628 capability: MountCapability::Write,
3629 lifecycle: MountLifecycle::Eager,
3630 cross_linkable: true,
3631 migration_target: None,
3632 };
3633 let engine = Engine::from_mounts(vec![(
3634 mount,
3635 Box::new(writer) as Box<dyn MemBackend>,
3636 )])
3637 .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
3638
3639 let mismatch = engine
3640 .load_warnings()
3641 .iter()
3642 .find_map(|w| match w {
3643 WarningHint::SchemaPinMismatch {
3644 mem,
3645 config_pin,
3646 mount_pin,
3647 } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
3648 _ => None,
3649 })
3650 .expect("SchemaPinMismatch warning must surface naming both pins");
3651 assert_eq!(mismatch.0, "specs");
3652 assert_eq!(mismatch.1, "software@0.1.0");
3653 assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
3654 }
3655
3656 #[test]
3657 fn from_workspace_root_quarantines_git_branch_mount_on_lean() {
3658 let tmp = TempDir::new().unwrap();
3659 let memstead = tmp.path().join(".memstead");
3660 std::fs::create_dir_all(&memstead).unwrap();
3661 std::fs::write(
3662 memstead.join("workspace.toml"),
3663 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3664 )
3665 .unwrap();
3666 let state_dir = memstead.join("state");
3669 std::fs::create_dir_all(&state_dir).unwrap();
3670 std::fs::write(
3671 state_dir.join("mounts.json"),
3672 r#"{
3673 "format": "memstead-mounts-3",
3674 "mounts": [
3675 {
3676 "mem": "specs",
3677 "schema": "default@1.0.0",
3678 "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
3679 "capability": "write",
3680 "lifecycle": "eager",
3681 "cross_linkable": true
3682 }
3683 ]
3684 }"#,
3685 )
3686 .unwrap();
3687 let engine = Engine::from_workspace_root(tmp.path())
3695 .expect("lean boot quarantines the git-branch mount, never fails the workspace");
3696 let roster = engine.quarantined_mems();
3697 assert_eq!(roster.len(), 1);
3698 assert_eq!(roster[0].mount.mem, "specs");
3699 assert_eq!(roster[0].reason_code, "UNSUPPORTED_WORKSPACE_SHAPE");
3700 assert_eq!(engine.unknown_mem_error("specs").code(), "MEM_QUARANTINED");
3701 }
3702
3703 #[test]
3704 fn from_workspace_root_roots_standalone_folder_mem() {
3705 let tmp = TempDir::new().unwrap();
3709 let root = tmp.path();
3710 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3711 std::fs::write(
3712 root.join(".memstead").join("config.json"),
3713 r#"{"schema":"default@1.0.0"}"#,
3714 )
3715 .unwrap();
3716 std::fs::write(
3718 root.join("hello.md"),
3719 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3720 )
3721 .unwrap();
3722
3723 let engine = Engine::from_workspace_root(root)
3724 .expect("a bare folder mem must root as a one-mount workspace");
3725 assert_eq!(engine.status().mem_count, 1, "exactly one mount");
3726 assert!(
3727 engine.status().entity_count >= 1,
3728 "the standalone mem's entity must load"
3729 );
3730 }
3731
3732 #[test]
3733 fn booting_a_workspace_writes_nothing_into_it() {
3734 let tmp = TempDir::new().unwrap();
3743 let root = tmp.path();
3744 std::fs::create_dir_all(root.join(".memstead").join("meta-schemas")).unwrap();
3745 std::fs::write(
3746 root.join(".memstead").join("config.json"),
3747 r#"{"schema":"default@1.0.0"}"#,
3748 )
3749 .unwrap();
3750 let stale = root
3753 .join(".memstead")
3754 .join("meta-schemas")
3755 .join("type-definition.schema.json");
3756 std::fs::write(&stale, "{\"title\":\"stale, from an older binary\"}").unwrap();
3757 std::fs::write(
3758 root.join("hello.md"),
3759 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3760 )
3761 .unwrap();
3762
3763 fn snapshot(dir: &Path) -> std::collections::BTreeMap<std::path::PathBuf, Vec<u8>> {
3764 let mut out = std::collections::BTreeMap::new();
3765 let mut stack = vec![dir.to_path_buf()];
3766 while let Some(d) = stack.pop() {
3767 for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
3768 let p = entry.path();
3769 if p.is_dir() {
3770 stack.push(p);
3771 } else if let Ok(bytes) = std::fs::read(&p) {
3772 out.insert(p, bytes);
3773 }
3774 }
3775 }
3776 out
3777 }
3778
3779 let before = snapshot(root);
3780 let engine = Engine::from_workspace_root(root).expect("the fixture must boot");
3781 assert_eq!(engine.status().mem_count, 1, "fixture sanity: one mount");
3782 let after = snapshot(root);
3783
3784 assert_eq!(
3785 before.keys().collect::<Vec<_>>(),
3786 after.keys().collect::<Vec<_>>(),
3787 "boot created or removed a file in the workspace it read"
3788 );
3789 for (path, bytes) in &before {
3790 assert_eq!(
3791 bytes,
3792 after.get(path).unwrap(),
3793 "boot rewrote {} in the workspace it read",
3794 path.display()
3795 );
3796 }
3797 }
3798
3799 #[test]
3800 fn from_workspace_root_still_rejects_truly_empty_dir() {
3801 let tmp = TempDir::new().unwrap();
3805 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
3806 assert!(
3807 matches!(err, crate::BootError::NotInitialised(_)),
3808 "got {err:?}"
3809 );
3810 }
3811
3812 #[test]
3819 fn boot_keeps_loading_violating_schema_and_surfaces_health_finding() {
3820 let tmp = TempDir::new().unwrap();
3821 let schemas_dir = tmp.path().join("schemas");
3822 let pkg = schemas_dir.join("debate");
3823 std::fs::create_dir_all(pkg.join("types")).unwrap();
3824 std::fs::write(
3825 pkg.join("schema.yaml"),
3826 r#"name: debate
3827version: 0.1.0
3828description: sealed-violator fixture
3829when_to_use: tests
3830types:
3831 - question
3832relationships:
3833 mode: strict
3834 definitions:
3835 - name: PART_OF
3836 description: hier
3837 default_weight: 3.0
3838 - name: _default
3839 description: fallback
3840 default_weight: 1.0
3841community:
3842 resolution: 1.0
3843 seed: 42
3844"#,
3845 )
3846 .unwrap();
3847 std::fs::write(
3848 pkg.join("types").join("question.yaml"),
3849 r#"name: question
3850description: t
3851when_to_use: tests
3852sections:
3853 - key: answers
3854 heading: Answers argued
3855 required: true
3856 search_weight: 10.0
3857 write_rules: []
3858 - key: notes
3859 heading: Notes
3860 required: false
3861 search_weight: 3.0
3862 catch_all: true
3863 write_rules: []
3864metadata_fields: []
3865title_weight: 100.0
3866text_fields:
3867 - answers
3868 - notes
3869hierarchy_relationship: PART_OF
3870no_self_loop_relationships: []
3871updatable_fields:
3872 - title
3873 - answers
3874 - notes
3875health_required_fields:
3876 - answers
3877staleness_threshold_days: 90
3878write_rules: []
3879"#,
3880 )
3881 .unwrap();
3882
3883 let mem_dir = tmp.path().join("mem");
3884 std::fs::create_dir_all(&mem_dir).unwrap();
3885 std::fs::write(
3886 mem_dir.join("q.md"),
3887 "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n",
3888 )
3889 .unwrap();
3890
3891 let writer = FilesystemMemWriter::new(mem_dir.clone());
3892 let mount = Mount {
3893 mem: "debate-mem".to_string(),
3894 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3895 storage: MountStorage::Folder { path: mem_dir },
3896 capability: MountCapability::Write,
3897 lifecycle: MountLifecycle::Eager,
3898 cross_linkable: true,
3899 migration_target: None,
3900 };
3901 let engine = Engine::from_mounts_with_schemas_dir(
3902 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3903 Some(&schemas_dir),
3904 )
3905 .expect("a violating sealed schema must keep loading, never refuse boot");
3906
3907 assert!(
3909 engine.status().entity_count >= 1,
3910 "entities load despite the schema violation"
3911 );
3912
3913 let hits: Vec<_> = engine
3915 .load_warnings()
3916 .iter()
3917 .filter_map(|w| match w {
3918 WarningHint::SchemaHeadingRoundtripViolation {
3919 mem,
3920 schema_ref,
3921 violations,
3922 } => Some((mem.clone(), schema_ref.clone(), violations.clone())),
3923 _ => None,
3924 })
3925 .collect();
3926 assert_eq!(
3927 hits.len(),
3928 1,
3929 "exactly one schema-level finding; all warnings = {:?}",
3930 engine.load_warnings()
3931 );
3932 let (mem, schema_ref, violations) = &hits[0];
3933 assert_eq!(mem, "debate-mem");
3934 assert_eq!(schema_ref, "debate@0.1.0");
3935 assert_eq!(violations.len(), 1);
3936 assert_eq!(violations[0].type_name, "question");
3937 assert_eq!(violations[0].key, "answers");
3938 assert_eq!(violations[0].heading, "Answers argued");
3939 assert_eq!(violations[0].derived_key, "answers_argued");
3940 }
3941
3942 #[test]
3950 fn sealed_violator_mem_still_serves_writes() {
3951 let tmp = TempDir::new().unwrap();
3953 let schemas_dir = tmp.path().join("schemas");
3954 let pkg = schemas_dir.join("debate");
3955 std::fs::create_dir_all(pkg.join("types")).unwrap();
3956 std::fs::write(
3957 pkg.join("schema.yaml"),
3958 r#"name: debate
3959version: 0.1.0
3960description: sealed-violator fixture
3961when_to_use: tests
3962types:
3963 - question
3964relationships:
3965 mode: strict
3966 definitions:
3967 - name: PART_OF
3968 description: hier
3969 default_weight: 3.0
3970 - name: _default
3971 description: fallback
3972 default_weight: 1.0
3973community:
3974 resolution: 1.0
3975 seed: 42
3976"#,
3977 )
3978 .unwrap();
3979 std::fs::write(
3980 pkg.join("types").join("question.yaml"),
3981 r#"name: question
3982description: t
3983when_to_use: tests
3984sections:
3985 - key: answers
3986 heading: Answers argued
3987 required: true
3988 search_weight: 10.0
3989 write_rules: []
3990 - key: notes
3991 heading: Notes
3992 required: false
3993 search_weight: 3.0
3994 catch_all: true
3995 write_rules: []
3996metadata_fields: []
3997title_weight: 100.0
3998text_fields:
3999 - answers
4000 - notes
4001hierarchy_relationship: PART_OF
4002no_self_loop_relationships: []
4003updatable_fields:
4004 - title
4005 - answers
4006 - notes
4007health_required_fields:
4008 - answers
4009staleness_threshold_days: 90
4010write_rules: []
4011"#,
4012 )
4013 .unwrap();
4014
4015 let mem_dir = tmp.path().join("mem");
4016 std::fs::create_dir_all(&mem_dir).unwrap();
4017 std::fs::write(
4021 mem_dir.join("q.md"),
4022 "---\ntype: question\n---\n# Q\n\n## Answers\n\nTwo answers.\n",
4023 )
4024 .unwrap();
4025
4026 let writer = FilesystemMemWriter::new(mem_dir.clone());
4027 let mount = Mount {
4028 mem: "debate-mem".to_string(),
4029 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
4030 storage: MountStorage::Folder { path: mem_dir },
4031 capability: MountCapability::Write,
4032 lifecycle: MountLifecycle::Eager,
4033 cross_linkable: true,
4034 migration_target: None,
4035 };
4036 let mut engine = Engine::from_mounts_with_schemas_dir(
4037 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
4038 Some(&schemas_dir),
4039 )
4040 .expect("a violating sealed schema must keep loading");
4041
4042 let id = crate::EntityId::new("debate-mem", "q");
4043 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
4044 let mut sections = indexmap::IndexMap::new();
4045 sections.insert("answers".to_string(), "Updated answers body.".to_string());
4046 let outcome = engine
4047 .update_entity(
4048 crate::engine::UpdateEntityArgs {
4049 anchors: Vec::new(),
4050 anchors_unset: Vec::new(),
4051 id: id.clone(),
4052 expected_hash: Some(hash),
4053 sections,
4054 append_sections: indexmap::IndexMap::new(),
4055 patch_sections: indexmap::IndexMap::new(),
4056 sections_unset: Vec::new(),
4057 metadata: indexmap::IndexMap::new(),
4058 metadata_unset: Vec::new(),
4059 declare_relations: Vec::new(),
4060 dry_run: false,
4061 relations_unset: Vec::new(),
4062 },
4063 crate::vcs::Actor::Cli,
4064 None,
4065 None,
4066 )
4067 .expect("a write against a sealed-violator mem must NOT be refused");
4068 assert!(!outcome.write_id.is_empty(), "the write commits");
4069 assert!(
4070 outcome
4071 .warnings
4072 .iter()
4073 .any(|w| w.code() == "SECTION_HEADING_DIVERGENCE"),
4074 "the write-path divergence warning fires where its condition holds: {:?}",
4075 outcome.warnings
4076 );
4077
4078 assert!(
4080 engine.load_warnings().iter().any(|w| matches!(
4081 w,
4082 WarningHint::SchemaHeadingRoundtripViolation { mem, .. } if mem == "debate-mem"
4083 )),
4084 "the health finding persists across writes"
4085 );
4086 let entity = engine.get_entity(&id).unwrap();
4095 assert!(
4096 entity
4097 .sections
4098 .values()
4099 .any(|s| s.contains("Updated answers body.")),
4100 "written content survives the round-trip (in the catch-all): {:?}",
4101 entity.sections
4102 );
4103 }
4104
4105 #[test]
4112 fn search_mem_filter_gates_against_visible_roster() {
4113 use crate::vcs::Actor;
4114 use crate::workspace_store::WorkspaceStoreAdapter;
4115 use indexmap::IndexMap;
4116
4117 let tmp = TempDir::new().unwrap();
4118 let mem_dir = tmp.path().join("mem");
4119 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4120 std::fs::write(
4121 mem_dir.join(".memstead").join("config.json"),
4122 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4123 )
4124 .unwrap();
4125 let memstead = tmp.path().join(".memstead");
4126 std::fs::create_dir_all(&memstead).unwrap();
4127 std::fs::write(
4128 memstead.join("workspace.toml"),
4129 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4130 )
4131 .unwrap();
4132 crate::FileWorkspaceStore::new()
4133 .save_state(
4134 tmp.path(),
4135 &crate::workspace::Workspace {
4136 mounts: vec![folder_mount("specs", mem_dir.clone())],
4137 settings: crate::workspace::WorkspaceSettings::default(),
4138 },
4139 )
4140 .unwrap();
4141 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4142 let mut sections = IndexMap::new();
4143 sections.insert(
4144 "identity".to_string(),
4145 "Zebra searching fixture.".to_string(),
4146 );
4147 sections.insert("purpose".to_string(), "Search gate test.".to_string());
4148 engine
4149 .create_entity(
4150 crate::CreateEntityArgs {
4151 mem: "specs".to_string(),
4152 title: "Zebra".to_string(),
4153 entity_type: "spec".to_string(),
4154 sections,
4155 metadata: IndexMap::new(),
4156 relations: Vec::new(),
4157 anchors: Vec::new(),
4158 dry_run: false,
4159 },
4160 Actor::Agent,
4161 None,
4162 None,
4163 )
4164 .unwrap();
4165
4166 let scope = |mem: Option<&str>, term: &str| crate::ops::SearchScope {
4167 query: Some(crate::ops::Query {
4168 any: vec![term.to_string()],
4169 ..Default::default()
4170 }),
4171 mem: mem.map(str::to_string),
4172 ..Default::default()
4173 };
4174
4175 let err = engine
4177 .search(&scope(Some("no-such-mem"), "zebra"))
4178 .expect_err("a nonexistent mem filter must refuse");
4179 assert_eq!(err.code(), "UNKNOWN_MEM", "got {err:?}");
4180
4181 let hit = engine.search(&scope(Some("specs"), "zebra")).unwrap();
4183 assert!(hit.total >= 1, "the fixture entity matches: {hit:?}");
4184
4185 let none = engine
4188 .search(&scope(Some("specs"), "quixotic-nonword"))
4189 .unwrap();
4190 assert_eq!(none.total, 0, "{none:?}");
4191 }
4192
4193 fn lazy_folder_mount(mem: &str, path: std::path::PathBuf) -> Mount {
4198 Mount {
4199 lifecycle: MountLifecycle::Lazy,
4200 ..folder_mount(mem, path)
4201 }
4202 }
4203
4204 fn write_spec(dir: &Path, slug: &str, title: &str, extra: &str) {
4205 std::fs::write(
4206 dir.join(format!("{slug}.md")),
4207 format!("---\ntype: spec\n---\n# {title}\n\n## Identity\n\nBody.\n{extra}"),
4208 )
4209 .unwrap();
4210 }
4211
4212 fn two_mem_dirs(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
4213 let eager_dir = tmp.path().join("eag");
4214 let lazy_dir = tmp.path().join("laz");
4215 std::fs::create_dir_all(&eager_dir).unwrap();
4216 std::fs::create_dir_all(&lazy_dir).unwrap();
4217 write_spec(&eager_dir, "alpha", "Alpha", "");
4218 write_spec(&lazy_dir, "omega", "Omega", "");
4219 (eager_dir, lazy_dir)
4220 }
4221
4222 fn mixed_engine(eager_dir: &Path, lazy_dir: &Path) -> Engine {
4223 Engine::from_mounts(vec![
4224 (
4225 folder_mount("eag", eager_dir.to_path_buf()),
4226 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4227 ),
4228 (
4229 lazy_folder_mount("laz", lazy_dir.to_path_buf()),
4230 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4231 ),
4232 ])
4233 .unwrap()
4234 }
4235
4236 #[test]
4242 fn lazy_mount_defers_and_first_read_loads() {
4243 let tmp = TempDir::new().unwrap();
4244 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4245 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4246
4247 assert!(
4249 engine
4250 .get_entity(&crate::EntityId::new("eag", "alpha"))
4251 .is_some()
4252 );
4253 assert_eq!(engine.deferred_mems(), vec!["laz"]);
4254 assert!(engine.mem_is_deferred("laz"));
4255 assert!(
4256 engine
4257 .get_entity(&crate::EntityId::new("laz", "omega"))
4258 .is_none(),
4259 "deferred mem's entities are not in the store yet"
4260 );
4261 assert!(engine.schema_for("laz").is_some());
4263
4264 engine.reload_if_stale(Some("laz"));
4266 assert!(engine.deferred_mems().is_empty());
4267 let omega = engine
4268 .get_entity(&crate::EntityId::new("laz", "omega"))
4269 .expect("first read loads the mem");
4270 assert_eq!(omega.title, "Omega");
4271
4272 let (actor, client) = cli_actor();
4274 engine
4275 .create_entity(
4276 empty_create_args("laz", "Later"),
4277 actor,
4278 Some(&client),
4279 None,
4280 )
4281 .unwrap();
4282 assert!(
4283 engine
4284 .get_entity(&crate::EntityId::new("laz", "later"))
4285 .is_some()
4286 );
4287 }
4288
4289 #[test]
4293 fn scoped_operation_never_loads_lazy_sibling() {
4294 let tmp = TempDir::new().unwrap();
4295 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4296 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4297
4298 engine.reload_if_stale(Some("eag"));
4299 assert_eq!(
4300 engine.deferred_mems(),
4301 vec!["laz"],
4302 "an eager-scoped operation must not load the lazy sibling"
4303 );
4304
4305 engine.reload_if_stale(None);
4306 assert!(
4307 engine.deferred_mems().is_empty(),
4308 "a workspace-scoped pass loads every deferred mem"
4309 );
4310 }
4311
4312 #[test]
4316 fn lazy_load_failure_quarantines_typed() {
4317 let tmp = TempDir::new().unwrap();
4318 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4319 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4320
4321 std::fs::remove_dir_all(&lazy_dir).unwrap();
4325 std::fs::write(&lazy_dir, b"not a directory").unwrap();
4326 engine.reload_if_stale(Some("laz"));
4327
4328 let q = engine
4329 .quarantine_reason("laz")
4330 .expect("failed deferred load quarantines, never serves empty");
4331 assert!(!q.reason_code.is_empty());
4332 assert!(
4333 !engine.mem_names().contains(&"laz"),
4334 "a quarantined mem leaves the serving roster"
4335 );
4336 assert!(engine.deferred_mems().is_empty());
4337 }
4338
4339 #[test]
4347 fn quarantine_entry_invalidates_both_memos() {
4348 let tmp = TempDir::new().unwrap();
4349 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4350 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4351
4352 let _ = engine.communities();
4354 let _ = engine.search_indexes();
4355 assert!(engine.search_indexes_memo.get().is_some());
4356
4357 std::fs::remove_dir_all(&lazy_dir).unwrap();
4359 std::fs::write(&lazy_dir, b"not a directory").unwrap();
4360 engine.reload_if_stale(Some("laz"));
4361 assert!(engine.quarantine_reason("laz").is_some());
4362 assert!(
4363 engine.search_indexes_memo.get().is_none(),
4364 "quarantine entry bumps the schemas epoch — the search memo must clear"
4365 );
4366 assert!(
4367 engine.community_memo.get().is_none(),
4368 "quarantine entry must clear the community memo too"
4369 );
4370 let _ = engine.search_indexes();
4372
4373 let _ = engine.communities();
4375 let _ = engine.search_indexes();
4376 let _ = engine.reload_one_mem("laz");
4377 assert!(engine.quarantine_reason("laz").is_some());
4378 assert!(
4379 engine.search_indexes_memo.get().is_none(),
4380 "a failed reattach bumps the epoch — the search memo must clear"
4381 );
4382 let _ = engine.search_indexes();
4383 }
4384
4385 #[test]
4390 fn quarantine_reattach_invalidates_both_memos() {
4391 let tmp = TempDir::new().unwrap();
4392 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4393 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4394
4395 std::fs::remove_dir_all(&lazy_dir).unwrap();
4397 std::fs::write(&lazy_dir, b"not a directory").unwrap();
4398 engine.reload_if_stale(Some("laz"));
4399 assert!(engine.quarantine_reason("laz").is_some());
4400
4401 let _ = engine.communities();
4403 let _ = engine.search_indexes();
4404 assert!(engine.community_memo.get().is_some());
4405 assert!(engine.search_indexes_memo.get().is_some());
4406
4407 std::fs::remove_file(&lazy_dir).unwrap();
4409 std::fs::create_dir_all(&lazy_dir).unwrap();
4410 write_spec(&lazy_dir, "omega", "Omega", "");
4411 engine
4412 .reload_one_mem("laz")
4413 .expect("reattach succeeds once the backend is back");
4414 assert!(engine.quarantine_reason("laz").is_none());
4415
4416 assert!(
4419 engine.community_memo.get().is_none(),
4420 "reattach must invalidate the community memo"
4421 );
4422 assert!(
4423 engine.search_indexes_memo.get().is_none(),
4424 "reattach must invalidate the search memo"
4425 );
4426 }
4427
4428 #[test]
4433 fn lazy_load_runs_the_same_validation_gauntlet() {
4434 let tmp = TempDir::new().unwrap();
4435 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4436 std::fs::write(
4438 lazy_dir.join("bad.md"),
4439 "---\ntype: spec\n---\n# Bad\n\n## Identity\n\nx.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[laz--omega]]\n",
4440 )
4441 .unwrap();
4442
4443 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4444 let warned_before = engine
4445 .load_warnings()
4446 .iter()
4447 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
4448 engine.reload_if_stale(Some("laz"));
4449 let warned_after = engine
4450 .load_warnings()
4451 .iter()
4452 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
4453 assert!(
4454 !warned_before && warned_after,
4455 "the gauntlet fires at load time: before={warned_before} after={warned_after}, \
4456 warnings: {:?}",
4457 engine.load_warnings()
4458 );
4459 let bad = engine
4461 .get_entity(&crate::EntityId::new("laz", "bad"))
4462 .unwrap();
4463 assert!(bad.relationships.is_empty());
4464 }
4465
4466 #[test]
4474 fn delete_guard_sees_referrers_in_deferred_mems() {
4475 use crate::engine::{DeleteEntityArgs, RelateEntityArgs};
4476
4477 let tmp = TempDir::new().unwrap();
4478 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4479 let (actor, client) = cli_actor();
4480
4481 {
4485 let mut authoring = Engine::from_mounts(vec![
4486 (
4487 folder_mount("eag", eager_dir.to_path_buf()),
4488 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf()))
4489 as Box<dyn MemBackend>,
4490 ),
4491 (
4492 folder_mount("laz", lazy_dir.to_path_buf()),
4493 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf()))
4494 as Box<dyn MemBackend>,
4495 ),
4496 ])
4497 .unwrap();
4498 let mut settings = crate::workspace::WorkspaceSettings::default();
4499 settings.cross_mem_links.insert(
4500 "laz".to_string(),
4501 memstead_schema::workspace_config::CrossLinkValue::List(vec!["eag".to_string()]),
4502 );
4503 authoring.set_settings(settings);
4504 authoring
4505 .relate_entity(
4506 RelateEntityArgs {
4507 source: crate::EntityId::new("laz", "omega"),
4508 expected_hash: None,
4509 rel_type: "USES".to_string(),
4510 target: crate::EntityId::new("eag", "alpha"),
4511 remove: false,
4512 description: None,
4513 dry_run: false,
4514 },
4515 actor,
4516 Some(&client),
4517 None,
4518 )
4519 .expect("cross-mem relate lands under the grant");
4520 }
4521
4522 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4526 assert!(engine.mem_is_deferred("laz"));
4527 let err = engine
4528 .delete_entity(
4529 DeleteEntityArgs {
4530 id: crate::EntityId::new("eag", "alpha"),
4531 expected_hash: None,
4532 },
4533 actor,
4534 Some(&client),
4535 None,
4536 )
4537 .expect_err("a referenced entity must refuse deletion, lazy referrer or not");
4538 assert!(
4539 matches!(err, EngineError::HasIncomingRefs { .. }),
4540 "expected HAS_INCOMING_REFS, got {err:?}"
4541 );
4542 assert!(
4543 engine
4544 .get_entity(&crate::EntityId::new("eag", "alpha"))
4545 .is_some(),
4546 "the entity survives"
4547 );
4548 }
4549
4550 #[test]
4561 fn acyclicity_guard_sees_edges_in_deferred_mems() {
4562 use crate::engine::RelateEntityArgs;
4563 use memstead_schema::workspace_config::CrossLinkValue;
4564
4565 let tmp = TempDir::new().unwrap();
4566 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4567 .iter()
4568 .map(|m| {
4569 let d = tmp.path().join(m);
4570 std::fs::create_dir_all(&d).unwrap();
4571 d
4572 })
4573 .collect();
4574 write_spec(&dirs[0], "node", "Node A", "");
4575 write_spec(&dirs[1], "node", "Node B", "");
4576 write_spec(&dirs[2], "node", "Node C", "");
4577
4578 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4579 ["ma", "mb", "mc"]
4580 .iter()
4581 .zip(dirs.iter())
4582 .map(|(m, d)| {
4583 let mut mount = folder_mount(m, d.clone());
4584 if lazy_mid && *m == "ma" {
4585 mount.lifecycle = MountLifecycle::Lazy;
4586 }
4587 (
4588 mount,
4589 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4590 )
4591 })
4592 .collect()
4593 };
4594 let grants = || {
4595 let mut settings = crate::workspace::WorkspaceSettings::default();
4596 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4597 settings
4598 .cross_mem_links
4599 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4600 }
4601 settings
4602 };
4603 let relate = |engine: &mut Engine, from: &str, to: &str| {
4604 let (actor, client) = cli_actor();
4605 engine.relate_entity(
4606 RelateEntityArgs {
4607 source: crate::EntityId::new(from, "node"),
4608 expected_hash: None,
4609 rel_type: "DEPENDS_ON".to_string(),
4610 target: crate::EntityId::new(to, "node"),
4611 remove: false,
4612 description: None,
4613 dry_run: false,
4614 },
4615 actor,
4616 Some(&client),
4617 None,
4618 )
4619 };
4620
4621 {
4623 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4624 authoring.set_settings(grants());
4625 relate(&mut authoring, "mb", "ma").expect("mb→ma lands");
4626 relate(&mut authoring, "ma", "mc").expect("ma→mc lands");
4627 }
4628
4629 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4632 engine.set_settings(grants());
4633 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4634 let err = relate(&mut engine, "mc", "mb")
4635 .expect_err("a cycle through a deferred mem must refuse, as eager refuses");
4636 assert!(
4637 matches!(err, EngineError::RelationshipCycle { .. }),
4638 "expected RELATIONSHIP_CYCLE, got {err:?}"
4639 );
4640 }
4641
4642 #[test]
4650 fn batch_acyclicity_guard_sees_edges_in_deferred_mems() {
4651 use crate::engine::RelateEntityArgs;
4652 use memstead_schema::workspace_config::CrossLinkValue;
4653
4654 let tmp = TempDir::new().unwrap();
4655 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4656 .iter()
4657 .map(|m| {
4658 let d = tmp.path().join(m);
4659 std::fs::create_dir_all(&d).unwrap();
4660 d
4661 })
4662 .collect();
4663 write_spec(&dirs[0], "node", "Node A", "");
4664 write_spec(&dirs[1], "node", "Node B", "");
4665 write_spec(&dirs[2], "node", "Node C", "");
4666 write_spec(&dirs[2], "node2", "Node C2", "");
4671
4672 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4673 ["ma", "mb", "mc"]
4674 .iter()
4675 .zip(dirs.iter())
4676 .map(|(m, d)| {
4677 let mut mount = folder_mount(m, d.clone());
4678 if lazy_mid && *m == "ma" {
4679 mount.lifecycle = MountLifecycle::Lazy;
4680 }
4681 (
4682 mount,
4683 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4684 )
4685 })
4686 .collect()
4687 };
4688 let grants = || {
4689 let mut settings = crate::workspace::WorkspaceSettings::default();
4690 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4691 settings
4692 .cross_mem_links
4693 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4694 }
4695 settings
4696 };
4697 let relate_args = |from: &str, to: &str| RelateEntityArgs {
4698 source: crate::EntityId::new(from, "node"),
4699 expected_hash: None,
4700 rel_type: "DEPENDS_ON".to_string(),
4701 target: crate::EntityId::new(to, "node"),
4702 remove: false,
4703 description: None,
4704 dry_run: false,
4705 };
4706
4707 {
4709 let (actor, client) = cli_actor();
4710 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4711 authoring.set_settings(grants());
4712 authoring
4713 .relate_entity(relate_args("mb", "ma"), actor, Some(&client), None)
4714 .expect("mb→ma lands");
4715 let (actor2, client2) = cli_actor();
4716 authoring
4717 .relate_entity(relate_args("ma", "mc"), actor2, Some(&client2), None)
4718 .expect("ma→mc lands");
4719 }
4720
4721 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4725 engine.set_settings(grants());
4726 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4727 let (actor, client) = cli_actor();
4728 let result = engine
4729 .batch_relate(
4730 vec![
4731 (relate_args("mc", "mb"), None),
4732 (
4738 RelateEntityArgs {
4739 source: crate::EntityId::new("mc", "node2"),
4740 expected_hash: None,
4741 rel_type: "DEPENDS_ON".to_string(),
4742 target: crate::EntityId::new("mc", "node"),
4743 remove: false,
4744 description: None,
4745 dry_run: false,
4746 },
4747 None,
4748 ),
4749 ],
4750 actor,
4751 Some(&client),
4752 false,
4753 )
4754 .expect("the batch call itself returns a report-all envelope");
4755 assert!(
4756 !result.applied,
4757 "a batch closing a cycle through a deferred mem must refuse, as eager refuses; got applied with {} succeeded",
4758 result.succeeded
4759 );
4760 }
4761
4762 #[test]
4770 fn relate_into_deferred_mem_verifies_against_storage_without_load() {
4771 use crate::engine::RelateEntityArgs;
4772 use memstead_schema::workspace_config::CrossLinkValue;
4773
4774 let tmp = TempDir::new().unwrap();
4775 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4776 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4777 let mut settings = crate::workspace::WorkspaceSettings::default();
4778 settings.cross_mem_links.insert(
4779 "eag".to_string(),
4780 CrossLinkValue::List(vec!["laz".to_string()]),
4781 );
4782 engine.set_settings(settings);
4783
4784 let relate = |engine: &mut Engine, to: &str| {
4785 let (actor, client) = cli_actor();
4786 engine.relate_entity(
4787 RelateEntityArgs {
4788 source: crate::EntityId::new("eag", "alpha"),
4789 expected_hash: None,
4790 rel_type: "SUPPORTS".to_string(),
4791 target: crate::EntityId::new("laz", to),
4792 remove: false,
4793 description: None,
4794 dry_run: false,
4795 },
4796 actor,
4797 Some(&client),
4798 None,
4799 )
4800 };
4801
4802 let outcome = relate(&mut engine, "omega").expect("verified target admits");
4804 assert!(
4805 engine.mem_is_deferred("laz"),
4806 "verification never loads the mem"
4807 );
4808 assert!(
4809 !outcome
4810 .warnings
4811 .iter()
4812 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4813 "a storage-verified target is not an auto-stub case: {:?}",
4814 outcome.warnings
4815 );
4816 let stub = engine
4817 .store()
4818 .get(&crate::EntityId::new("laz", "omega"))
4819 .expect("until-load stub present");
4820 assert!(stub.stub);
4821 assert_eq!(
4822 stub.stub_kind,
4823 Some(crate::entity::StubKind::LoadTime),
4824 "verified-in-storage stub carries the load-time kind"
4825 );
4826
4827 let outcome = relate(&mut engine, "missing").expect("absent Write-mem target auto-stubs");
4829 assert!(engine.mem_is_deferred("laz"), "still no load");
4830 assert!(
4831 outcome
4832 .warnings
4833 .iter()
4834 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4835 "absent target keeps the auto-stub warning: {:?}",
4836 outcome.warnings
4837 );
4838 let stub = engine
4839 .store()
4840 .get(&crate::EntityId::new("laz", "missing"))
4841 .expect("forward-reference stub present");
4842 assert_eq!(
4843 stub.stub_kind,
4844 Some(crate::entity::StubKind::ForwardReference)
4845 );
4846 }
4847
4848 #[test]
4854 fn readonly_deferred_target_answers_from_storage() {
4855 use crate::engine::RelateEntityArgs;
4856 use memstead_schema::workspace_config::CrossLinkValue;
4857
4858 let tmp = TempDir::new().unwrap();
4859 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4860 let mut ro_mount = lazy_folder_mount("laz", lazy_dir.to_path_buf());
4861 ro_mount.capability = crate::workspace::MountCapability::ReadOnly;
4862 let mut engine = Engine::from_mounts(vec![
4863 (
4864 folder_mount("eag", eager_dir.to_path_buf()),
4865 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4866 ),
4867 (
4868 ro_mount,
4869 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4870 ),
4871 ])
4872 .unwrap();
4873 let mut settings = crate::workspace::WorkspaceSettings::default();
4874 settings.cross_mem_links.insert(
4875 "eag".to_string(),
4876 CrossLinkValue::List(vec!["laz".to_string()]),
4877 );
4878 engine.set_settings(settings);
4879
4880 let relate = |engine: &mut Engine, to: &str| {
4881 let (actor, client) = cli_actor();
4882 engine.relate_entity(
4883 RelateEntityArgs {
4884 source: crate::EntityId::new("eag", "alpha"),
4885 expected_hash: None,
4886 rel_type: "SUPPORTS".to_string(),
4887 target: crate::EntityId::new("laz", to),
4888 remove: false,
4889 description: None,
4890 dry_run: false,
4891 },
4892 actor,
4893 Some(&client),
4894 None,
4895 )
4896 };
4897
4898 relate(&mut engine, "omega").expect("present-in-storage RO target admits");
4899 assert!(
4900 engine.mem_is_deferred("laz"),
4901 "the admit never loads the mem"
4902 );
4903
4904 let err =
4905 relate(&mut engine, "missing").expect_err("absent RO target keeps the typed refusal");
4906 assert!(
4907 matches!(err, EngineError::CrossMemTargetNotFound { .. }),
4908 "expected CROSS_MEM_TARGET_NOT_FOUND, got {err:?}"
4909 );
4910 assert!(
4911 engine.mem_is_deferred("laz"),
4912 "the refusal never loads the mem either"
4913 );
4914 }
4915
4916 #[test]
4921 fn cross_mem_link_into_lazy_mem_resolves_on_load() {
4922 let tmp = TempDir::new().unwrap();
4923 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4924 write_spec(
4925 &eager_dir,
4926 "linker",
4927 "Linker",
4928 "\nSee [[laz--omega]] for detail.\n",
4929 );
4930
4931 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4932 let target = crate::EntityId::new("laz", "omega");
4933 assert!(
4934 engine.get_entity(&target).is_none_or(|e| e.stub),
4935 "before the lazy load the cross-mem target is at most a stub, never real"
4936 );
4937
4938 engine.reload_if_stale(Some("laz"));
4939 let resolved = engine.get_entity(&target).expect("target loaded");
4940 assert!(!resolved.stub, "after the load the target is real");
4941 assert!(
4942 !engine.load_warnings().iter().any(
4943 |w| matches!(w, WarningHint::SuspiciousNestedPrefix { resolved_id, .. } if resolved_id.as_ref() == target.as_ref())
4944 ),
4945 "no lingering nested-prefix warning for a resolved cross-mem link: {:?}",
4946 engine.load_warnings()
4947 );
4948 }
4949
4950 fn roster_change(warnings: &[WarningHint]) -> Option<(Vec<String>, Vec<String>)> {
4954 warnings.iter().find_map(|w| match w {
4955 WarningHint::MemRosterChanged { added, removed, .. } => {
4956 Some((added.clone(), removed.clone()))
4957 }
4958 _ => None,
4959 })
4960 }
4961
4962 fn search_scope(mem: Option<&str>, term: &str) -> crate::ops::SearchScope {
4963 crate::ops::SearchScope {
4964 query: Some(crate::ops::Query {
4965 any: vec![term.to_string()],
4966 ..Default::default()
4967 }),
4968 mem: mem.map(str::to_string),
4969 ..Default::default()
4970 }
4971 }
4972
4973 fn roster_fixture() -> (TempDir, Engine) {
4974 use crate::workspace_store::WorkspaceStoreAdapter;
4975 let tmp = TempDir::new().unwrap();
4976 let root = tmp.path();
4977 std::fs::create_dir_all(root.join(".memstead")).unwrap();
4978 std::fs::write(
4979 root.join(".memstead").join("workspace.toml"),
4980 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4981 )
4982 .unwrap();
4983 for mem in ["alpha", "beta"] {
4984 let dir = root.join(mem);
4985 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
4986 std::fs::write(
4987 dir.join(".memstead").join("config.json"),
4988 br#"{"format":1,"schema":"default@1.0.0"}"#,
4989 )
4990 .unwrap();
4991 }
4992 std::fs::write(
4993 root.join("alpha").join("one.md"),
4994 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# One\n\n## Identity\n\nalpha one\n\n## Purpose\n\nseed\n",
4995 )
4996 .unwrap();
4997 std::fs::write(
4998 root.join("beta").join("two.md"),
4999 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Two\n\n## Identity\n\nbeta two\n\n## Purpose\n\nseed\n\n## Relationships\n\n- **DEPENDS_ON**: [[alpha--one]]\n",
5000 )
5001 .unwrap();
5002 let mount = |mem: &str| Mount {
5003 mem: mem.to_string(),
5004 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
5005 storage: MountStorage::Folder {
5006 path: root.join(mem),
5007 },
5008 capability: MountCapability::Write,
5009 lifecycle: MountLifecycle::Eager,
5010 cross_linkable: true,
5011 migration_target: None,
5012 };
5013 crate::FileWorkspaceStore::new()
5014 .save_state(
5015 root,
5016 &crate::workspace::Workspace {
5017 mounts: vec![mount("alpha"), mount("beta")],
5018 settings: crate::workspace::WorkspaceSettings::default(),
5019 },
5020 )
5021 .unwrap();
5022 let engine = Engine::from_workspace_root(root).expect("workspace boots");
5023 (tmp, engine)
5024 }
5025
5026 fn save_roster(root: &Path, mems: &[(&str, &Path)]) {
5027 let with_schema: Vec<(&str, &Path, &str)> =
5028 mems.iter().map(|(m, d)| (*m, *d, "default")).collect();
5029 save_roster_pinned(root, &with_schema);
5030 }
5031
5032 fn save_roster_pinned(root: &Path, mems: &[(&str, &Path, &str)]) {
5033 use crate::workspace_store::WorkspaceStoreAdapter;
5034 let mounts = mems
5035 .iter()
5036 .map(|(mem, dir, schema)| Mount {
5037 mem: mem.to_string(),
5038 schema: Some(SchemaRef::new(*schema, semver::Version::new(1, 0, 0))),
5039 storage: MountStorage::Folder {
5040 path: dir.to_path_buf(),
5041 },
5042 capability: MountCapability::Write,
5043 lifecycle: MountLifecycle::Eager,
5044 cross_linkable: true,
5045 migration_target: None,
5046 })
5047 .collect();
5048 crate::FileWorkspaceStore::new()
5051 .save_state(
5052 root,
5053 &crate::workspace::Workspace {
5054 mounts,
5055 settings: crate::workspace::WorkspaceSettings::default(),
5056 },
5057 )
5058 .unwrap();
5059 }
5060
5061 #[test]
5066 fn roster_membership_follows_reload_before_operation() {
5067 let (tmp, mut engine) = roster_fixture();
5068 let root = tmp.path();
5069 assert_eq!(engine.mem_names(), vec!["alpha", "beta"]);
5070 assert!(
5071 engine.reload_if_stale(None).is_empty(),
5072 "no change, no warning"
5073 );
5074
5075 save_roster(root, &[("alpha", &root.join("alpha"))]);
5077 let warnings = engine.reload_if_stale(None);
5078 let (added, removed) = roster_change(&warnings).expect("the roster change is reported");
5079 assert_eq!(removed, vec!["beta".to_string()]);
5080 assert!(added.is_empty());
5081 assert_eq!(engine.mem_names(), vec!["alpha"]);
5082 assert!(
5083 engine
5084 .get_entity(&crate::EntityId("beta--two".into()))
5085 .is_none()
5086 );
5087 let unscoped = engine.search(&search_scope(None, "beta two")).unwrap();
5088 assert_eq!(
5089 unscoped.total, 0,
5090 "no hit from the removed mem: {unscoped:?}"
5091 );
5092 let scoped = engine
5093 .search(&search_scope(Some("beta"), "beta two"))
5094 .expect_err("a search scoped to the removed mem refuses");
5095 assert_eq!(scoped.code(), "MEM_UNMOUNTED", "{scoped}");
5096 let err = engine.unknown_mem_error("beta");
5097 assert_eq!(err.code(), "MEM_UNMOUNTED", "{err}");
5098 assert!(
5099 engine.reload_if_stale(None).is_empty(),
5100 "reconciled once, quiet after"
5101 );
5102
5103 save_roster(
5105 root,
5106 &[("alpha", &root.join("alpha")), ("beta", &root.join("beta"))],
5107 );
5108 let warnings = engine.reload_if_stale(None);
5109 let (added, _) = roster_change(&warnings).expect("the return is reported");
5110 assert_eq!(added, vec!["beta".to_string()]);
5111 assert_eq!(engine.mem_names(), vec!["alpha", "beta"]);
5112 assert!(
5113 engine
5114 .get_entity(&crate::EntityId("beta--two".into()))
5115 .is_some()
5116 );
5117 assert_eq!(
5118 engine.unknown_mem_error("beta").code(),
5119 "UNKNOWN_MEM",
5120 "the memory clears"
5121 );
5122 }
5123
5124 #[test]
5128 fn roster_entry_with_missing_storage_quarantines_like_boot() {
5129 let (tmp, mut engine) = roster_fixture();
5130 let root = tmp.path();
5131 std::fs::create_dir_all(root.join("ghost").join(".memstead")).unwrap();
5136 std::fs::write(
5137 root.join("ghost").join(".memstead").join("config.json"),
5138 br#"{"format":1,"schema":"nope@1.0.0"}"#,
5139 )
5140 .unwrap();
5141 save_roster_pinned(
5142 root,
5143 &[
5144 ("alpha", &root.join("alpha"), "default"),
5145 ("beta", &root.join("beta"), "default"),
5146 ("ghost", &root.join("ghost"), "nope"),
5147 ("hollow", &root.join("does-not-exist"), "default"),
5148 ],
5149 );
5150 let warnings = engine.reload_if_stale(None);
5151 let quarantined = warnings
5152 .iter()
5153 .find_map(|w| match w {
5154 WarningHint::MemRosterChanged { quarantined, .. } => Some(quarantined.clone()),
5155 _ => None,
5156 })
5157 .expect("reported");
5158 assert_eq!(quarantined, vec!["ghost".to_string(), "hollow".to_string()]);
5159 let q = engine
5160 .quarantine_reason("ghost")
5161 .expect("on the quarantine roster");
5162 let booted = Engine::from_workspace_root(root).unwrap();
5164 let at_boot = booted
5165 .quarantine_reason("ghost")
5166 .expect("boot quarantines it too");
5167 assert_eq!(q.reason_code, at_boot.reason_code);
5168 assert_eq!(q.reason_code, "SCHEMA_NOT_FOUND");
5169 for name in ["ghost", "hollow"] {
5172 assert_eq!(
5173 engine
5174 .quarantine_reason(name)
5175 .map(|q| q.reason_code.clone()),
5176 booted
5177 .quarantine_reason(name)
5178 .map(|q| q.reason_code.clone()),
5179 "{name}: quarantine parity with boot"
5180 );
5181 assert_eq!(
5182 engine.mem_names().contains(&name),
5183 booted.mem_names().contains(&name),
5184 "{name}: membership parity with boot (reconciled {:?}, boot {:?})",
5185 engine.mem_names(),
5186 booted.mem_names()
5187 );
5188 }
5189 assert!(engine.mem_names().contains(&"alpha") && engine.mem_names().contains(&"beta"));
5190 assert!(
5191 engine
5192 .get_entity(&crate::EntityId("alpha--one".into()))
5193 .is_some()
5194 );
5195 assert_eq!(engine.unknown_mem_error("ghost").code(), "MEM_QUARANTINED");
5196 }
5197
5198 #[test]
5204 fn unmount_is_atomic_and_cross_mem_edges_go_dangling() {
5205 let (tmp, mut engine) = roster_fixture();
5206 let root = tmp.path();
5207 save_roster(root, &[("beta", &root.join("beta"))]);
5209 let _ = engine.reload_if_stale(None);
5210 assert_eq!(engine.mem_names(), vec!["beta"]);
5211 let findings = engine.consistency_findings("beta").unwrap();
5212 assert!(
5213 findings
5214 .iter()
5215 .any(|f| f.code == "DANGLING_RELATION_TARGET_MISSING" && f.id == "beta--two"),
5216 "the edge into the unmounted mem is dangling: {findings:?}"
5217 );
5218 let hits = engine.search(&search_scope(None, "alpha one")).unwrap();
5219 assert_eq!(hits.total, 0, "{hits:?}");
5220 let communities = engine.communities();
5222 assert!(
5223 communities
5224 .entity_cluster_map
5225 .keys()
5226 .all(|id| !id.starts_with("alpha--")),
5227 "the community partition carries no entity of the removed mem"
5228 );
5229 assert!(
5230 communities
5231 .clusters
5232 .values()
5233 .all(|c| c.entities.iter().all(|e| !e.starts_with("alpha--")))
5234 );
5235 assert!(engine.store().all_entities().all(|e| e.mem != "alpha"));
5236
5237 let start = std::time::Instant::now();
5240 for _ in 0..100 {
5241 let _ = engine.reload_if_stale(None);
5242 }
5243 let with_roster = start.elapsed();
5244 let start = std::time::Instant::now();
5245 for _ in 0..100 {
5246 let _ = engine.reload_if_stale_content_only(None);
5247 }
5248 let content_only = start.elapsed();
5249 eprintln!(
5250 "BENCH roster probe: 100 ops with roster {with_roster:?}, content-only {content_only:?}"
5251 );
5252 assert!(
5253 with_roster < content_only * 4 + std::time::Duration::from_millis(50),
5254 "roster probe left the branch-tip band: {with_roster:?} vs {content_only:?}"
5255 );
5256 }
5257
5258 #[test]
5262 fn failed_unmount_leaves_the_roster_unapplied_and_the_mem_served() {
5263 let (tmp, mut engine) = roster_fixture();
5264 let root = tmp.path();
5265 engine.inject_unmount_failure = Some("beta".to_string());
5266 save_roster(root, &[("alpha", &root.join("alpha"))]);
5267 let warnings = engine.reload_if_stale(None);
5268 let (removed, failures) = warnings
5269 .iter()
5270 .find_map(|w| match w {
5271 WarningHint::MemRosterChanged {
5272 removed, failures, ..
5273 } => Some((removed.clone(), failures.clone())),
5274 _ => None,
5275 })
5276 .expect("reported");
5277 assert!(removed.is_empty());
5278 assert!(
5279 failures
5280 .iter()
5281 .any(|f| f.contains("unmount:beta") && f.contains("injected")),
5282 "{failures:?}"
5283 );
5284 assert_eq!(
5285 engine.mem_names(),
5286 vec!["alpha", "beta"],
5287 "beta still served"
5288 );
5289 assert!(
5290 engine
5291 .get_entity(&crate::EntityId("beta--two".into()))
5292 .is_some()
5293 );
5294 assert_eq!(
5295 engine.unknown_mem_error("beta").code(),
5296 "UNKNOWN_MEM",
5297 "not remembered as unmounted"
5298 );
5299 engine.inject_unmount_failure = None;
5302 let warnings = engine.reload_if_stale(None);
5303 let (_, removed) = roster_change(&warnings).expect("retried");
5304 assert_eq!(removed, vec!["beta".to_string()]);
5305 assert_eq!(engine.mem_names(), vec!["alpha"]);
5306 }
5307}