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
229 .mem_config
230 .as_ref()
231 .and_then(|c| c.mutation_stamp.as_ref())
232 && stamp.engine_version != crate::build_info::full_version()
233 {
234 load_warnings.push(WarningHint::EngineVersionSkew {
235 mem: m.mount.mem.clone(),
236 stamped_engine: stamp.engine_version.clone(),
237 running_engine: crate::build_info::full_version().to_string(),
238 stamped_schema: stamp.schema.clone(),
239 });
240 }
241 let settled_pin = config_pin.or(mount_pin);
244 let Some(effective_pin) = m.mount.migration_target.as_ref().or(settled_pin) else {
247 let e = EngineError::MemConfigIncomplete {
249 mem: m.mount.mem.clone(),
250 missing_fields: vec!["schema".to_string()],
251 };
252 quarantined.push(crate::engine::QuarantinedMem {
253 mount: m.mount.clone(),
254 reason_code: e.code().to_string(),
255 reason_message: e.to_string(),
256 });
257 quarantined_idx.insert(m_idx);
258 continue;
259 };
260 let schema = match SchemaResolver::new(&builtin_schemas).resolve(effective_pin) {
261 Ok(schema) => schema,
262 Err(sources) => {
263 let failed = failed_schema_packages.iter().find(|f| {
271 f.name.as_deref() == Some(effective_pin.name.as_str())
272 && f.version
273 .as_deref()
274 .is_none_or(|v| v == effective_pin.version.to_string())
275 });
276 let (reason_code, reason_message) = match failed {
277 Some(f) => (
278 "SCHEMA_LOAD_FAILED".to_string(),
279 format!(
280 "schema package at {} failed to load: {}",
281 f.path.display(),
282 f.error
283 ),
284 ),
285 None => {
286 let e = EngineError::SchemaNotFound {
287 mem: m.mount.mem.clone(),
288 pin: effective_pin.as_display(),
289 sources,
290 install_hint: None,
291 };
292 (e.code().to_string(), e.to_string())
293 }
294 };
295 quarantined.push(crate::engine::QuarantinedMem {
296 mount: m.mount.clone(),
297 reason_code,
298 reason_message,
299 });
300 quarantined_idx.insert(m_idx);
301 continue;
302 }
303 };
304 schemas.insert(m.mount.mem.clone(), schema.clone());
305
306 let locally_installed = workspace_schemas.iter().any(|s| {
316 s.manifest.name == effective_pin.name && s.version == effective_pin.version
317 });
318 let is_builtin = builtin_schemas_only.iter().any(|s| {
319 s.manifest.name == effective_pin.name && s.version == effective_pin.version
320 });
321 if !locally_installed
322 && is_builtin
323 && let Some(newest) =
324 newest_builtin_version(&effective_pin.name, &builtin_schemas_only)
325 && *newest > effective_pin.version
326 {
327 load_warnings.push(WarningHint::SchemaGenerationsBehind {
328 mem: m.mount.mem.clone(),
329 pinned: effective_pin.as_display(),
330 newest: newest.to_string(),
331 });
332 }
333
334 if let Err(memstead_schema::SchemaLoadError::SectionHeadingMismatch { violations }) =
339 memstead_schema::check_section_heading_roundtrip(&schema)
340 {
341 let (name, version) = schema.id();
342 load_warnings.push(WarningHint::SchemaHeadingRoundtripViolation {
343 mem: m.mount.mem.clone(),
344 schema_ref: format!("{name}@{version}"),
345 violations: violations.iter().map(Into::into).collect(),
346 });
347 }
348
349 if m.mount.lifecycle == crate::workspace::MountLifecycle::Lazy {
355 if let Some(w) = unbacked_mount_warning(&m.mount, m.backend.as_ref(), None) {
356 load_warnings.push(w);
357 }
358 deferred_idx.insert(m_idx);
359 continue;
360 }
361
362 let (entries, read_errors) = match collect_source_entries(m.backend.as_ref()) {
363 Ok(pair) => pair,
364 Err(e) => {
365 quarantined.push(crate::engine::QuarantinedMem {
368 mount: m.mount.clone(),
369 reason_code: e.code().to_string(),
370 reason_message: e.to_string(),
371 });
372 quarantined_idx.insert(m_idx);
373 schemas.remove(&m.mount.mem);
374 continue;
375 }
376 };
377 if let Some(w) =
381 unbacked_mount_warning(&m.mount, m.backend.as_ref(), Some(entries.len()))
382 {
383 load_warnings.push(w);
384 }
385 let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
386 push_entities_into_store(
392 &mut store,
393 load_result.entities,
394 fallback.as_ref(),
395 Some(crate::entity::store_builder::LoadCollector {
396 warnings: &mut load_warnings,
397 known_suffixes: &known_suffixes,
398 mem_names: &mem_names,
399 }),
400 );
401 if let crate::workspace::MountStorage::Folder { path } = &m.mount.storage {
406 let root = path.clone();
407 load_errors.extend(load_result.errors.into_iter().map(|(p, msg)| {
408 let abs = if p.is_relative() { root.join(&p) } else { p };
409 (abs, msg)
410 }));
411 } else {
412 load_errors.extend(load_result.errors);
413 }
414 }
415
416 for idx in &deferred_idx {
419 mounted[*idx].deferred = true;
420 }
421
422 if !quarantined_idx.is_empty() {
427 let mut keep_idx = 0usize;
428 mounted.retain(|_| {
429 let keep = !quarantined_idx.contains(&keep_idx);
430 keep_idx += 1;
431 keep
432 });
433 }
434
435 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
443 mounted
444 .iter()
445 .map(|m| (m.mount.mem.clone(), m.mount.capability))
446 .collect();
447 crate::entity::store_builder::validate_loaded_relations(
448 &mut store,
449 &schemas,
450 &mount_caps,
451 &mut load_warnings,
452 );
453
454 crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
459
460 load_warnings.retain(|w| match w {
469 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
470 store.get(resolved_id).is_none_or(|e| e.stub)
471 }
472 _ => true,
473 });
474
475 let mem_router = build_mem_router_from_mounts(&mounted);
481
482 Ok(Self {
483 mounts: mounted,
484 store,
485 schemas,
486 workspace_schemas,
487 builtin_schemas: builtin_schemas_only,
488 load_errors,
489 community_memo: OnceCell::new(),
490 labelling_memo: OnceCell::new(),
491 #[cfg(not(target_arch = "wasm32"))]
492 search_indexes_memo: OnceCell::new(),
493 settings: WorkspaceSettings::default(),
494 create_rule_set_memo: OnceCell::new(),
495 declared_origins: HashMap::new(),
496 workspace_root: None,
497 load_warnings,
498 quarantined,
499 boot_diagnosis: None,
500 pipeline_configs: crate::pipeline_store::BindingConfigs::default(),
501 mem_router: Arc::new(mem_router),
502 backend_factory: crate::workspace_store::instantiate_lean_backend,
503 unmounted_storage_prober: None,
504 schemas_epoch: 0,
505 git_branch_ops: None,
506 event_subscribers: Arc::new(std::sync::Mutex::new(
507 crate::engine::events::SubscriberRegistry::new(),
508 )),
509 pending_mem_changed: Vec::new(),
510 mutation_clock: Arc::new(std::time::SystemTime::now),
511 current_role: crate::vcs::Role::Unspecified,
512 })
513 }
514
515 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
533 use crate::workspace_store::{
534 FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
535 instantiate_lean_backend,
536 };
537
538 let workspace = match detect_layout(workspace_root) {
539 Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
543 Some(ws) => ws,
544 None => {
545 return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
546 }
547 },
548 Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
549 };
550
551 let settings = workspace.settings.clone();
552 let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
553 Vec::with_capacity(workspace.mounts.len());
554 let mut instantiate_quarantine: Vec<crate::engine::QuarantinedMem> = Vec::new();
558 for mount in workspace.mounts {
559 match instantiate_lean_backend(&mount) {
560 Ok(backend) => mounts.push((mount, backend)),
561 Err(e) => instantiate_quarantine.push(crate::engine::QuarantinedMem {
562 reason_code: e.code().to_string(),
563 reason_message: e.to_string(),
564 mount,
565 }),
566 }
567 }
568 let fixed_dir = workspace_root.join(".memstead").join("schemas");
577 let (local, failed) = load_workspace_schemas_with_failures(Some(fixed_dir.as_path()));
578 let mut engine = Engine::from_mounts_inner(mounts, local, failed)
581 .map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
582 engine.quarantined.extend(instantiate_quarantine);
583 engine.set_settings(settings);
584 engine.workspace_root = Some(workspace_root.to_path_buf());
585 engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
596 workspace_root,
597 )?);
598 let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
603 Ok(engine)
604 }
605}
606
607pub(crate) fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
640 let mut router = MemRouterSnapshot::new();
641 for m in mounts {
642 match m.mount.capability {
643 MountCapability::Write => {
644 let dir: Option<PathBuf> = match &m.mount.storage {
645 MountStorage::Folder { path } => Some(path.clone()),
646 MountStorage::GitBranch { .. } => None,
647 MountStorage::Archive { .. } => None,
648 MountStorage::InMemory => None,
652 };
653 router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
654 }
655 MountCapability::ReadOnly => match &m.mount.storage {
656 MountStorage::Archive { path } => {
657 router.add_read_only(m.mount.mem.clone(), path.clone());
658 }
659 MountStorage::Folder { path } => {
660 router.add_read_only(m.mount.mem.clone(), path.clone());
661 }
662 MountStorage::GitBranch { gitdir, .. } => {
663 router.add_read_only(m.mount.mem.clone(), gitdir.clone());
664 }
665 MountStorage::InMemory => {}
671 },
672 }
673 }
674 router
675}
676
677pub fn resolve_builtin_schema_pin_pub(
682 pin: &memstead_schema::SchemaRef,
683 catalogue: &[Arc<memstead_schema::Schema>],
684) -> Option<Arc<memstead_schema::Schema>> {
685 resolve_builtin_schema_pin(pin, catalogue)
686}
687
688fn newest_builtin_version<'a>(
693 name: &str,
694 builtins: &'a [Arc<memstead_schema::Schema>],
695) -> Option<&'a semver::Version> {
696 builtins
697 .iter()
698 .filter(|s| s.manifest.name == name)
699 .map(|s| &s.version)
700 .max()
701}
702
703pub struct SchemaResolver<'a> {
718 catalogue: &'a [Arc<memstead_schema::Schema>],
719}
720
721impl<'a> SchemaResolver<'a> {
722 pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
725 Self { catalogue }
726 }
727
728 pub fn resolve(
732 &self,
733 pin: &memstead_schema::SchemaRef,
734 ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
735 {
736 resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
737 crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
738 &pin.name,
739 &pin.version,
740 self.catalogue,
741 )
742 })
743 }
744}
745
746pub fn load_workspace_schemas(
754 schemas_dir: Option<&Path>,
755) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
756 Ok(load_workspace_schemas_with_failures(schemas_dir).0)
757}
758
759#[derive(Debug, Clone)]
766pub struct FailedSchemaPackage {
767 pub path: PathBuf,
768 pub name: Option<String>,
769 pub version: Option<String>,
770 pub error: String,
772}
773
774pub fn load_workspace_schemas_with_failures(
780 schemas_dir: Option<&Path>,
781) -> (Vec<Arc<memstead_schema::Schema>>, Vec<FailedSchemaPackage>) {
782 let Some(dir) = schemas_dir else {
783 return (Vec::new(), Vec::new());
784 };
785 if !dir.is_dir() {
786 return (Vec::new(), Vec::new());
787 }
788 let entries = match std::fs::read_dir(dir) {
789 Ok(e) => e,
790 Err(_) => return (Vec::new(), Vec::new()),
791 };
792 let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
793 let mut failures: Vec<FailedSchemaPackage> = Vec::new();
794 for entry in entries.flatten() {
795 let path = entry.path();
796 if !path.is_dir() {
797 continue;
798 }
799 if !path.join("schema.yaml").is_file() {
800 continue;
801 }
802 match memstead_schema::load_schema_from_dir(&path) {
803 Ok(schema) => schemas.push(Arc::new(schema)),
804 Err(e) => {
805 let header = std::fs::read_to_string(path.join("schema.yaml")).unwrap_or_default();
809 let peek = |k: &str| {
810 header
811 .lines()
812 .find_map(|l| l.strip_prefix(&format!("{k}:")))
813 .map(|v| v.trim().trim_matches('"').to_string())
814 .filter(|v| !v.is_empty())
815 };
816 failures.push(FailedSchemaPackage {
817 path: path.clone(),
818 name: peek("name"),
819 version: peek("version"),
820 error: e.to_string(),
821 });
822 }
823 }
824 }
825 (schemas, failures)
826}
827
828pub(super) fn resolve_builtin_schema_pin(
829 pin: &memstead_schema::SchemaRef,
830 catalogue: &[Arc<memstead_schema::Schema>],
831) -> Option<Arc<memstead_schema::Schema>> {
832 catalogue
833 .iter()
834 .find(|s| {
835 let id = s.id();
836 id.0 == pin.name && id.1 == pin.version
837 })
838 .cloned()
839}
840
841pub(super) fn unbacked_mount_warning(
849 mount: &crate::workspace::Mount,
850 backend: &dyn MemBackend,
851 entity_count: Option<usize>,
852) -> Option<WarningHint> {
853 use crate::ops::MountUnbackedReason;
854 use crate::workspace::MountStorage;
855 let (location, missing_reason) = match &mount.storage {
856 MountStorage::GitBranch { branch, .. } => (branch.clone(), MountUnbackedReason::MissingRef),
857 MountStorage::Folder { path } => {
858 (path.display().to_string(), MountUnbackedReason::MissingPath)
859 }
860 MountStorage::Archive { path } => {
861 (path.display().to_string(), MountUnbackedReason::MissingPath)
862 }
863 MountStorage::InMemory => return None,
864 };
865 if !backend.storage_present().unwrap_or(true) {
866 return Some(WarningHint::MountUnbacked {
867 mem: mount.mem.clone(),
868 reason: missing_reason,
869 location,
870 });
871 }
872 if entity_count == Some(0) {
873 return Some(WarningHint::MountUnbacked {
874 mem: mount.mem.clone(),
875 reason: MountUnbackedReason::Empty,
876 location,
877 });
878 }
879 None
880}
881
882pub(super) fn collect_source_entries(
883 backend: &dyn MemBackend,
884) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
885 let paths = backend.list_entities()?;
886 let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
887 let mut errors: Vec<SourceReadError> = Vec::new();
888 for path in paths {
889 match backend.read_entity(&path) {
890 Ok(Some(bytes)) => match String::from_utf8(bytes) {
891 Ok(content) => entries.push(SourceEntry {
892 relative_path: path.to_string_lossy().into_owned(),
893 source_path: path.clone(),
894 content,
895 }),
896 Err(e) => errors.push(SourceReadError {
897 source_path: path,
898 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
899 }),
900 },
901 Ok(None) => {
902 }
904 Err(e) => errors.push(SourceReadError {
905 source_path: path,
906 error: std::io::Error::other(e.to_string()),
907 }),
908 }
909 }
910 Ok((entries, errors))
911}
912
913#[cfg(test)]
914mod tests {
915
916 use std::path::Path;
917
918 use memstead_schema::SchemaRef;
919 use tempfile::TempDir;
920
921 use crate::backend::MemBackend;
922 use crate::engine::test_helpers::*;
923 use crate::engine::{Engine, EngineError};
924 use crate::ops::WarningHint;
925 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
926 use crate::vcs::CommitContext;
927 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
928
929 #[test]
934 fn unbacked_mount_probe_classes_missing_path_empty_and_present() {
935 use crate::engine::boot::unbacked_mount_warning;
936 use crate::ops::MountUnbackedReason;
937 let tmp = TempDir::new().unwrap();
938 let mount = |path: std::path::PathBuf| Mount {
939 mem: "probe".into(),
940 schema: None,
941 storage: MountStorage::Folder { path },
942 capability: MountCapability::Write,
943 lifecycle: MountLifecycle::Eager,
944 cross_linkable: true,
945 migration_target: None,
946 };
947
948 let gone = tmp.path().join("gone");
949 let backend = FilesystemMemWriter::new(gone.clone());
950 let w = unbacked_mount_warning(&mount(gone.clone()), &backend, Some(0))
951 .expect("a missing folder is unbacked");
952 match &w {
953 WarningHint::MountUnbacked {
954 mem,
955 reason,
956 location,
957 } => {
958 assert_eq!(mem, "probe");
959 assert_eq!(*reason, MountUnbackedReason::MissingPath);
960 assert_eq!(location, &gone.display().to_string());
961 }
962 other => panic!("unexpected variant: {other:?}"),
963 }
964 assert_eq!(w.code(), "MOUNT_UNBACKED");
965 let json = serde_json::to_value(&w).unwrap();
966 assert_eq!(json["details"]["reason"], "missing_path");
967 assert!(unbacked_mount_warning(&mount(gone), &backend, None).is_some());
969
970 let hollow = tmp.path().join("hollow");
971 std::fs::create_dir_all(&hollow).unwrap();
972 let backend = FilesystemMemWriter::new(hollow.clone());
973 let w = unbacked_mount_warning(&mount(hollow.clone()), &backend, Some(0))
974 .expect("an entity-less folder is unbacked");
975 assert_eq!(
976 serde_json::to_value(&w).unwrap()["details"]["reason"],
977 "empty"
978 );
979 assert!(unbacked_mount_warning(&mount(hollow.clone()), &backend, None).is_none());
981 assert!(unbacked_mount_warning(&mount(hollow), &backend, Some(1)).is_none());
983 }
984
985 #[test]
989 fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
990 let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
991 let resolver = super::SchemaResolver::new(&catalogue);
992
993 let ok: SchemaRef = "default@1.0.0".parse().unwrap();
994 assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
995
996 let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
997 let sources = resolver.resolve(&miss).unwrap_err();
998 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
999 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1000 assert!(sources.iter().all(|s| !s.pinned_version_match));
1001 }
1002
1003 #[test]
1004 fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
1005 let engine = Engine::from_mounts(Vec::new()).unwrap();
1006 assert!(engine.mem_names().is_empty());
1007 match engine.list_entities("missing") {
1008 Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
1009 other => panic!("expected UnknownMem, got {other:?}"),
1010 }
1011 }
1012
1013 #[test]
1014 fn duplicate_mem_names_rejected_at_construction() {
1015 let tmp = TempDir::new().unwrap();
1016 let writer1: Box<dyn MemBackend> =
1017 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1018 let writer2: Box<dyn MemBackend> =
1019 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1020 let err = Engine::from_mounts(vec![
1021 (folder_mount("specs", tmp.path().to_path_buf()), writer1),
1022 (folder_mount("specs", tmp.path().to_path_buf()), writer2),
1023 ])
1024 .unwrap_err();
1025 assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
1026 }
1027
1028 #[test]
1029 fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
1030 let tmp = TempDir::new().unwrap();
1036 let mem_dir = tmp.path().to_path_buf();
1037 let body =
1038 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1039 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1040
1041 let writer = FilesystemMemWriter::new(mem_dir.clone());
1042 let engine = Engine::from_mounts(vec![(
1043 folder_mount("specs", mem_dir),
1044 Box::new(writer) as Box<dyn MemBackend>,
1045 )])
1046 .unwrap();
1047
1048 let warnings = engine.load_warnings();
1049 assert!(
1050 warnings
1051 .iter()
1052 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1053 "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
1054 );
1055 }
1056
1057 #[test]
1065 fn generation_behind_hint_fires_for_old_builtin_pin_only() {
1066 let tmp = TempDir::new().unwrap();
1068 let mem_dir = tmp.path().to_path_buf();
1069 let writer = FilesystemMemWriter::new(mem_dir.clone());
1070 let mut engine = Engine::from_mounts(vec![(
1071 folder_mount("specs", mem_dir),
1072 Box::new(writer) as Box<dyn MemBackend>,
1073 )])
1074 .unwrap();
1075 let behind: Vec<_> = engine
1076 .load_warnings()
1077 .iter()
1078 .filter_map(|w| match w {
1079 WarningHint::SchemaGenerationsBehind {
1080 mem,
1081 pinned,
1082 newest,
1083 } => Some((mem.clone(), pinned.clone(), newest.clone())),
1084 _ => None,
1085 })
1086 .collect();
1087 assert_eq!(
1088 behind,
1089 vec![(
1090 "specs".to_string(),
1091 "default@1.0.0".to_string(),
1092 "1.3.0".to_string()
1093 )],
1094 "old built-in pin must surface the generation-behind hint"
1095 );
1096 assert!(
1097 engine
1098 .health()
1099 .warnings
1100 .iter()
1101 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1102 "the hint rides health without an include gate"
1103 );
1104 engine
1106 .create_entity_with_ctx(
1107 crate::engine::CreateEntityArgs {
1108 anchors: Vec::new(),
1109 mem: "specs".to_string(),
1110 title: "Still writable".to_string(),
1111 entity_type: "spec".to_string(),
1112 sections: indexmap::IndexMap::from_iter([
1113 ("identity".to_string(), "i".to_string()),
1114 ("purpose".to_string(), "p".to_string()),
1115 ]),
1116 metadata: indexmap::IndexMap::new(),
1117 relations: Vec::new(),
1118 dry_run: false,
1119 },
1120 &crate::vcs::CommitContext::internal(),
1121 )
1122 .expect("generation-behind hint must never block mutations");
1123
1124 let tmp = TempDir::new().unwrap();
1126 let mem_dir = tmp.path().to_path_buf();
1127 let writer = FilesystemMemWriter::new(mem_dir.clone());
1128 let mut mount = folder_mount("specs", mem_dir);
1129 mount.schema = Some("default@1.3.0".parse().unwrap());
1130 let engine =
1131 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1132 assert!(
1133 !engine
1134 .load_warnings()
1135 .iter()
1136 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1137 "newest built-in pin must stay silent: {:?}",
1138 engine.load_warnings()
1139 );
1140 let health_json = serde_json::to_string(&engine.health().warnings).unwrap();
1141 assert!(
1142 !health_json.contains("SCHEMA_GENERATIONS_BEHIND"),
1143 "newest pin: health output carries no generation hint"
1144 );
1145 }
1146
1147 #[test]
1151 fn newest_builtin_version_orders_by_semver_not_string() {
1152 let manifest = |version: &str| {
1153 format!(
1154 r#"name: gen-test
1155version: {version}
1156description: test
1157when_to_use: test
1158types:
1159 - note
1160relationships:
1161 mode: strict
1162 definitions:
1163 - name: _default
1164 description: default
1165 default_weight: 1.0
1166 - name: PART_OF
1167 description: hier
1168 default_weight: 3.0
1169community:
1170 resolution: 1.0
1171 seed: 42
1172"#
1173 )
1174 };
1175 let type_yaml = r#"name: note
1176description: test
1177when_to_use: test
1178sections:
1179 - key: body
1180 heading: Body
1181 required: true
1182 search_weight: 10.0
1183 catch_all: true
1184metadata_fields: []
1185title_weight: 1.0
1186text_fields: [body]
1187hierarchy_relationship: PART_OF
1188no_self_loop_relationships: []
1189updatable_fields: [title, body]
1190health_required_fields: [body]
1191staleness_threshold_days: 30
1192write_rules: []
1193"#;
1194 let types = vec![("note".to_string(), type_yaml.to_string())];
1195 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = ["0.9.0", "0.10.0", "0.2.0"]
1196 .iter()
1197 .map(|v| {
1198 std::sync::Arc::new(
1199 memstead_schema::load_schema_from_memory(&manifest(v), &types)
1200 .expect("fixture schema loads"),
1201 )
1202 })
1203 .collect();
1204 let newest = super::newest_builtin_version("gen-test", &catalogue)
1205 .expect("name present in catalogue");
1206 assert_eq!(newest.to_string(), "0.10.0", "semver, not string, ordering");
1207 assert!(super::newest_builtin_version("absent", &catalogue).is_none());
1208 }
1209
1210 #[test]
1216 fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
1217 let tmp = TempDir::new().unwrap();
1218 let mem_dir = tmp.path().to_path_buf();
1219 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1222 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1223 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1224 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1225
1226 let writer = FilesystemMemWriter::new(mem_dir.clone());
1227 let engine = Engine::from_mounts(vec![(
1228 folder_mount("specs", mem_dir),
1229 Box::new(writer) as Box<dyn MemBackend>,
1230 )])
1231 .unwrap();
1232
1233 let source_id = crate::entity::EntityId::new("specs", "source");
1234 let target_id = crate::entity::EntityId::new("specs", "target");
1235 let source_entity = engine.get_entity(&source_id).expect("source loaded");
1236 assert!(
1239 source_entity.relationships.is_empty(),
1240 "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
1241 source_entity.relationships,
1242 );
1243 let outgoing: Vec<_> = engine
1245 .store()
1246 .outgoing(&source_id)
1247 .iter()
1248 .filter(|e| e.rel_type == "MADE_UP_TYPE")
1249 .collect();
1250 assert!(
1251 outgoing.is_empty(),
1252 "MADE_UP_TYPE edge must be dropped from the store"
1253 );
1254 let parsed_invalid: Vec<_> = engine
1256 .load_warnings()
1257 .iter()
1258 .filter_map(|w| match w {
1259 WarningHint::ParsedRelationInvalid {
1260 entity_id,
1261 rel_type,
1262 target,
1263 reason,
1264 origin,
1265 recovery,
1266 } => Some((
1267 entity_id.clone(),
1268 rel_type.clone(),
1269 target.clone(),
1270 reason.clone(),
1271 origin.clone(),
1272 recovery.clone(),
1273 )),
1274 _ => None,
1275 })
1276 .collect();
1277 assert_eq!(
1278 parsed_invalid.len(),
1279 1,
1280 "expected one warning, got {parsed_invalid:?}"
1281 );
1282 assert_eq!(parsed_invalid[0].0, source_id);
1283 assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
1284 assert_eq!(parsed_invalid[0].2, target_id);
1285 assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
1286 assert_eq!(parsed_invalid[0].4, "writable");
1287 let recovery = parsed_invalid[0]
1289 .5
1290 .as_ref()
1291 .expect("writable-origin warning must carry recovery");
1292 assert_eq!(
1293 recovery.kind,
1294 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1295 );
1296 assert_eq!(recovery.source_id, parsed_invalid[0].0);
1297 assert_eq!(recovery.target_id, parsed_invalid[0].2);
1298 assert_eq!(recovery.rel_type, parsed_invalid[0].1);
1299 }
1300
1301 #[test]
1309 fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
1310 let tmp = TempDir::new().unwrap();
1311 let mem_dir = tmp.path().to_path_buf();
1312 let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
1316 let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
1317 std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
1318 std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
1319
1320 let writer = FilesystemMemWriter::new(mem_dir.clone());
1321 let engine = Engine::from_mounts(vec![(
1322 folder_mount("specs", mem_dir),
1323 Box::new(writer) as Box<dyn MemBackend>,
1324 )])
1325 .unwrap();
1326
1327 let alpha_id = crate::entity::EntityId::new("specs", "alpha");
1328 let beta_id = crate::entity::EntityId::new("specs", "beta");
1329
1330 assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
1333 assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
1334
1335 let surviving: Vec<_> = engine
1338 .store()
1339 .all_entities()
1340 .flat_map(|e| {
1341 engine
1342 .store()
1343 .outgoing(&e.id)
1344 .iter()
1345 .filter(|edge| edge.rel_type == "PART_OF")
1346 .map(|edge| (e.id.clone(), edge.target.clone()))
1347 .collect::<Vec<_>>()
1348 })
1349 .collect();
1350 assert_eq!(
1351 surviving.len(),
1352 1,
1353 "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
1354 );
1355
1356 let cycle_drops: Vec<_> = engine
1359 .load_warnings()
1360 .iter()
1361 .filter_map(|w| match w {
1362 WarningHint::ParsedRelationInvalid {
1363 entity_id,
1364 rel_type,
1365 target,
1366 reason,
1367 ..
1368 } if reason == "cycle" => {
1369 Some((entity_id.clone(), rel_type.clone(), target.clone()))
1370 }
1371 _ => None,
1372 })
1373 .collect();
1374 assert_eq!(
1375 cycle_drops.len(),
1376 1,
1377 "exactly one cycle warning must fire, got {cycle_drops:?}",
1378 );
1379 let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
1381 assert_eq!(dropped_rel_type, "PART_OF");
1382 let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
1383 let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
1384 assert!(
1385 is_alpha_to_beta || is_beta_to_alpha,
1386 "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
1387 );
1388 assert_ne!(
1390 (&surviving[0].0, &surviving[0].1),
1391 (dropped_from, dropped_to),
1392 "surviving edge must differ from the dropped one",
1393 );
1394 }
1395
1396 #[test]
1404 fn nested_prefix_warning_exempts_real_cross_mem_target_loaded_later() {
1405 let tmp = TempDir::new().unwrap();
1406 let project_dir = tmp.path().join("project");
1407 let registry_dir = tmp.path().join("registry");
1408 std::fs::create_dir_all(&project_dir).unwrap();
1409 std::fs::create_dir_all(®istry_dir).unwrap();
1410
1411 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nReal: [[registry--registry-service]]. Drifted: [[registry--never-created]].\n";
1414 std::fs::write(project_dir.join("source.md"), source).unwrap();
1415
1416 let service = "---\ntype: spec\n---\n# Registry Service\n\n## Identity\n\nA real entity.\n";
1419 std::fs::write(registry_dir.join("registry-service.md"), service).unwrap();
1420
1421 let engine = Engine::from_mounts(vec![
1422 (
1423 folder_mount("project", project_dir.clone()),
1424 Box::new(FilesystemMemWriter::new(project_dir)) as Box<dyn MemBackend>,
1425 ),
1426 (
1427 folder_mount("registry", registry_dir.clone()),
1428 Box::new(FilesystemMemWriter::new(registry_dir)) as Box<dyn MemBackend>,
1429 ),
1430 ])
1431 .unwrap();
1432
1433 let nested: Vec<_> = engine
1434 .load_warnings()
1435 .iter()
1436 .filter_map(|w| match w {
1437 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1438 Some(resolved_id.to_string())
1439 }
1440 _ => None,
1441 })
1442 .collect();
1443 assert!(
1444 !nested.contains(&"registry--registry-service".to_string()),
1445 "a valid cross-mem id resolving to a real entity must not warn, got {nested:?}",
1446 );
1447 assert!(
1448 nested.contains(&"registry--never-created".to_string()),
1449 "a genuinely unresolved nested-prefix link must keep its warning, got {nested:?}",
1450 );
1451 }
1452
1453 #[test]
1463 fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
1464 use crate::engine::test_helpers::write_schema_files_with_default_type;
1465
1466 let tmp = TempDir::new().unwrap();
1467 let schemas_dir = tmp.path().join("schemas");
1468 std::fs::create_dir_all(&schemas_dir).unwrap();
1469 let manifest = r#"name: shape-test
1473version: 0.1.0
1474description: shape-constraint schema
1475when_to_use: tests
1476types:
1477 - doc
1478 - actor
1479relationships:
1480 mode: strict
1481 definitions:
1482 - name: OWNS
1483 description: actor owns doc
1484 default_weight: 1.0
1485 source_types: [actor]
1486 target_types: [doc]
1487 - name: _default
1488 description: fallback
1489 default_weight: 1.0
1490community:
1491 resolution: 1.0
1492 seed: 42
1493"#;
1494 write_schema_files_with_default_type(
1495 &schemas_dir,
1496 "shape-test",
1497 manifest,
1498 &["doc", "actor"],
1499 );
1500
1501 let mem_dir = tmp.path().join("mem");
1502 std::fs::create_dir_all(&mem_dir).unwrap();
1503 let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
1507 let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
1508 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1509 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1510
1511 let writer = FilesystemMemWriter::new(mem_dir.clone());
1512 let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
1513 let mount = Mount {
1514 mem: "specs".to_string(),
1515 schema: Some(pin),
1516 storage: MountStorage::Folder { path: mem_dir },
1517 capability: MountCapability::Write,
1518 lifecycle: MountLifecycle::Eager,
1519 cross_linkable: true,
1520 migration_target: None,
1521 };
1522 let engine = Engine::from_mounts_with_schemas_dir(
1523 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1524 Some(&schemas_dir),
1525 )
1526 .unwrap();
1527
1528 let source_id = crate::entity::EntityId::new("specs", "source");
1529 let target_id = crate::entity::EntityId::new("specs", "target");
1530
1531 let shape_drops: Vec<_> = engine
1532 .load_warnings()
1533 .iter()
1534 .filter_map(|w| match w {
1535 WarningHint::ParsedRelationInvalid {
1536 entity_id,
1537 rel_type,
1538 target,
1539 reason,
1540 origin,
1541 recovery,
1542 } if reason == "shape" => Some((
1543 entity_id.clone(),
1544 rel_type.clone(),
1545 target.clone(),
1546 origin.clone(),
1547 recovery.clone(),
1548 )),
1549 _ => None,
1550 })
1551 .collect();
1552 assert_eq!(
1553 shape_drops.len(),
1554 1,
1555 "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
1556 engine.load_warnings(),
1557 );
1558 let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
1559 shape_drops.into_iter().next().unwrap();
1560 assert_eq!(drop_from, source_id);
1561 assert_eq!(drop_type, "OWNS");
1562 assert_eq!(drop_to, target_id);
1563 assert_eq!(drop_origin, "writable");
1564 let recovery = drop_recovery.expect("writable origin must carry recovery");
1567 assert_eq!(
1568 recovery.kind,
1569 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1570 );
1571 assert_eq!(recovery.source_id, source_id);
1572 assert_eq!(recovery.target_id, target_id);
1573 assert_eq!(recovery.rel_type, "OWNS");
1574 }
1575
1576 #[test]
1582 fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
1583 let tmp = TempDir::new().unwrap();
1584 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1588 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1589 let archive_path = build_archive(
1590 tmp.path(),
1591 "ext",
1592 &[
1593 ("target.md", target.as_bytes()),
1594 ("source.md", source.as_bytes()),
1595 ],
1596 );
1597
1598 let engine = Engine::from_mounts(vec![(
1599 archive_mount("external", archive_path.clone()),
1600 Box::new(ArchiveBackend::new(archive_path)),
1601 )])
1602 .unwrap();
1603
1604 let invalid: Vec<_> = engine
1605 .load_warnings()
1606 .iter()
1607 .filter_map(|w| match w {
1608 WarningHint::ParsedRelationInvalid {
1609 rel_type,
1610 reason,
1611 origin,
1612 recovery,
1613 ..
1614 } => Some((
1615 rel_type.clone(),
1616 reason.clone(),
1617 origin.clone(),
1618 recovery.clone(),
1619 )),
1620 _ => None,
1621 })
1622 .collect();
1623 assert_eq!(
1624 invalid.len(),
1625 1,
1626 "expected one parse-time drop on the readonly mount, got {invalid:?}",
1627 );
1628 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1629 assert_eq!(invalid[0].1, "unknown_rel_type");
1630 assert_eq!(invalid[0].2, "readonly");
1631 assert!(
1632 invalid[0].3.is_none(),
1633 "readonly-origin warning must omit the recovery hint, got {:?}",
1634 invalid[0].3,
1635 );
1636 }
1637
1638 #[test]
1639 fn load_on_init_populates_store_from_folder_mount() {
1640 let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1643
1644 let tmp = TempDir::new().unwrap();
1645 let mem_dir = tmp.path().to_path_buf();
1646 let writer = FilesystemMemWriter::new(mem_dir.clone());
1647 <FilesystemMemWriter as MemWriter>::write_entity(
1648 &writer,
1649 Path::new("hello.md"),
1650 body.as_bytes(),
1651 )
1652 .unwrap();
1653 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1654 .unwrap();
1655
1656 let engine = Engine::from_mounts(vec![(
1657 folder_mount("specs", mem_dir),
1658 Box::new(writer) as Box<dyn MemBackend>,
1659 )])
1660 .unwrap();
1661
1662 assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1664 let id = crate::EntityId::new("specs", "hello");
1665 let entity = engine.get_entity(&id).expect("entity must be present");
1666 assert_eq!(entity.title, "Hello");
1667 assert_eq!(entity.entity_type, "spec");
1668 assert!(engine.load_errors().is_empty());
1669 assert_eq!(engine.schemas().len(), 1);
1671 assert!(engine.schemas().contains_key("specs"));
1672 }
1673
1674 #[test]
1675 fn load_on_init_populates_store_from_archive_mount() {
1676 let body =
1677 "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1678
1679 let tmp = TempDir::new().unwrap();
1680 let archive_path =
1681 build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1682
1683 let engine = Engine::from_mounts(vec![(
1684 archive_mount("external", archive_path.clone()),
1685 Box::new(ArchiveBackend::new(archive_path)),
1686 )])
1687 .unwrap();
1688
1689 let id = crate::EntityId::new("external", "from-archive");
1690 let entity = engine.get_entity(&id).expect("entity must be present");
1691 assert_eq!(entity.title, "From Archive");
1692 assert!(engine.load_errors().is_empty());
1693 }
1694
1695 #[test]
1696 fn load_on_init_populates_store_from_heterogeneous_mounts() {
1697 let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1698 let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1699
1700 let tmp = TempDir::new().unwrap();
1701
1702 let folder_dir = tmp.path().join("folder-mem");
1703 std::fs::create_dir_all(&folder_dir).unwrap();
1704 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1705 <FilesystemMemWriter as MemWriter>::write_entity(
1706 &folder_writer,
1707 Path::new("local.md"),
1708 folder_body.as_bytes(),
1709 )
1710 .unwrap();
1711 <FilesystemMemWriter as MemWriter>::commit(
1712 &folder_writer,
1713 "seed",
1714 &CommitContext::internal(),
1715 )
1716 .unwrap();
1717
1718 let archive_path = build_archive(
1719 tmp.path(),
1720 "external",
1721 &[("external.md", archive_body.as_bytes())],
1722 );
1723
1724 let engine = Engine::from_mounts(vec![
1725 (
1726 folder_mount("local", folder_dir),
1727 Box::new(folder_writer) as Box<dyn MemBackend>,
1728 ),
1729 (
1730 archive_mount("external", archive_path.clone()),
1731 Box::new(ArchiveBackend::new(archive_path)),
1732 ),
1733 ])
1734 .unwrap();
1735
1736 assert_eq!(engine.store().len(), 2);
1738 assert!(
1739 engine
1740 .get_entity(&crate::EntityId::new("local", "local"))
1741 .is_some()
1742 );
1743 assert!(
1744 engine
1745 .get_entity(&crate::EntityId::new("external", "external"))
1746 .is_some()
1747 );
1748 }
1749
1750 #[test]
1751 fn load_on_init_collects_per_file_parse_errors_without_failing() {
1752 let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1756 let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1757
1758 let tmp = TempDir::new().unwrap();
1759 let mem_dir = tmp.path().to_path_buf();
1760 let writer = FilesystemMemWriter::new(mem_dir.clone());
1761 <FilesystemMemWriter as MemWriter>::write_entity(
1762 &writer,
1763 Path::new("good.md"),
1764 good.as_bytes(),
1765 )
1766 .unwrap();
1767 <FilesystemMemWriter as MemWriter>::write_entity(
1768 &writer,
1769 Path::new("bad.md"),
1770 bad.as_bytes(),
1771 )
1772 .unwrap();
1773 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1774 .unwrap();
1775
1776 let engine = Engine::from_mounts(vec![(
1777 folder_mount("specs", mem_dir),
1778 Box::new(writer) as Box<dyn MemBackend>,
1779 )])
1780 .unwrap();
1781
1782 assert!(
1784 engine
1785 .get_entity(&crate::EntityId::new("specs", "good"))
1786 .is_some(),
1787 "good.md must parse and reach the store"
1788 );
1789 let bad_known_to_engine = engine
1794 .get_entity(&crate::EntityId::new("specs", "bad"))
1795 .is_some()
1796 || !engine.load_errors().is_empty();
1797 assert!(
1798 bad_known_to_engine,
1799 "bad.md must either parse or surface in load_errors"
1800 );
1801 }
1802
1803 #[test]
1804 fn empty_mount_list_yields_empty_store() {
1805 let engine = Engine::from_mounts(Vec::new()).unwrap();
1806 assert!(engine.store().is_empty());
1807 assert!(engine.schemas().is_empty());
1808 assert!(engine.load_errors().is_empty());
1809 }
1810
1811 #[test]
1814 fn from_workspace_root_errors_for_empty_layout() {
1815 let tmp = TempDir::new().unwrap();
1816 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1817 match err {
1818 crate::BootError::NotInitialised(p) => {
1819 assert_eq!(p, tmp.path());
1820 }
1821 other => panic!("expected NotInitialised, got {other:?}"),
1822 }
1823 }
1824
1825 #[test]
1826 fn from_workspace_root_loads_new_two_layer_layout() {
1827 let tmp = TempDir::new().unwrap();
1828 let mem_dir = tmp.path().join("mem");
1829 std::fs::create_dir_all(&mem_dir).unwrap();
1830 std::fs::write(
1831 mem_dir.join("hello.md"),
1832 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1833 )
1834 .unwrap();
1835
1836 let memstead = tmp.path().join(".memstead");
1837 std::fs::create_dir_all(&memstead).unwrap();
1838 std::fs::write(
1839 memstead.join("workspace.toml"),
1840 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1841 )
1842 .unwrap();
1843 use crate::workspace_store::WorkspaceStoreAdapter;
1846 let store = crate::FileWorkspaceStore::new();
1847 store
1848 .save_state(
1849 tmp.path(),
1850 &crate::workspace::Workspace {
1851 mounts: vec![folder_mount("specs", mem_dir)],
1852 settings: crate::workspace::WorkspaceSettings::default(),
1853 },
1854 )
1855 .unwrap();
1856
1857 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1858 assert_eq!(engine.mem_names(), vec!["specs"]);
1859 let entity = engine
1860 .get_entity(&crate::EntityId::new("specs", "hello"))
1861 .expect("seeded entity must load through from_workspace_root");
1862 assert_eq!(entity.title, "Hello");
1863 }
1864
1865 #[test]
1870 fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1871 use crate::pipeline::{MediumType, Projection};
1872 let tmp = TempDir::new().unwrap();
1873 let mem_dir = tmp.path().join("mem");
1874 std::fs::create_dir_all(&mem_dir).unwrap();
1875
1876 let memstead = tmp.path().join(".memstead");
1877 std::fs::create_dir_all(&memstead).unwrap();
1878 std::fs::write(
1879 memstead.join("workspace.toml"),
1880 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1881 )
1882 .unwrap();
1883 use crate::workspace_store::WorkspaceStoreAdapter;
1884 crate::FileWorkspaceStore::new()
1885 .save_state(
1886 tmp.path(),
1887 &crate::workspace::Workspace {
1888 mounts: vec![folder_mount("specs", mem_dir)],
1889 settings: crate::workspace::WorkspaceSettings::default(),
1890 },
1891 )
1892 .unwrap();
1893
1894 crate::pipeline_store::write_binding(
1896 tmp.path(),
1897 "specs",
1898 "graph",
1899 &sample_v2_binding("specs"),
1900 )
1901 .unwrap();
1902
1903 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1904 let pc = engine.pipeline_configs();
1905 assert_eq!(pc.bindings.len(), 1, "one binding enumerated");
1906 assert_eq!(pc.bindings[0].mem, "specs");
1907 assert_eq!(pc.bindings[0].name, "graph");
1908 assert_eq!(pc.bindings[0].config.destination_mem, "specs");
1909 assert_eq!(
1910 pc.bindings[0].config.sources[0].medium_type,
1911 MediumType::Codebase
1912 );
1913
1914 crate::pipeline_store::write_projection(
1921 tmp.path(),
1922 "specs",
1923 "legacy",
1924 &Projection {
1925 intent: None,
1926 source_facets: vec!["view".to_string()],
1927 reference_mems: Vec::new(),
1928 destination_mem: "specs".to_string(),
1929 rules: None,
1930 },
1931 )
1932 .unwrap();
1933 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1934 let pc = engine.pipeline_configs();
1935 assert_eq!(pc.bindings.len(), 1, "the healthy binding still serves");
1936 assert_eq!(pc.quarantined.len(), 1, "the legacy one quarantines");
1937 assert_eq!(pc.quarantined[0].name, "legacy");
1938 assert_eq!(pc.quarantined[0].reason_code, "PROJECTION_STORE_LEGACY");
1939 assert!(
1940 pc.quarantined[0]
1941 .reason_message
1942 .contains("memstead projection migrate"),
1943 "quarantine reason names the migrate command, got: {}",
1944 pc.quarantined[0].reason_message
1945 );
1946 }
1947
1948 fn sample_v2_binding(dest: &str) -> crate::binding::Binding {
1951 v2_binding_with_pointer(dest, "..")
1952 }
1953
1954 fn v2_binding_with_pointer(dest: &str, pointer: &str) -> crate::binding::Binding {
1955 use crate::binding::{BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations};
1956 use crate::pipeline::{IngestTrigger, MediumType, Source};
1957 Binding {
1958 version: BINDING_VERSION,
1959 intent: None,
1960 sources: vec![Source {
1961 name: "src".to_string(),
1962 medium_type: MediumType::Codebase,
1963 pointer: pointer.to_string(),
1964 change_detection: None,
1965 scope: Vec::new(),
1966 engagement: None,
1967 preparation: None,
1968 }],
1969 reference_mems: Vec::new(),
1970 destination_mem: dest.to_string(),
1971 deny_paths: Vec::new(),
1972 coverage_semantics: None,
1973 rules: None,
1974 prune: None,
1975 operations: Operations {
1976 build: Some(BuildOperation {
1977 mode: BuildMode::Discovery,
1978 trigger: IngestTrigger::Loop,
1979 batch_size: 10,
1980 post_actions: None,
1981 }),
1982 sync: None,
1983 verify: None,
1984 },
1985 }
1986 }
1987
1988 #[test]
1996 fn entity_anchors_resolve_live_state_for_path_medium() {
1997 use crate::anchor::{AnchorInput, AnchorState};
1998 use crate::vcs::Actor;
1999 use crate::workspace_store::WorkspaceStoreAdapter;
2000 use indexmap::IndexMap;
2001
2002 let tmp = TempDir::new().unwrap();
2003 let mem_dir = tmp.path().join("mem");
2004 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2005 std::fs::write(
2006 mem_dir.join(".memstead").join("config.json"),
2007 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2008 )
2009 .unwrap();
2010
2011 let memstead = tmp.path().join(".memstead");
2012 std::fs::create_dir_all(&memstead).unwrap();
2013 std::fs::write(
2014 memstead.join("workspace.toml"),
2015 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2016 )
2017 .unwrap();
2018 crate::FileWorkspaceStore::new()
2019 .save_state(
2020 tmp.path(),
2021 &crate::workspace::Workspace {
2022 mounts: vec![folder_mount("specs", mem_dir.clone())],
2023 settings: crate::workspace::WorkspaceSettings::default(),
2024 },
2025 )
2026 .unwrap();
2027
2028 crate::pipeline_store::write_binding(
2034 tmp.path(),
2035 "specs",
2036 "graph",
2037 &v2_binding_with_pointer("specs", "src"),
2038 )
2039 .unwrap();
2040 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2041 std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
2042 std::fs::write(tmp.path().join("src").join("gone.rs"), "fn gone() {}").unwrap();
2045
2046 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2047
2048 let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
2049 artifact: Some(artifact.to_string()),
2050 grain: Some("file".to_string()),
2051 class: Some(class.to_string()),
2052 hash: hash.map(str::to_string),
2053 hash_stability: Some("stable".to_string()),
2054 ..Default::default()
2055 };
2056 let mut sections = IndexMap::new();
2057 sections.insert("identity".to_string(), "Covers src.".to_string());
2058 sections.insert("purpose".to_string(), "Track sources.".to_string());
2059 let present_hash = crate::anchor::prepared_content_hash(
2062 &std::fs::read(tmp.path().join("src").join("present.rs")).unwrap(),
2063 );
2064 let created = engine
2065 .create_entity(
2066 crate::CreateEntityArgs {
2067 mem: "specs".to_string(),
2068 title: "Covers".to_string(),
2069 entity_type: "spec".to_string(),
2070 sections,
2071 metadata: IndexMap::new(),
2072 relations: Vec::new(),
2073 anchors: vec![
2074 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")), ],
2079 dry_run: false,
2080 },
2081 Actor::Agent,
2082 None,
2083 None,
2084 )
2085 .unwrap();
2086
2087 std::fs::remove_file(tmp.path().join("src").join("gone.rs")).unwrap();
2088 let resolved = engine.entity_anchors_resolved(&created.id);
2089 assert_eq!(resolved.len(), 4);
2090 let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
2091 resolved
2092 .iter()
2093 .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
2094 .and_then(|r| r.state)
2095 };
2096 assert_eq!(
2097 state_of(
2098 "src/present.rs",
2099 crate::anchor::AnchorProvenanceClass::Anchored
2100 ),
2101 Some(AnchorState::Resolves),
2102 "recorded hash matches the observed prepared form → resolves"
2103 );
2104 assert_eq!(
2105 state_of(
2106 "src/present.rs",
2107 crate::anchor::AnchorProvenanceClass::Derived
2108 ),
2109 Some(AnchorState::Drifted),
2110 "recorded hash mismatches the observed prepared form on a stable medium → drifted"
2111 );
2112 assert_eq!(
2113 state_of(
2114 "src/present.rs",
2115 crate::anchor::AnchorProvenanceClass::InformedBy
2116 ),
2117 Some(AnchorState::Resolves),
2118 "present non-hash anchor resolves on existence"
2119 );
2120 assert_eq!(
2121 state_of(
2122 "src/gone.rs",
2123 crate::anchor::AnchorProvenanceClass::Anchored
2124 ),
2125 Some(AnchorState::Orphaned),
2126 "absent artifact is orphaned"
2127 );
2128 }
2129
2130 #[test]
2135 fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
2136 use crate::workspace_store::WorkspaceStoreAdapter;
2137
2138 let tmp = TempDir::new().unwrap();
2139 let mem_dir = tmp.path().join("mem");
2140 std::fs::create_dir_all(&mem_dir).unwrap();
2141 let memstead = tmp.path().join(".memstead");
2142 std::fs::create_dir_all(&memstead).unwrap();
2143 std::fs::write(
2144 memstead.join("workspace.toml"),
2145 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2146 )
2147 .unwrap();
2148 crate::FileWorkspaceStore::new()
2149 .save_state(
2150 tmp.path(),
2151 &crate::workspace::Workspace {
2152 mounts: vec![folder_mount("specs", mem_dir)],
2153 settings: crate::workspace::WorkspaceSettings::default(),
2154 },
2155 )
2156 .unwrap();
2157
2158 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2159 assert!(engine.pipeline_configs().bindings.is_empty());
2160
2161 engine
2163 .add_projection_json(
2164 "specs",
2165 "graph",
2166 r#"{
2167 "sources": [{ "name": "src", "type": "codebase", "pointer": "..",
2168 "scope": [{ "path": "**/*.rs", "mode": "allow" }] }],
2169 "destination_mem": "specs"
2170 }"#,
2171 None,
2172 )
2173 .unwrap();
2174 assert_eq!(engine.pipeline_configs().bindings.len(), 1);
2176 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph");
2177 assert_eq!(
2178 engine.pipeline_configs().bindings[0].config.sources[0].name,
2179 "src"
2180 );
2181
2182 let err = engine
2184 .add_projection_json("specs", "bad", "{ not json", None)
2185 .unwrap_err();
2186 assert!(
2187 matches!(
2188 err,
2189 crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
2190 ),
2191 "got {err:?}"
2192 );
2193
2194 engine
2196 .update_projection_json("specs", "graph", r#"{"intent":"i2"}"#, None)
2197 .unwrap();
2198 assert_eq!(
2199 engine.pipeline_configs().bindings[0]
2200 .config
2201 .intent
2202 .as_deref(),
2203 Some("i2")
2204 );
2205
2206 engine
2208 .rename_projection("specs", "graph", "graph2", None)
2209 .unwrap();
2210 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph2");
2211
2212 let json = engine.pipeline_configs_json();
2215 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2216 assert!(parsed.get("mediums").is_none(), "no mediums key: {json}");
2217 assert!(parsed.get("facets").is_none(), "no facets key: {json}");
2218 let bindings = parsed["bindings"].as_array().unwrap();
2219 assert_eq!(bindings.len(), 1);
2220 assert_eq!(bindings[0]["name"], "graph2");
2221 assert_eq!(bindings[0]["config"]["sources"][0]["type"], "codebase");
2222
2223 engine.delete_projection("specs", "graph2", None).unwrap();
2224 assert!(engine.pipeline_configs().bindings.is_empty());
2225 }
2226
2227 #[test]
2233 fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
2234 use crate::engine::test_helpers::write_schema_files_with_default_type;
2235
2236 let tmp = TempDir::new().unwrap();
2237 let mem_dir = tmp.path().join("mem");
2238 std::fs::create_dir_all(&mem_dir).unwrap();
2239
2240 let authored_dir = tmp.path().join(".memstead").join("schemas");
2242 let manifest = r#"name: authored
2243version: 0.1.0
2244description: an authored-in-workspace test schema
2245when_to_use: tests
2246types:
2247 - doc
2248relationships:
2249 mode: strict
2250 definitions:
2251 - name: _default
2252 description: fallback
2253 default_weight: 1.0
2254community:
2255 resolution: 1.0
2256 seed: 42
2257"#;
2258 write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
2259
2260 let memstead = tmp.path().join(".memstead");
2262 std::fs::create_dir_all(&memstead).unwrap();
2263 std::fs::write(
2264 memstead.join("workspace.toml"),
2265 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2266 )
2267 .unwrap();
2268 let mount = Mount {
2269 mem: "specs".to_string(),
2270 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2271 storage: MountStorage::Folder { path: mem_dir },
2272 capability: MountCapability::Write,
2273 lifecycle: MountLifecycle::Eager,
2274 cross_linkable: true,
2275 migration_target: None,
2276 };
2277 use crate::workspace_store::WorkspaceStoreAdapter;
2278 crate::FileWorkspaceStore::new()
2279 .save_state(
2280 tmp.path(),
2281 &crate::workspace::Workspace {
2282 mounts: vec![mount],
2283 settings: crate::workspace::WorkspaceSettings::default(),
2284 },
2285 )
2286 .unwrap();
2287
2288 let engine = Engine::from_workspace_root(tmp.path())
2291 .expect("authored schema at .memstead/schemas/ must resolve at boot");
2292 assert_eq!(engine.mem_names(), vec!["specs"]);
2293 }
2294
2295 #[test]
2301 fn health_reports_authoring_drift_for_stamped_schemas_only() {
2302 use crate::engine::test_helpers::write_schema_files_with_default_type;
2303
2304 let tmp = TempDir::new().unwrap();
2305 let mem_dir = tmp.path().join("mem");
2306 std::fs::create_dir_all(&mem_dir).unwrap();
2307 let manifest = r#"name: authored
2308version: 0.1.0
2309description: an authored-in-workspace test schema
2310when_to_use: tests
2311types:
2312 - doc
2313relationships:
2314 mode: strict
2315 definitions:
2316 - name: _default
2317 description: fallback
2318 default_weight: 1.0
2319community:
2320 resolution: 1.0
2321 seed: 42
2322"#;
2323 let sealed_root = tmp.path().join(".memstead").join("schemas");
2326 write_schema_files_with_default_type(&sealed_root, "authored@0.1.0", manifest, &["doc"]);
2327 let author_root = tmp.path().join("author");
2328 write_schema_files_with_default_type(&author_root, "authored@0.1.0", manifest, &["doc"]);
2329 let authoring_dir = author_root.join("authored@0.1.0");
2330 let sealed_dir = sealed_root.join("authored@0.1.0");
2331 let stamp_path = sealed_dir.join(memstead_schema::INSTALL_PROVENANCE_FILE);
2333 std::fs::write(
2334 &stamp_path,
2335 serde_json::to_vec_pretty(&serde_json::json!({
2336 "authoring_path": authoring_dir.display().to_string(),
2337 }))
2338 .unwrap(),
2339 )
2340 .unwrap();
2341
2342 let memstead = tmp.path().join(".memstead");
2343 std::fs::write(
2344 memstead.join("workspace.toml"),
2345 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2346 )
2347 .unwrap();
2348 let mount = Mount {
2349 mem: "specs".to_string(),
2350 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2351 storage: MountStorage::Folder { path: mem_dir },
2352 capability: MountCapability::Write,
2353 lifecycle: MountLifecycle::Eager,
2354 cross_linkable: true,
2355 migration_target: None,
2356 };
2357 use crate::workspace_store::WorkspaceStoreAdapter;
2358 crate::FileWorkspaceStore::new()
2359 .save_state(
2360 tmp.path(),
2361 &crate::workspace::Workspace {
2362 mounts: vec![mount],
2363 settings: crate::workspace::WorkspaceSettings::default(),
2364 },
2365 )
2366 .unwrap();
2367 let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
2368 let drift_codes = |e: &Engine| -> Vec<String> {
2369 e.health()
2370 .warnings
2371 .iter()
2372 .filter(|w| w.code().starts_with("SCHEMA_AUTHORING_SOURCE_"))
2373 .map(|w| w.code().to_string())
2374 .collect()
2375 };
2376
2377 let sealed_before = std::fs::read(sealed_dir.join("schema.yaml")).unwrap();
2380 let author_before = std::fs::read(authoring_dir.join("schema.yaml")).unwrap();
2381 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2382 assert_eq!(
2383 std::fs::read(sealed_dir.join("schema.yaml")).unwrap(),
2384 sealed_before,
2385 "health must not touch the sealed copy"
2386 );
2387 assert_eq!(
2388 std::fs::read(authoring_dir.join("schema.yaml")).unwrap(),
2389 author_before,
2390 "health must not touch the authoring copy"
2391 );
2392
2393 std::fs::write(
2397 authoring_dir.join("schema.yaml"),
2398 format!(
2399 "# yaml-language-server: $schema=../../.memstead/meta-schemas/schema-manifest.json\n# cosmetic comment\n{manifest}"
2400 ),
2401 )
2402 .unwrap();
2403 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2404
2405 std::fs::write(
2408 authoring_dir.join("schema.yaml"),
2409 manifest.replace(
2410 "an authored-in-workspace test schema",
2411 "a semantically different description",
2412 ),
2413 )
2414 .unwrap();
2415 let warnings = engine.health().warnings;
2416 let diverged = warnings
2417 .iter()
2418 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED")
2419 .expect("semantic change must surface as DIVERGED");
2420 let d = serde_json::to_value(diverged).unwrap();
2421 assert_eq!(d["details"]["schema_ref"], "authored@0.1.0");
2422 assert_eq!(d["details"]["mems"], serde_json::json!(["specs"]));
2423
2424 std::fs::remove_dir_all(&authoring_dir).unwrap();
2426 let warnings = engine.health().warnings;
2427 let missing = warnings
2428 .iter()
2429 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_MISSING")
2430 .expect("vanished authoring package must surface as MISSING");
2431 let m = serde_json::to_value(missing).unwrap();
2432 assert_eq!(m["details"]["schema_ref"], "authored@0.1.0");
2433 assert_eq!(m["details"]["mems"], serde_json::json!(["specs"]));
2434 assert_eq!(
2435 m["details"]["stamped_path"],
2436 authoring_dir.display().to_string()
2437 );
2438 assert!(
2439 !warnings
2440 .iter()
2441 .any(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED"),
2442 "missing and diverged are distinct findings"
2443 );
2444
2445 std::fs::remove_file(&stamp_path).unwrap();
2447 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2448 }
2449
2450 #[test]
2455 fn full_refresh_is_additive_and_reports_skipped_removals() {
2456 use crate::engine::test_helpers::write_schema_files_with_default_type;
2457 use crate::workspace_store::WorkspaceStoreAdapter;
2458
2459 let tmp = TempDir::new().unwrap();
2460 let root = tmp.path();
2461 let mem_a = root.join("mem-a");
2462 std::fs::create_dir_all(&mem_a).unwrap();
2463 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2464 std::fs::write(
2465 root.join(".memstead").join("workspace.toml"),
2466 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2467 )
2468 .unwrap();
2469 let mount = |mem: &str, dir: &Path, schema: &str, version: semver::Version| Mount {
2470 mem: mem.to_string(),
2471 schema: Some(SchemaRef::new(schema, version)),
2472 storage: MountStorage::Folder {
2473 path: dir.to_path_buf(),
2474 },
2475 capability: MountCapability::Write,
2476 lifecycle: MountLifecycle::Eager,
2477 cross_linkable: true,
2478 migration_target: None,
2479 };
2480 let save = |mounts: Vec<Mount>| {
2481 crate::FileWorkspaceStore::new()
2482 .save_state(
2483 root,
2484 &crate::workspace::Workspace {
2485 mounts,
2486 settings: crate::workspace::WorkspaceSettings::default(),
2487 },
2488 )
2489 .unwrap();
2490 };
2491 save(vec![mount(
2492 "specs",
2493 &mem_a,
2494 "default",
2495 semver::Version::new(1, 0, 0),
2496 )]);
2497 let mut engine = Engine::from_workspace_root(root).expect("workspace boots");
2498 assert_eq!(engine.mem_names(), vec!["specs"]);
2499
2500 let manifest = r#"name: authored
2503version: 0.1.0
2504description: an out-of-band installed schema
2505when_to_use: tests
2506types:
2507 - doc
2508relationships:
2509 mode: strict
2510 definitions:
2511 - name: _default
2512 description: fallback
2513 default_weight: 1.0
2514community:
2515 resolution: 1.0
2516 seed: 42
2517"#;
2518 write_schema_files_with_default_type(
2519 &root.join(".memstead").join("schemas"),
2520 "authored@0.1.0",
2521 manifest,
2522 &["doc"],
2523 );
2524 let mem_b = root.join("mem-b");
2525 std::fs::create_dir_all(&mem_b).unwrap();
2526 save(vec![
2527 mount("specs", &mem_a, "default", semver::Version::new(1, 0, 0)),
2528 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2529 ]);
2530
2531 let (actor, client) = cli_actor();
2534 let mut pre = crate::engine::test_helpers::empty_create_args("notes", "Too Early");
2535 pre.entity_type = "doc".to_string();
2536 pre.sections = indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2537 let err = engine
2538 .create_entity(pre.clone(), actor, Some(&client), None)
2539 .unwrap_err();
2540 assert_eq!(err.code(), "UNKNOWN_MEM", "{err:?}");
2541 assert!(
2542 !engine
2543 .workspace_schemas()
2544 .iter()
2545 .any(|s| s.id().0 == "authored"),
2546 "schema catalogue is fixed pre-refresh"
2547 );
2548
2549 let report = engine.full_refresh();
2551 assert_eq!(report.schemas_added, vec!["authored@0.1.0".to_string()]);
2552 assert_eq!(report.mems_mounted, vec!["notes".to_string()]);
2553 assert!(report.schema_removals_skipped.is_empty(), "{report:?}");
2554 assert!(report.mem_removals_skipped.is_empty(), "{report:?}");
2555 assert!(report.failures.is_empty(), "{report:?}");
2556 engine
2557 .create_entity(pre, actor, Some(&client), None)
2558 .expect("newly mounted mem accepts writes after the refresh");
2559
2560 std::fs::remove_dir_all(
2563 root.join(".memstead")
2564 .join("schemas")
2565 .join("authored@0.1.0"),
2566 )
2567 .unwrap();
2568 save(vec![mount(
2569 "notes",
2570 &mem_b,
2571 "authored",
2572 semver::Version::new(0, 1, 0),
2573 )]);
2574 let report = engine.full_refresh();
2575 assert_eq!(report.mem_removals_skipped, vec!["specs".to_string()]);
2576 assert_eq!(
2577 report.schema_removals_skipped,
2578 vec!["authored@0.1.0".to_string()]
2579 );
2580 assert!(report.schemas_added.is_empty());
2581 assert!(report.mems_mounted.is_empty());
2582 engine
2585 .create_entity(
2586 crate::engine::test_helpers::empty_create_args("specs", "Still Here"),
2587 actor,
2588 Some(&client),
2589 None,
2590 )
2591 .expect("skipped-removal mem stays writable");
2592 let mut into_notes =
2593 crate::engine::test_helpers::empty_create_args("notes", "Still Resolvable");
2594 into_notes.entity_type = "doc".to_string();
2595 into_notes.sections =
2596 indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2597 engine
2598 .create_entity(into_notes, actor, Some(&client), None)
2599 .expect("removed-from-source schema stays resolvable");
2600
2601 let broken = root.join("broken-mem");
2604 std::fs::write(&broken, b"not a directory").unwrap();
2605 save(vec![
2606 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2607 mount("broken", &broken, "default", semver::Version::new(1, 0, 0)),
2608 ]);
2609 let report = engine.full_refresh();
2610 assert!(
2611 report.failures.iter().any(|f| f.item == "mount:broken"),
2612 "failed mount must be reported per-item: {report:?}"
2613 );
2614 assert!(
2615 !report.mems_mounted.contains(&"broken".to_string()),
2616 "a failed mount never surfaces as newly available"
2617 );
2618 assert!(
2619 engine
2620 .get_entity(&crate::EntityId::new("broken", "anything"))
2621 .is_none()
2622 && engine.mem_names().contains(&"notes"),
2623 "other mounts unaffected"
2624 );
2625 }
2626
2627 #[test]
2628 fn from_workspace_root_propagates_mem_management_settings() {
2629 let tmp = TempDir::new().unwrap();
2634 let mem_dir = tmp.path().join("mem");
2635 std::fs::create_dir_all(&mem_dir).unwrap();
2636
2637 let memstead = tmp.path().join(".memstead");
2638 std::fs::create_dir_all(&memstead).unwrap();
2639 std::fs::write(
2640 memstead.join("workspace.toml"),
2641 r#"format = "memstead-git-branch-2"
2642
2643[persistence_adapter]
2644name = "file-two-layer"
2645
2646[[mem_management.create]]
2647pattern = "exec-*"
2648schemas = ["default@1.0.0"]
2649
2650[[mem_management.delete]]
2651pattern = "exec-*"
2652"#,
2653 )
2654 .unwrap();
2655 use crate::workspace_store::WorkspaceStoreAdapter;
2656 let store = crate::FileWorkspaceStore::new();
2657 store
2658 .save_state(
2659 tmp.path(),
2660 &crate::workspace::Workspace {
2661 mounts: vec![folder_mount("specs", mem_dir)],
2662 settings: crate::workspace::WorkspaceSettings::default(),
2663 },
2664 )
2665 .unwrap();
2666
2667 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2668 let s = engine.settings();
2669 assert_eq!(s.mem_create_rules.len(), 1);
2670 assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
2671 assert_eq!(
2672 s.mem_create_rules[0].schemas,
2673 vec!["default@1.0.0".to_string()]
2674 );
2675 assert_eq!(s.mem_delete_rules.len(), 1);
2676 assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
2677 }
2678
2679 #[test]
2687 fn from_mounts_quarantines_unknown_schema_pin() {
2688 let tmp = TempDir::new().unwrap();
2689 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
2690 let mount = Mount {
2691 mem: "specs".to_string(),
2692 schema: Some(SchemaRef::new(
2693 "totally-not-a-schema",
2694 semver::Version::new(1, 0, 0),
2695 )),
2696 storage: MountStorage::Folder {
2697 path: tmp.path().to_path_buf(),
2698 },
2699 capability: MountCapability::Write,
2700 lifecycle: MountLifecycle::Eager,
2701 cross_linkable: true,
2702 migration_target: None,
2703 };
2704 let engine = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2705 .expect("a broken mem quarantines, never fails the workspace");
2706
2707 let roster = engine.quarantined_mems();
2708 assert_eq!(roster.len(), 1);
2709 assert_eq!(roster[0].mount.mem, "specs");
2710 assert_eq!(roster[0].reason_code, "SCHEMA_NOT_FOUND");
2711 assert!(
2712 roster[0].reason_message.contains("totally-not-a-schema"),
2713 "reason carries the failing pin: {}",
2714 roster[0].reason_message
2715 );
2716 assert!(engine.mounts().iter().all(|m| m.mem != "specs"));
2718 let err = engine.unknown_mem_error("specs");
2721 assert_eq!(err.code(), "MEM_QUARANTINED");
2722 assert!(
2723 err.to_string().contains("SCHEMA_NOT_FOUND"),
2724 "quarantine refusal carries the underlying reason: {err}"
2725 );
2726 let health = engine.health();
2728 assert_eq!(health.quarantined.len(), 1);
2729 assert_eq!(health.quarantined[0].reason_code, "SCHEMA_NOT_FOUND");
2730 }
2731
2732 #[test]
2738 fn reload_returns_repaired_mem_from_quarantine() {
2739 let tmp = TempDir::new().unwrap();
2740 let dir = tmp.path().to_path_buf();
2741 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2742 let config_path = dir.join(".memstead").join("config.json");
2743 std::fs::write(&config_path, r#"{ "schema": "ghost@1.0.0" }"#).unwrap();
2744 let writer = FilesystemMemWriter::new(dir.clone());
2745 let mount = Mount {
2746 mem: "specs".to_string(),
2747 schema: None,
2748 storage: MountStorage::Folder { path: dir },
2749 capability: MountCapability::Write,
2750 lifecycle: MountLifecycle::Eager,
2751 cross_linkable: true,
2752 migration_target: None,
2753 };
2754 let mut engine =
2755 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2756 assert_eq!(engine.quarantined_mems().len(), 1);
2757 let err = engine.reload_one_mem("specs").unwrap_err();
2760 assert_eq!(err.code(), "MEM_QUARANTINED");
2761 assert_eq!(engine.quarantined_mems().len(), 1);
2762
2763 std::fs::write(&config_path, r#"{ "schema": "default@1.0.0" }"#).unwrap();
2766 engine
2767 .reload_one_mem("specs")
2768 .expect("repaired mem re-attaches on reload");
2769 assert!(
2770 engine.quarantined_mems().is_empty(),
2771 "roster entry disappears after re-attach"
2772 );
2773 let mut sections = indexmap::IndexMap::new();
2775 sections.insert("identity".to_string(), "back".to_string());
2776 sections.insert("purpose".to_string(), "post-repair service".to_string());
2777 engine
2778 .create_entity_with_ctx(
2779 crate::engine::CreateEntityArgs {
2780 anchors: Vec::new(),
2781 mem: "specs".to_string(),
2782 title: "Back".to_string(),
2783 entity_type: "spec".to_string(),
2784 sections,
2785 metadata: indexmap::IndexMap::new(),
2786 relations: Vec::new(),
2787 dry_run: false,
2788 },
2789 &crate::vcs::CommitContext::internal(),
2790 )
2791 .expect("reattached mem serves writes");
2792 }
2793
2794 #[test]
2799 fn cross_mem_link_into_quarantined_mem_degrades_without_cascade() {
2800 let tmp = TempDir::new().unwrap();
2801 let healthy_dir = tmp.path().join("healthy");
2802 std::fs::create_dir_all(&healthy_dir).unwrap();
2803 std::fs::write(
2804 healthy_dir.join("linker.md"),
2805 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n\
2806 # Linker\n\n## Identity\n\nsee [[badpin:target]] for detail.\n",
2807 )
2808 .unwrap();
2809 let badpin_dir = tmp.path().join("badpin");
2810 std::fs::create_dir_all(&badpin_dir).unwrap();
2811 let mount = |mem: &str, dir: std::path::PathBuf, pin: &str| {
2812 (
2813 Mount {
2814 mem: mem.to_string(),
2815 schema: Some(SchemaRef::new(pin, semver::Version::new(1, 0, 0))),
2816 storage: MountStorage::Folder { path: dir.clone() },
2817 capability: MountCapability::Write,
2818 lifecycle: MountLifecycle::Eager,
2819 cross_linkable: true,
2820 migration_target: None,
2821 },
2822 Box::new(FilesystemMemWriter::new(dir)) as Box<dyn MemBackend>,
2823 )
2824 };
2825 let engine = Engine::from_mounts(vec![
2826 mount("healthy", healthy_dir, "default"),
2827 mount("badpin", badpin_dir, "ghost"),
2828 ])
2829 .expect("boot survives the cross-mem link into the quarantined mem");
2830 assert_eq!(engine.quarantined_mems().len(), 1);
2831 let linker = engine
2835 .get_entity(&crate::EntityId::new("healthy", "linker"))
2836 .expect("linking entity loads");
2837 assert_eq!(linker.entity_type, "spec");
2838 assert!(
2839 engine
2840 .get_entity(&crate::EntityId::new("badpin", "target"))
2841 .is_none_or(|e| e.stub),
2842 "the quarantined-side target is at most a stub, never real data"
2843 );
2844 }
2845
2846 #[test]
2852 fn old_key_authored_schema_quarantines_pinning_mem_never_workspace() {
2853 let tmp = TempDir::new().unwrap();
2854 let root = tmp.path();
2855 std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
2857 std::fs::write(
2858 root.join(".memstead").join("workspace.toml"),
2859 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2860 )
2861 .unwrap();
2862 for m in ["healthy", "oldkey"] {
2863 std::fs::create_dir_all(root.join(m)).unwrap();
2864 }
2865 std::fs::write(
2866 root.join(".memstead").join("state").join("mounts.json"),
2867 r#"{ "format": "memstead-mounts-3", "mounts": [
2868 { "mem": "healthy", "schema": "default@1.1.0", "storage": { "type": "folder", "path": "healthy" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true },
2869 { "mem": "oldkey", "schema": "fieldschema@0.1.0", "storage": { "type": "folder", "path": "oldkey" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
2870 ] }"#,
2871 )
2872 .unwrap();
2873 let pkg = root
2875 .join(".memstead")
2876 .join("schemas")
2877 .join("fieldschema@0.1.0");
2878 std::fs::create_dir_all(pkg.join("types")).unwrap();
2879 std::fs::write(
2880 pkg.join("schema.yaml"),
2881 "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",
2882 )
2883 .unwrap();
2884 std::fs::write(
2885 pkg.join("types").join("thing.yaml"),
2886 "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",
2887 )
2888 .unwrap();
2889
2890 let engine = Engine::from_workspace_root(root)
2891 .expect("the old-key schema quarantines its mem, never the workspace");
2892 assert!(engine.mounts().iter().any(|m| m.mem == "healthy"));
2893 let q = engine
2894 .quarantine_reason("oldkey")
2895 .expect("oldkey mem is quarantined");
2896 assert_eq!(q.reason_code, "SCHEMA_LOAD_FAILED");
2897 assert!(
2898 q.reason_message.contains("no_self_loop_relationships"),
2899 "quarantine reason is the rename error naming the new key: {}",
2900 q.reason_message
2901 );
2902 }
2903
2904 #[test]
2909 fn ingest_0_3_entries_are_leaves_prior_version_unchanged() {
2910 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";
2911 let boot = |pin: &str| {
2912 let tmp = TempDir::new().unwrap();
2913 let dir = tmp.path().to_path_buf();
2914 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2915 std::fs::write(
2916 dir.join(".memstead").join("config.json"),
2917 format!("{{ \"schema\": \"{pin}\" }}"),
2918 )
2919 .unwrap();
2920 std::fs::write(dir.join("gap.md"), entry_md).unwrap();
2921 let writer = FilesystemMemWriter::new(dir.clone());
2922 let mount = Mount {
2923 mem: "proc".to_string(),
2924 schema: None,
2925 storage: MountStorage::Folder { path: dir },
2926 capability: MountCapability::Write,
2927 lifecycle: MountLifecycle::Eager,
2928 cross_linkable: true,
2929 migration_target: None,
2930 };
2931 let engine =
2932 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2933 .unwrap();
2934 (engine.health(), tmp)
2935 };
2936
2937 let (health_new, _t1) = boot("ingest@0.3.0");
2938 assert_eq!(
2939 health_new.orphan_count, 0,
2940 "0.3.0 entry types are leaves — zero false orphans"
2941 );
2942 assert_eq!(
2943 health_new
2944 .leaf_entities_by_type
2945 .get("ingest@0.3.0:coverage_gap"),
2946 Some(&1),
2947 "the population stays visible: {:?}",
2948 health_new.leaf_entities_by_type
2949 );
2950
2951 let (health_old, _t2) = boot("ingest@0.2.0");
2952 assert_eq!(
2953 health_old.orphan_count, 1,
2954 "the prior version's behaviour is unchanged"
2955 );
2956 assert!(health_old.leaf_entities_by_type.is_empty());
2957 }
2958
2959 #[test]
2965 fn broken_mem_quarantines_while_healthy_siblings_serve() {
2966 let tmp = TempDir::new().unwrap();
2967 let make_mount = |mem: &str, pin: Option<SchemaRef>| {
2968 let dir = tmp.path().join(mem);
2969 std::fs::create_dir_all(&dir).unwrap();
2970 let writer = FilesystemMemWriter::new(dir.clone());
2971 (
2972 Mount {
2973 mem: mem.to_string(),
2974 schema: pin,
2975 storage: MountStorage::Folder { path: dir },
2976 capability: MountCapability::Write,
2977 lifecycle: MountLifecycle::Eager,
2978 cross_linkable: true,
2979 migration_target: None,
2980 },
2981 Box::new(writer) as Box<dyn MemBackend>,
2982 )
2983 };
2984 let healthy = make_mount(
2985 "healthy",
2986 Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
2987 );
2988 let bad_pin = make_mount(
2989 "badpin",
2990 Some(SchemaRef::new("ghost", semver::Version::new(1, 0, 0))),
2991 );
2992 let missing_pin = make_mount("nopin", None);
2993 let bad_io_path = tmp.path().join("badio");
2996 std::fs::write(&bad_io_path, "not a directory").unwrap();
2997 let bad_io = (
2998 Mount {
2999 mem: "badio".to_string(),
3000 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3001 storage: MountStorage::Folder {
3002 path: bad_io_path.clone(),
3003 },
3004 capability: MountCapability::Write,
3005 lifecycle: MountLifecycle::Eager,
3006 cross_linkable: true,
3007 migration_target: None,
3008 },
3009 Box::new(FilesystemMemWriter::new(bad_io_path)) as Box<dyn MemBackend>,
3010 );
3011 let mut engine = Engine::from_mounts(vec![healthy, bad_pin, missing_pin, bad_io])
3012 .expect("mixed workspace boots");
3013
3014 let codes: std::collections::HashMap<String, String> = engine
3016 .quarantined_mems()
3017 .iter()
3018 .map(|q| (q.mount.mem.clone(), q.reason_code.clone()))
3019 .collect();
3020 assert_eq!(
3021 codes.get("badpin").map(String::as_str),
3022 Some("SCHEMA_NOT_FOUND")
3023 );
3024 assert_eq!(
3025 codes.get("nopin").map(String::as_str),
3026 Some("MEM_CONFIG_INCOMPLETE")
3027 );
3028 assert!(
3029 codes.contains_key("badio"),
3030 "backend read failure quarantines too: {codes:?}"
3031 );
3032
3033 let mut sections = indexmap::IndexMap::new();
3035 sections.insert("identity".to_string(), "alive".to_string());
3036 sections.insert("purpose".to_string(), "proof of service".to_string());
3037 let created = engine
3038 .create_entity_with_ctx(
3039 crate::engine::CreateEntityArgs {
3040 anchors: Vec::new(),
3041 mem: "healthy".to_string(),
3042 title: "Alive".to_string(),
3043 entity_type: "spec".to_string(),
3044 sections,
3045 metadata: indexmap::IndexMap::new(),
3046 relations: Vec::new(),
3047 dry_run: false,
3048 },
3049 &crate::vcs::CommitContext::internal(),
3050 )
3051 .expect("healthy mem serves writes");
3052 assert_eq!(created.id.to_string(), "healthy--alive");
3053
3054 let mut sections = indexmap::IndexMap::new();
3056 sections.insert("identity".to_string(), "x".to_string());
3057 let err = engine
3058 .create_entity_with_ctx(
3059 crate::engine::CreateEntityArgs {
3060 anchors: Vec::new(),
3061 mem: "badpin".to_string(),
3062 title: "Nope".to_string(),
3063 entity_type: "spec".to_string(),
3064 sections,
3065 metadata: indexmap::IndexMap::new(),
3066 relations: Vec::new(),
3067 dry_run: false,
3068 },
3069 &crate::vcs::CommitContext::internal(),
3070 )
3071 .unwrap_err();
3072 assert_eq!(err.code(), "MEM_QUARANTINED");
3073 }
3074
3075 #[test]
3082 fn mem_config_schema_is_authoritative_over_mount_pin() {
3083 let tmp = TempDir::new().unwrap();
3084 let mem_dir = tmp.path().to_path_buf();
3085 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3086 std::fs::write(
3087 mem_dir.join(".memstead").join("config.json"),
3088 r#"{"schema":"software@0.1.0"}"#,
3089 )
3090 .unwrap();
3091 let writer = FilesystemMemWriter::new(mem_dir.clone());
3092 let mount = Mount {
3093 mem: "specs".to_string(),
3094 schema: Some(SchemaRef::new(
3095 "totally-not-a-schema",
3096 semver::Version::new(9, 9, 9),
3097 )),
3098 storage: MountStorage::Folder { path: mem_dir },
3099 capability: MountCapability::Write,
3100 lifecycle: MountLifecycle::Eager,
3101 cross_linkable: true,
3102 migration_target: None,
3103 };
3104 let engine = Engine::from_mounts(vec![(
3105 mount,
3106 Box::new(writer) as Box<dyn MemBackend>,
3107 )])
3108 .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
3109
3110 let mismatch = engine
3111 .load_warnings()
3112 .iter()
3113 .find_map(|w| match w {
3114 WarningHint::SchemaPinMismatch {
3115 mem,
3116 config_pin,
3117 mount_pin,
3118 } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
3119 _ => None,
3120 })
3121 .expect("SchemaPinMismatch warning must surface naming both pins");
3122 assert_eq!(mismatch.0, "specs");
3123 assert_eq!(mismatch.1, "software@0.1.0");
3124 assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
3125 }
3126
3127 #[test]
3128 fn from_workspace_root_quarantines_git_branch_mount_on_lean() {
3129 let tmp = TempDir::new().unwrap();
3130 let memstead = tmp.path().join(".memstead");
3131 std::fs::create_dir_all(&memstead).unwrap();
3132 std::fs::write(
3133 memstead.join("workspace.toml"),
3134 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3135 )
3136 .unwrap();
3137 let state_dir = memstead.join("state");
3140 std::fs::create_dir_all(&state_dir).unwrap();
3141 std::fs::write(
3142 state_dir.join("mounts.json"),
3143 r#"{
3144 "format": "memstead-mounts-3",
3145 "mounts": [
3146 {
3147 "mem": "specs",
3148 "schema": "default@1.0.0",
3149 "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
3150 "capability": "write",
3151 "lifecycle": "eager",
3152 "cross_linkable": true
3153 }
3154 ]
3155 }"#,
3156 )
3157 .unwrap();
3158 let engine = Engine::from_workspace_root(tmp.path())
3166 .expect("lean boot quarantines the git-branch mount, never fails the workspace");
3167 let roster = engine.quarantined_mems();
3168 assert_eq!(roster.len(), 1);
3169 assert_eq!(roster[0].mount.mem, "specs");
3170 assert_eq!(roster[0].reason_code, "UNSUPPORTED_WORKSPACE_SHAPE");
3171 assert_eq!(engine.unknown_mem_error("specs").code(), "MEM_QUARANTINED");
3172 }
3173
3174 #[test]
3175 fn from_workspace_root_roots_standalone_folder_mem() {
3176 let tmp = TempDir::new().unwrap();
3180 let root = tmp.path();
3181 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3182 std::fs::write(
3183 root.join(".memstead").join("config.json"),
3184 r#"{"schema":"default@1.0.0"}"#,
3185 )
3186 .unwrap();
3187 std::fs::write(
3189 root.join("hello.md"),
3190 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3191 )
3192 .unwrap();
3193
3194 let engine = Engine::from_workspace_root(root)
3195 .expect("a bare folder mem must root as a one-mount workspace");
3196 assert_eq!(engine.status().mem_count, 1, "exactly one mount");
3197 assert!(
3198 engine.status().entity_count >= 1,
3199 "the standalone mem's entity must load"
3200 );
3201 }
3202
3203 #[test]
3204 fn from_workspace_root_still_rejects_truly_empty_dir() {
3205 let tmp = TempDir::new().unwrap();
3209 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
3210 assert!(
3211 matches!(err, crate::BootError::NotInitialised(_)),
3212 "got {err:?}"
3213 );
3214 }
3215
3216 #[test]
3223 fn boot_keeps_loading_violating_schema_and_surfaces_health_finding() {
3224 let tmp = TempDir::new().unwrap();
3225 let schemas_dir = tmp.path().join("schemas");
3226 let pkg = schemas_dir.join("debate");
3227 std::fs::create_dir_all(pkg.join("types")).unwrap();
3228 std::fs::write(
3229 pkg.join("schema.yaml"),
3230 r#"name: debate
3231version: 0.1.0
3232description: sealed-violator fixture
3233when_to_use: tests
3234types:
3235 - question
3236relationships:
3237 mode: strict
3238 definitions:
3239 - name: PART_OF
3240 description: hier
3241 default_weight: 3.0
3242 - name: _default
3243 description: fallback
3244 default_weight: 1.0
3245community:
3246 resolution: 1.0
3247 seed: 42
3248"#,
3249 )
3250 .unwrap();
3251 std::fs::write(
3252 pkg.join("types").join("question.yaml"),
3253 r#"name: question
3254description: t
3255when_to_use: tests
3256sections:
3257 - key: answers
3258 heading: Answers argued
3259 required: true
3260 search_weight: 10.0
3261 write_rules: []
3262 - key: notes
3263 heading: Notes
3264 required: false
3265 search_weight: 3.0
3266 catch_all: true
3267 write_rules: []
3268metadata_fields: []
3269title_weight: 100.0
3270text_fields:
3271 - answers
3272 - notes
3273hierarchy_relationship: PART_OF
3274no_self_loop_relationships: []
3275updatable_fields:
3276 - title
3277 - answers
3278 - notes
3279health_required_fields:
3280 - answers
3281staleness_threshold_days: 90
3282write_rules: []
3283"#,
3284 )
3285 .unwrap();
3286
3287 let mem_dir = tmp.path().join("mem");
3288 std::fs::create_dir_all(&mem_dir).unwrap();
3289 std::fs::write(
3290 mem_dir.join("q.md"),
3291 "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n",
3292 )
3293 .unwrap();
3294
3295 let writer = FilesystemMemWriter::new(mem_dir.clone());
3296 let mount = Mount {
3297 mem: "debate-mem".to_string(),
3298 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3299 storage: MountStorage::Folder { path: mem_dir },
3300 capability: MountCapability::Write,
3301 lifecycle: MountLifecycle::Eager,
3302 cross_linkable: true,
3303 migration_target: None,
3304 };
3305 let engine = Engine::from_mounts_with_schemas_dir(
3306 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3307 Some(&schemas_dir),
3308 )
3309 .expect("a violating sealed schema must keep loading, never refuse boot");
3310
3311 assert!(
3313 engine.status().entity_count >= 1,
3314 "entities load despite the schema violation"
3315 );
3316
3317 let hits: Vec<_> = engine
3319 .load_warnings()
3320 .iter()
3321 .filter_map(|w| match w {
3322 WarningHint::SchemaHeadingRoundtripViolation {
3323 mem,
3324 schema_ref,
3325 violations,
3326 } => Some((mem.clone(), schema_ref.clone(), violations.clone())),
3327 _ => None,
3328 })
3329 .collect();
3330 assert_eq!(
3331 hits.len(),
3332 1,
3333 "exactly one schema-level finding; all warnings = {:?}",
3334 engine.load_warnings()
3335 );
3336 let (mem, schema_ref, violations) = &hits[0];
3337 assert_eq!(mem, "debate-mem");
3338 assert_eq!(schema_ref, "debate@0.1.0");
3339 assert_eq!(violations.len(), 1);
3340 assert_eq!(violations[0].type_name, "question");
3341 assert_eq!(violations[0].key, "answers");
3342 assert_eq!(violations[0].heading, "Answers argued");
3343 assert_eq!(violations[0].derived_key, "answers_argued");
3344 }
3345
3346 #[test]
3354 fn sealed_violator_mem_still_serves_writes() {
3355 let tmp = TempDir::new().unwrap();
3357 let schemas_dir = tmp.path().join("schemas");
3358 let pkg = schemas_dir.join("debate");
3359 std::fs::create_dir_all(pkg.join("types")).unwrap();
3360 std::fs::write(
3361 pkg.join("schema.yaml"),
3362 r#"name: debate
3363version: 0.1.0
3364description: sealed-violator fixture
3365when_to_use: tests
3366types:
3367 - question
3368relationships:
3369 mode: strict
3370 definitions:
3371 - name: PART_OF
3372 description: hier
3373 default_weight: 3.0
3374 - name: _default
3375 description: fallback
3376 default_weight: 1.0
3377community:
3378 resolution: 1.0
3379 seed: 42
3380"#,
3381 )
3382 .unwrap();
3383 std::fs::write(
3384 pkg.join("types").join("question.yaml"),
3385 r#"name: question
3386description: t
3387when_to_use: tests
3388sections:
3389 - key: answers
3390 heading: Answers argued
3391 required: true
3392 search_weight: 10.0
3393 write_rules: []
3394 - key: notes
3395 heading: Notes
3396 required: false
3397 search_weight: 3.0
3398 catch_all: true
3399 write_rules: []
3400metadata_fields: []
3401title_weight: 100.0
3402text_fields:
3403 - answers
3404 - notes
3405hierarchy_relationship: PART_OF
3406no_self_loop_relationships: []
3407updatable_fields:
3408 - title
3409 - answers
3410 - notes
3411health_required_fields:
3412 - answers
3413staleness_threshold_days: 90
3414write_rules: []
3415"#,
3416 )
3417 .unwrap();
3418
3419 let mem_dir = tmp.path().join("mem");
3420 std::fs::create_dir_all(&mem_dir).unwrap();
3421 std::fs::write(
3425 mem_dir.join("q.md"),
3426 "---\ntype: question\n---\n# Q\n\n## Answers\n\nTwo answers.\n",
3427 )
3428 .unwrap();
3429
3430 let writer = FilesystemMemWriter::new(mem_dir.clone());
3431 let mount = Mount {
3432 mem: "debate-mem".to_string(),
3433 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3434 storage: MountStorage::Folder { path: mem_dir },
3435 capability: MountCapability::Write,
3436 lifecycle: MountLifecycle::Eager,
3437 cross_linkable: true,
3438 migration_target: None,
3439 };
3440 let mut engine = Engine::from_mounts_with_schemas_dir(
3441 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3442 Some(&schemas_dir),
3443 )
3444 .expect("a violating sealed schema must keep loading");
3445
3446 let id = crate::EntityId::new("debate-mem", "q");
3447 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3448 let mut sections = indexmap::IndexMap::new();
3449 sections.insert("answers".to_string(), "Updated answers body.".to_string());
3450 let outcome = engine
3451 .update_entity(
3452 crate::engine::UpdateEntityArgs {
3453 anchors: Vec::new(),
3454 anchors_unset: Vec::new(),
3455 id: id.clone(),
3456 expected_hash: Some(hash),
3457 sections,
3458 append_sections: indexmap::IndexMap::new(),
3459 patch_sections: indexmap::IndexMap::new(),
3460 metadata: indexmap::IndexMap::new(),
3461 metadata_unset: Vec::new(),
3462 declare_relations: Vec::new(),
3463 dry_run: false,
3464 relations_unset: Vec::new(),
3465 },
3466 crate::vcs::Actor::Cli,
3467 None,
3468 None,
3469 )
3470 .expect("a write against a sealed-violator mem must NOT be refused");
3471 assert!(!outcome.commit_sha.is_empty(), "the write commits");
3472 assert!(
3473 outcome
3474 .warnings
3475 .iter()
3476 .any(|w| w.code() == "SECTION_HEADING_DIVERGENCE"),
3477 "the write-path divergence warning fires where its condition holds: {:?}",
3478 outcome.warnings
3479 );
3480
3481 assert!(
3483 engine.load_warnings().iter().any(|w| matches!(
3484 w,
3485 WarningHint::SchemaHeadingRoundtripViolation { mem, .. } if mem == "debate-mem"
3486 )),
3487 "the health finding persists across writes"
3488 );
3489 let entity = engine.get_entity(&id).unwrap();
3498 assert!(
3499 entity
3500 .sections
3501 .values()
3502 .any(|s| s.contains("Updated answers body.")),
3503 "written content survives the round-trip (in the catch-all): {:?}",
3504 entity.sections
3505 );
3506 }
3507
3508 #[test]
3515 fn search_mem_filter_gates_against_visible_roster() {
3516 use crate::vcs::Actor;
3517 use crate::workspace_store::WorkspaceStoreAdapter;
3518 use indexmap::IndexMap;
3519
3520 let tmp = TempDir::new().unwrap();
3521 let mem_dir = tmp.path().join("mem");
3522 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3523 std::fs::write(
3524 mem_dir.join(".memstead").join("config.json"),
3525 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3526 )
3527 .unwrap();
3528 let memstead = tmp.path().join(".memstead");
3529 std::fs::create_dir_all(&memstead).unwrap();
3530 std::fs::write(
3531 memstead.join("workspace.toml"),
3532 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3533 )
3534 .unwrap();
3535 crate::FileWorkspaceStore::new()
3536 .save_state(
3537 tmp.path(),
3538 &crate::workspace::Workspace {
3539 mounts: vec![folder_mount("specs", mem_dir.clone())],
3540 settings: crate::workspace::WorkspaceSettings::default(),
3541 },
3542 )
3543 .unwrap();
3544 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
3545 let mut sections = IndexMap::new();
3546 sections.insert(
3547 "identity".to_string(),
3548 "Zebra searching fixture.".to_string(),
3549 );
3550 sections.insert("purpose".to_string(), "Search gate test.".to_string());
3551 engine
3552 .create_entity(
3553 crate::CreateEntityArgs {
3554 mem: "specs".to_string(),
3555 title: "Zebra".to_string(),
3556 entity_type: "spec".to_string(),
3557 sections,
3558 metadata: IndexMap::new(),
3559 relations: Vec::new(),
3560 anchors: Vec::new(),
3561 dry_run: false,
3562 },
3563 Actor::Agent,
3564 None,
3565 None,
3566 )
3567 .unwrap();
3568
3569 let scope = |mem: Option<&str>, term: &str| crate::ops::SearchScope {
3570 query: Some(crate::ops::Query {
3571 any: vec![term.to_string()],
3572 ..Default::default()
3573 }),
3574 mem: mem.map(str::to_string),
3575 ..Default::default()
3576 };
3577
3578 let err = engine
3580 .search(&scope(Some("no-such-mem"), "zebra"))
3581 .expect_err("a nonexistent mem filter must refuse");
3582 assert_eq!(err.code(), "UNKNOWN_MEM", "got {err:?}");
3583
3584 let hit = engine.search(&scope(Some("specs"), "zebra")).unwrap();
3586 assert!(hit.total >= 1, "the fixture entity matches: {hit:?}");
3587
3588 let none = engine
3591 .search(&scope(Some("specs"), "quixotic-nonword"))
3592 .unwrap();
3593 assert_eq!(none.total, 0, "{none:?}");
3594 }
3595
3596 fn lazy_folder_mount(mem: &str, path: std::path::PathBuf) -> Mount {
3601 Mount {
3602 lifecycle: MountLifecycle::Lazy,
3603 ..folder_mount(mem, path)
3604 }
3605 }
3606
3607 fn write_spec(dir: &Path, slug: &str, title: &str, extra: &str) {
3608 std::fs::write(
3609 dir.join(format!("{slug}.md")),
3610 format!("---\ntype: spec\n---\n# {title}\n\n## Identity\n\nBody.\n{extra}"),
3611 )
3612 .unwrap();
3613 }
3614
3615 fn two_mem_dirs(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
3616 let eager_dir = tmp.path().join("eag");
3617 let lazy_dir = tmp.path().join("laz");
3618 std::fs::create_dir_all(&eager_dir).unwrap();
3619 std::fs::create_dir_all(&lazy_dir).unwrap();
3620 write_spec(&eager_dir, "alpha", "Alpha", "");
3621 write_spec(&lazy_dir, "omega", "Omega", "");
3622 (eager_dir, lazy_dir)
3623 }
3624
3625 fn mixed_engine(eager_dir: &Path, lazy_dir: &Path) -> Engine {
3626 Engine::from_mounts(vec![
3627 (
3628 folder_mount("eag", eager_dir.to_path_buf()),
3629 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
3630 ),
3631 (
3632 lazy_folder_mount("laz", lazy_dir.to_path_buf()),
3633 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
3634 ),
3635 ])
3636 .unwrap()
3637 }
3638
3639 #[test]
3645 fn lazy_mount_defers_and_first_read_loads() {
3646 let tmp = TempDir::new().unwrap();
3647 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3648 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3649
3650 assert!(
3652 engine
3653 .get_entity(&crate::EntityId::new("eag", "alpha"))
3654 .is_some()
3655 );
3656 assert_eq!(engine.deferred_mems(), vec!["laz"]);
3657 assert!(engine.mem_is_deferred("laz"));
3658 assert!(
3659 engine
3660 .get_entity(&crate::EntityId::new("laz", "omega"))
3661 .is_none(),
3662 "deferred mem's entities are not in the store yet"
3663 );
3664 assert!(engine.schema_for("laz").is_some());
3666
3667 engine.reload_if_stale(Some("laz"));
3669 assert!(engine.deferred_mems().is_empty());
3670 let omega = engine
3671 .get_entity(&crate::EntityId::new("laz", "omega"))
3672 .expect("first read loads the mem");
3673 assert_eq!(omega.title, "Omega");
3674
3675 let (actor, client) = cli_actor();
3677 engine
3678 .create_entity(
3679 empty_create_args("laz", "Later"),
3680 actor,
3681 Some(&client),
3682 None,
3683 )
3684 .unwrap();
3685 assert!(
3686 engine
3687 .get_entity(&crate::EntityId::new("laz", "later"))
3688 .is_some()
3689 );
3690 }
3691
3692 #[test]
3696 fn scoped_operation_never_loads_lazy_sibling() {
3697 let tmp = TempDir::new().unwrap();
3698 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3699 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3700
3701 engine.reload_if_stale(Some("eag"));
3702 assert_eq!(
3703 engine.deferred_mems(),
3704 vec!["laz"],
3705 "an eager-scoped operation must not load the lazy sibling"
3706 );
3707
3708 engine.reload_if_stale(None);
3709 assert!(
3710 engine.deferred_mems().is_empty(),
3711 "a workspace-scoped pass loads every deferred mem"
3712 );
3713 }
3714
3715 #[test]
3719 fn lazy_load_failure_quarantines_typed() {
3720 let tmp = TempDir::new().unwrap();
3721 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3722 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3723
3724 std::fs::remove_dir_all(&lazy_dir).unwrap();
3728 std::fs::write(&lazy_dir, b"not a directory").unwrap();
3729 engine.reload_if_stale(Some("laz"));
3730
3731 let q = engine
3732 .quarantine_reason("laz")
3733 .expect("failed deferred load quarantines, never serves empty");
3734 assert!(!q.reason_code.is_empty());
3735 assert!(
3736 !engine.mem_names().contains(&"laz"),
3737 "a quarantined mem leaves the serving roster"
3738 );
3739 assert!(engine.deferred_mems().is_empty());
3740 }
3741
3742 #[test]
3750 fn quarantine_entry_invalidates_both_memos() {
3751 let tmp = TempDir::new().unwrap();
3752 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3753 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3754
3755 let _ = engine.communities();
3757 let _ = engine.search_indexes();
3758 assert!(engine.search_indexes_memo.get().is_some());
3759
3760 std::fs::remove_dir_all(&lazy_dir).unwrap();
3762 std::fs::write(&lazy_dir, b"not a directory").unwrap();
3763 engine.reload_if_stale(Some("laz"));
3764 assert!(engine.quarantine_reason("laz").is_some());
3765 assert!(
3766 engine.search_indexes_memo.get().is_none(),
3767 "quarantine entry bumps the schemas epoch — the search memo must clear"
3768 );
3769 assert!(
3770 engine.community_memo.get().is_none(),
3771 "quarantine entry must clear the community memo too"
3772 );
3773 let _ = engine.search_indexes();
3775
3776 let _ = engine.communities();
3778 let _ = engine.search_indexes();
3779 let _ = engine.reload_one_mem("laz");
3780 assert!(engine.quarantine_reason("laz").is_some());
3781 assert!(
3782 engine.search_indexes_memo.get().is_none(),
3783 "a failed reattach bumps the epoch — the search memo must clear"
3784 );
3785 let _ = engine.search_indexes();
3786 }
3787
3788 #[test]
3793 fn quarantine_reattach_invalidates_both_memos() {
3794 let tmp = TempDir::new().unwrap();
3795 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3796 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3797
3798 std::fs::remove_dir_all(&lazy_dir).unwrap();
3800 std::fs::write(&lazy_dir, b"not a directory").unwrap();
3801 engine.reload_if_stale(Some("laz"));
3802 assert!(engine.quarantine_reason("laz").is_some());
3803
3804 let _ = engine.communities();
3806 let _ = engine.search_indexes();
3807 assert!(engine.community_memo.get().is_some());
3808 assert!(engine.search_indexes_memo.get().is_some());
3809
3810 std::fs::remove_file(&lazy_dir).unwrap();
3812 std::fs::create_dir_all(&lazy_dir).unwrap();
3813 write_spec(&lazy_dir, "omega", "Omega", "");
3814 engine
3815 .reload_one_mem("laz")
3816 .expect("reattach succeeds once the backend is back");
3817 assert!(engine.quarantine_reason("laz").is_none());
3818
3819 assert!(
3822 engine.community_memo.get().is_none(),
3823 "reattach must invalidate the community memo"
3824 );
3825 assert!(
3826 engine.search_indexes_memo.get().is_none(),
3827 "reattach must invalidate the search memo"
3828 );
3829 }
3830
3831 #[test]
3836 fn lazy_load_runs_the_same_validation_gauntlet() {
3837 let tmp = TempDir::new().unwrap();
3838 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3839 std::fs::write(
3841 lazy_dir.join("bad.md"),
3842 "---\ntype: spec\n---\n# Bad\n\n## Identity\n\nx.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[laz--omega]]\n",
3843 )
3844 .unwrap();
3845
3846 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3847 let warned_before = engine
3848 .load_warnings()
3849 .iter()
3850 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
3851 engine.reload_if_stale(Some("laz"));
3852 let warned_after = engine
3853 .load_warnings()
3854 .iter()
3855 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
3856 assert!(
3857 !warned_before && warned_after,
3858 "the gauntlet fires at load time: before={warned_before} after={warned_after}, \
3859 warnings: {:?}",
3860 engine.load_warnings()
3861 );
3862 let bad = engine
3864 .get_entity(&crate::EntityId::new("laz", "bad"))
3865 .unwrap();
3866 assert!(bad.relationships.is_empty());
3867 }
3868
3869 #[test]
3877 fn delete_guard_sees_referrers_in_deferred_mems() {
3878 use crate::engine::{DeleteEntityArgs, RelateEntityArgs};
3879
3880 let tmp = TempDir::new().unwrap();
3881 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3882 let (actor, client) = cli_actor();
3883
3884 {
3888 let mut authoring = Engine::from_mounts(vec![
3889 (
3890 folder_mount("eag", eager_dir.to_path_buf()),
3891 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf()))
3892 as Box<dyn MemBackend>,
3893 ),
3894 (
3895 folder_mount("laz", lazy_dir.to_path_buf()),
3896 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf()))
3897 as Box<dyn MemBackend>,
3898 ),
3899 ])
3900 .unwrap();
3901 let mut settings = crate::workspace::WorkspaceSettings::default();
3902 settings.cross_mem_links.insert(
3903 "laz".to_string(),
3904 memstead_schema::workspace_config::CrossLinkValue::List(vec!["eag".to_string()]),
3905 );
3906 authoring.set_settings(settings);
3907 authoring
3908 .relate_entity(
3909 RelateEntityArgs {
3910 source: crate::EntityId::new("laz", "omega"),
3911 expected_hash: None,
3912 rel_type: "USES".to_string(),
3913 target: crate::EntityId::new("eag", "alpha"),
3914 remove: false,
3915 description: None,
3916 dry_run: false,
3917 },
3918 actor,
3919 Some(&client),
3920 None,
3921 )
3922 .expect("cross-mem relate lands under the grant");
3923 }
3924
3925 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3929 assert!(engine.mem_is_deferred("laz"));
3930 let err = engine
3931 .delete_entity(
3932 DeleteEntityArgs {
3933 id: crate::EntityId::new("eag", "alpha"),
3934 expected_hash: None,
3935 },
3936 actor,
3937 Some(&client),
3938 None,
3939 )
3940 .expect_err("a referenced entity must refuse deletion, lazy referrer or not");
3941 assert!(
3942 matches!(err, EngineError::HasIncomingRefs { .. }),
3943 "expected HAS_INCOMING_REFS, got {err:?}"
3944 );
3945 assert!(
3946 engine
3947 .get_entity(&crate::EntityId::new("eag", "alpha"))
3948 .is_some(),
3949 "the entity survives"
3950 );
3951 }
3952
3953 #[test]
3964 fn acyclicity_guard_sees_edges_in_deferred_mems() {
3965 use crate::engine::RelateEntityArgs;
3966 use memstead_schema::workspace_config::CrossLinkValue;
3967
3968 let tmp = TempDir::new().unwrap();
3969 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
3970 .iter()
3971 .map(|m| {
3972 let d = tmp.path().join(m);
3973 std::fs::create_dir_all(&d).unwrap();
3974 d
3975 })
3976 .collect();
3977 write_spec(&dirs[0], "node", "Node A", "");
3978 write_spec(&dirs[1], "node", "Node B", "");
3979 write_spec(&dirs[2], "node", "Node C", "");
3980
3981 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
3982 ["ma", "mb", "mc"]
3983 .iter()
3984 .zip(dirs.iter())
3985 .map(|(m, d)| {
3986 let mut mount = folder_mount(m, d.clone());
3987 if lazy_mid && *m == "ma" {
3988 mount.lifecycle = MountLifecycle::Lazy;
3989 }
3990 (
3991 mount,
3992 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
3993 )
3994 })
3995 .collect()
3996 };
3997 let grants = || {
3998 let mut settings = crate::workspace::WorkspaceSettings::default();
3999 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4000 settings
4001 .cross_mem_links
4002 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4003 }
4004 settings
4005 };
4006 let relate = |engine: &mut Engine, from: &str, to: &str| {
4007 let (actor, client) = cli_actor();
4008 engine.relate_entity(
4009 RelateEntityArgs {
4010 source: crate::EntityId::new(from, "node"),
4011 expected_hash: None,
4012 rel_type: "DEPENDS_ON".to_string(),
4013 target: crate::EntityId::new(to, "node"),
4014 remove: false,
4015 description: None,
4016 dry_run: false,
4017 },
4018 actor,
4019 Some(&client),
4020 None,
4021 )
4022 };
4023
4024 {
4026 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4027 authoring.set_settings(grants());
4028 relate(&mut authoring, "mb", "ma").expect("mb→ma lands");
4029 relate(&mut authoring, "ma", "mc").expect("ma→mc lands");
4030 }
4031
4032 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4035 engine.set_settings(grants());
4036 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4037 let err = relate(&mut engine, "mc", "mb")
4038 .expect_err("a cycle through a deferred mem must refuse, as eager refuses");
4039 assert!(
4040 matches!(err, EngineError::RelationshipCycle { .. }),
4041 "expected RELATIONSHIP_CYCLE, got {err:?}"
4042 );
4043 }
4044
4045 #[test]
4053 fn batch_acyclicity_guard_sees_edges_in_deferred_mems() {
4054 use crate::engine::RelateEntityArgs;
4055 use memstead_schema::workspace_config::CrossLinkValue;
4056
4057 let tmp = TempDir::new().unwrap();
4058 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4059 .iter()
4060 .map(|m| {
4061 let d = tmp.path().join(m);
4062 std::fs::create_dir_all(&d).unwrap();
4063 d
4064 })
4065 .collect();
4066 write_spec(&dirs[0], "node", "Node A", "");
4067 write_spec(&dirs[1], "node", "Node B", "");
4068 write_spec(&dirs[2], "node", "Node C", "");
4069 write_spec(&dirs[2], "node2", "Node C2", "");
4074
4075 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4076 ["ma", "mb", "mc"]
4077 .iter()
4078 .zip(dirs.iter())
4079 .map(|(m, d)| {
4080 let mut mount = folder_mount(m, d.clone());
4081 if lazy_mid && *m == "ma" {
4082 mount.lifecycle = MountLifecycle::Lazy;
4083 }
4084 (
4085 mount,
4086 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4087 )
4088 })
4089 .collect()
4090 };
4091 let grants = || {
4092 let mut settings = crate::workspace::WorkspaceSettings::default();
4093 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4094 settings
4095 .cross_mem_links
4096 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4097 }
4098 settings
4099 };
4100 let relate_args = |from: &str, to: &str| RelateEntityArgs {
4101 source: crate::EntityId::new(from, "node"),
4102 expected_hash: None,
4103 rel_type: "DEPENDS_ON".to_string(),
4104 target: crate::EntityId::new(to, "node"),
4105 remove: false,
4106 description: None,
4107 dry_run: false,
4108 };
4109
4110 {
4112 let (actor, client) = cli_actor();
4113 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4114 authoring.set_settings(grants());
4115 authoring
4116 .relate_entity(relate_args("mb", "ma"), actor, Some(&client), None)
4117 .expect("mb→ma lands");
4118 let (actor2, client2) = cli_actor();
4119 authoring
4120 .relate_entity(relate_args("ma", "mc"), actor2, Some(&client2), None)
4121 .expect("ma→mc lands");
4122 }
4123
4124 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4128 engine.set_settings(grants());
4129 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4130 let (actor, client) = cli_actor();
4131 let result = engine
4132 .batch_relate(
4133 vec![
4134 (relate_args("mc", "mb"), None),
4135 (
4141 RelateEntityArgs {
4142 source: crate::EntityId::new("mc", "node2"),
4143 expected_hash: None,
4144 rel_type: "DEPENDS_ON".to_string(),
4145 target: crate::EntityId::new("mc", "node"),
4146 remove: false,
4147 description: None,
4148 dry_run: false,
4149 },
4150 None,
4151 ),
4152 ],
4153 actor,
4154 Some(&client),
4155 false,
4156 )
4157 .expect("the batch call itself returns a report-all envelope");
4158 assert!(
4159 !result.applied,
4160 "a batch closing a cycle through a deferred mem must refuse, as eager refuses; got applied with {} succeeded",
4161 result.succeeded
4162 );
4163 }
4164
4165 #[test]
4173 fn relate_into_deferred_mem_verifies_against_storage_without_load() {
4174 use crate::engine::RelateEntityArgs;
4175 use memstead_schema::workspace_config::CrossLinkValue;
4176
4177 let tmp = TempDir::new().unwrap();
4178 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4179 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4180 let mut settings = crate::workspace::WorkspaceSettings::default();
4181 settings.cross_mem_links.insert(
4182 "eag".to_string(),
4183 CrossLinkValue::List(vec!["laz".to_string()]),
4184 );
4185 engine.set_settings(settings);
4186
4187 let relate = |engine: &mut Engine, to: &str| {
4188 let (actor, client) = cli_actor();
4189 engine.relate_entity(
4190 RelateEntityArgs {
4191 source: crate::EntityId::new("eag", "alpha"),
4192 expected_hash: None,
4193 rel_type: "SUPPORTS".to_string(),
4194 target: crate::EntityId::new("laz", to),
4195 remove: false,
4196 description: None,
4197 dry_run: false,
4198 },
4199 actor,
4200 Some(&client),
4201 None,
4202 )
4203 };
4204
4205 let outcome = relate(&mut engine, "omega").expect("verified target admits");
4207 assert!(
4208 engine.mem_is_deferred("laz"),
4209 "verification never loads the mem"
4210 );
4211 assert!(
4212 !outcome
4213 .warnings
4214 .iter()
4215 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4216 "a storage-verified target is not an auto-stub case: {:?}",
4217 outcome.warnings
4218 );
4219 let stub = engine
4220 .store()
4221 .get(&crate::EntityId::new("laz", "omega"))
4222 .expect("until-load stub present");
4223 assert!(stub.stub);
4224 assert_eq!(
4225 stub.stub_kind,
4226 Some(crate::entity::StubKind::LoadTime),
4227 "verified-in-storage stub carries the load-time kind"
4228 );
4229
4230 let outcome = relate(&mut engine, "missing").expect("absent Write-mem target auto-stubs");
4232 assert!(engine.mem_is_deferred("laz"), "still no load");
4233 assert!(
4234 outcome
4235 .warnings
4236 .iter()
4237 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4238 "absent target keeps the auto-stub warning: {:?}",
4239 outcome.warnings
4240 );
4241 let stub = engine
4242 .store()
4243 .get(&crate::EntityId::new("laz", "missing"))
4244 .expect("forward-reference stub present");
4245 assert_eq!(
4246 stub.stub_kind,
4247 Some(crate::entity::StubKind::ForwardReference)
4248 );
4249 }
4250
4251 #[test]
4257 fn readonly_deferred_target_answers_from_storage() {
4258 use crate::engine::RelateEntityArgs;
4259 use memstead_schema::workspace_config::CrossLinkValue;
4260
4261 let tmp = TempDir::new().unwrap();
4262 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4263 let mut ro_mount = lazy_folder_mount("laz", lazy_dir.to_path_buf());
4264 ro_mount.capability = crate::workspace::MountCapability::ReadOnly;
4265 let mut engine = Engine::from_mounts(vec![
4266 (
4267 folder_mount("eag", eager_dir.to_path_buf()),
4268 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4269 ),
4270 (
4271 ro_mount,
4272 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4273 ),
4274 ])
4275 .unwrap();
4276 let mut settings = crate::workspace::WorkspaceSettings::default();
4277 settings.cross_mem_links.insert(
4278 "eag".to_string(),
4279 CrossLinkValue::List(vec!["laz".to_string()]),
4280 );
4281 engine.set_settings(settings);
4282
4283 let relate = |engine: &mut Engine, to: &str| {
4284 let (actor, client) = cli_actor();
4285 engine.relate_entity(
4286 RelateEntityArgs {
4287 source: crate::EntityId::new("eag", "alpha"),
4288 expected_hash: None,
4289 rel_type: "SUPPORTS".to_string(),
4290 target: crate::EntityId::new("laz", to),
4291 remove: false,
4292 description: None,
4293 dry_run: false,
4294 },
4295 actor,
4296 Some(&client),
4297 None,
4298 )
4299 };
4300
4301 relate(&mut engine, "omega").expect("present-in-storage RO target admits");
4302 assert!(
4303 engine.mem_is_deferred("laz"),
4304 "the admit never loads the mem"
4305 );
4306
4307 let err =
4308 relate(&mut engine, "missing").expect_err("absent RO target keeps the typed refusal");
4309 assert!(
4310 matches!(err, EngineError::CrossMemTargetNotFound { .. }),
4311 "expected CROSS_MEM_TARGET_NOT_FOUND, got {err:?}"
4312 );
4313 assert!(
4314 engine.mem_is_deferred("laz"),
4315 "the refusal never loads the mem either"
4316 );
4317 }
4318
4319 #[test]
4324 fn cross_mem_link_into_lazy_mem_resolves_on_load() {
4325 let tmp = TempDir::new().unwrap();
4326 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4327 write_spec(
4328 &eager_dir,
4329 "linker",
4330 "Linker",
4331 "\nSee [[laz--omega]] for detail.\n",
4332 );
4333
4334 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4335 let target = crate::EntityId::new("laz", "omega");
4336 assert!(
4337 engine.get_entity(&target).is_none_or(|e| e.stub),
4338 "before the lazy load the cross-mem target is at most a stub, never real"
4339 );
4340
4341 engine.reload_if_stale(Some("laz"));
4342 let resolved = engine.get_entity(&target).expect("target loaded");
4343 assert!(!resolved.stub, "after the load the target is real");
4344 assert!(
4345 !engine.load_warnings().iter().any(
4346 |w| matches!(w, WarningHint::SuspiciousNestedPrefix { resolved_id, .. } if resolved_id.as_ref() == target.as_ref())
4347 ),
4348 "no lingering nested-prefix warning for a resolved cross-mem link: {:?}",
4349 engine.load_warnings()
4350 );
4351 }
4352}