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 deferred_idx.insert(m_idx);
356 continue;
357 }
358
359 let (entries, read_errors) = match collect_source_entries(m.backend.as_ref()) {
360 Ok(pair) => pair,
361 Err(e) => {
362 quarantined.push(crate::engine::QuarantinedMem {
365 mount: m.mount.clone(),
366 reason_code: e.code().to_string(),
367 reason_message: e.to_string(),
368 });
369 quarantined_idx.insert(m_idx);
370 schemas.remove(&m.mount.mem);
371 continue;
372 }
373 };
374 let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
375 push_entities_into_store(
381 &mut store,
382 load_result.entities,
383 fallback.as_ref(),
384 Some(crate::entity::store_builder::LoadCollector {
385 warnings: &mut load_warnings,
386 known_suffixes: &known_suffixes,
387 mem_names: &mem_names,
388 }),
389 );
390 if let crate::workspace::MountStorage::Folder { path } = &m.mount.storage {
395 let root = path.clone();
396 load_errors.extend(load_result.errors.into_iter().map(|(p, msg)| {
397 let abs = if p.is_relative() { root.join(&p) } else { p };
398 (abs, msg)
399 }));
400 } else {
401 load_errors.extend(load_result.errors);
402 }
403 }
404
405 for idx in &deferred_idx {
408 mounted[*idx].deferred = true;
409 }
410
411 if !quarantined_idx.is_empty() {
416 let mut keep_idx = 0usize;
417 mounted.retain(|_| {
418 let keep = !quarantined_idx.contains(&keep_idx);
419 keep_idx += 1;
420 keep
421 });
422 }
423
424 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
432 mounted
433 .iter()
434 .map(|m| (m.mount.mem.clone(), m.mount.capability))
435 .collect();
436 crate::entity::store_builder::validate_loaded_relations(
437 &mut store,
438 &schemas,
439 &mount_caps,
440 &mut load_warnings,
441 );
442
443 crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
448
449 load_warnings.retain(|w| match w {
458 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
459 store.get(resolved_id).is_none_or(|e| e.stub)
460 }
461 _ => true,
462 });
463
464 let mem_router = build_mem_router_from_mounts(&mounted);
470
471 Ok(Self {
472 mounts: mounted,
473 store,
474 schemas,
475 workspace_schemas,
476 builtin_schemas: builtin_schemas_only,
477 load_errors,
478 community_memo: OnceCell::new(),
479 labelling_memo: OnceCell::new(),
480 #[cfg(not(target_arch = "wasm32"))]
481 search_indexes_memo: OnceCell::new(),
482 settings: WorkspaceSettings::default(),
483 create_rule_set_memo: OnceCell::new(),
484 declared_origins: HashMap::new(),
485 workspace_root: None,
486 load_warnings,
487 quarantined,
488 boot_diagnosis: None,
489 pipeline_configs: crate::pipeline_store::BindingConfigs::default(),
490 mem_router: Arc::new(mem_router),
491 backend_factory: crate::workspace_store::instantiate_lean_backend,
492 unmounted_storage_prober: None,
493 schemas_epoch: 0,
494 git_branch_ops: None,
495 event_subscribers: Arc::new(std::sync::Mutex::new(
496 crate::engine::events::SubscriberRegistry::new(),
497 )),
498 pending_mem_changed: Vec::new(),
499 mutation_clock: Arc::new(std::time::SystemTime::now),
500 current_role: crate::vcs::Role::Unspecified,
501 })
502 }
503
504 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
522 use crate::workspace_store::{
523 FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
524 instantiate_lean_backend,
525 };
526
527 let workspace = match detect_layout(workspace_root) {
528 Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
532 Some(ws) => ws,
533 None => {
534 return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
535 }
536 },
537 Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
538 };
539
540 let settings = workspace.settings.clone();
541 let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
542 Vec::with_capacity(workspace.mounts.len());
543 let mut instantiate_quarantine: Vec<crate::engine::QuarantinedMem> = Vec::new();
547 for mount in workspace.mounts {
548 match instantiate_lean_backend(&mount) {
549 Ok(backend) => mounts.push((mount, backend)),
550 Err(e) => instantiate_quarantine.push(crate::engine::QuarantinedMem {
551 reason_code: e.code().to_string(),
552 reason_message: e.to_string(),
553 mount,
554 }),
555 }
556 }
557 let fixed_dir = workspace_root.join(".memstead").join("schemas");
566 let (local, failed) = load_workspace_schemas_with_failures(Some(fixed_dir.as_path()));
567 let mut engine = Engine::from_mounts_inner(mounts, local, failed)
570 .map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
571 engine.quarantined.extend(instantiate_quarantine);
572 engine.set_settings(settings);
573 engine.workspace_root = Some(workspace_root.to_path_buf());
574 engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
585 workspace_root,
586 )?);
587 let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
592 Ok(engine)
593 }
594}
595
596pub(crate) fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
629 let mut router = MemRouterSnapshot::new();
630 for m in mounts {
631 match m.mount.capability {
632 MountCapability::Write => {
633 let dir: Option<PathBuf> = match &m.mount.storage {
634 MountStorage::Folder { path } => Some(path.clone()),
635 MountStorage::GitBranch { .. } => None,
636 MountStorage::Archive { .. } => None,
637 MountStorage::InMemory => None,
641 };
642 router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
643 }
644 MountCapability::ReadOnly => match &m.mount.storage {
645 MountStorage::Archive { path } => {
646 router.add_read_only(m.mount.mem.clone(), path.clone());
647 }
648 MountStorage::Folder { path } => {
649 router.add_read_only(m.mount.mem.clone(), path.clone());
650 }
651 MountStorage::GitBranch { gitdir, .. } => {
652 router.add_read_only(m.mount.mem.clone(), gitdir.clone());
653 }
654 MountStorage::InMemory => {}
660 },
661 }
662 }
663 router
664}
665
666pub fn resolve_builtin_schema_pin_pub(
671 pin: &memstead_schema::SchemaRef,
672 catalogue: &[Arc<memstead_schema::Schema>],
673) -> Option<Arc<memstead_schema::Schema>> {
674 resolve_builtin_schema_pin(pin, catalogue)
675}
676
677fn newest_builtin_version<'a>(
682 name: &str,
683 builtins: &'a [Arc<memstead_schema::Schema>],
684) -> Option<&'a semver::Version> {
685 builtins
686 .iter()
687 .filter(|s| s.manifest.name == name)
688 .map(|s| &s.version)
689 .max()
690}
691
692pub struct SchemaResolver<'a> {
707 catalogue: &'a [Arc<memstead_schema::Schema>],
708}
709
710impl<'a> SchemaResolver<'a> {
711 pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
714 Self { catalogue }
715 }
716
717 pub fn resolve(
721 &self,
722 pin: &memstead_schema::SchemaRef,
723 ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
724 {
725 resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
726 crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
727 &pin.name,
728 &pin.version,
729 self.catalogue,
730 )
731 })
732 }
733}
734
735pub fn load_workspace_schemas(
743 schemas_dir: Option<&Path>,
744) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
745 Ok(load_workspace_schemas_with_failures(schemas_dir).0)
746}
747
748#[derive(Debug, Clone)]
755pub struct FailedSchemaPackage {
756 pub path: PathBuf,
757 pub name: Option<String>,
758 pub version: Option<String>,
759 pub error: String,
761}
762
763pub fn load_workspace_schemas_with_failures(
769 schemas_dir: Option<&Path>,
770) -> (Vec<Arc<memstead_schema::Schema>>, Vec<FailedSchemaPackage>) {
771 let Some(dir) = schemas_dir else {
772 return (Vec::new(), Vec::new());
773 };
774 if !dir.is_dir() {
775 return (Vec::new(), Vec::new());
776 }
777 let entries = match std::fs::read_dir(dir) {
778 Ok(e) => e,
779 Err(_) => return (Vec::new(), Vec::new()),
780 };
781 let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
782 let mut failures: Vec<FailedSchemaPackage> = Vec::new();
783 for entry in entries.flatten() {
784 let path = entry.path();
785 if !path.is_dir() {
786 continue;
787 }
788 if !path.join("schema.yaml").is_file() {
789 continue;
790 }
791 match memstead_schema::load_schema_from_dir(&path) {
792 Ok(schema) => schemas.push(Arc::new(schema)),
793 Err(e) => {
794 let header = std::fs::read_to_string(path.join("schema.yaml")).unwrap_or_default();
798 let peek = |k: &str| {
799 header
800 .lines()
801 .find_map(|l| l.strip_prefix(&format!("{k}:")))
802 .map(|v| v.trim().trim_matches('"').to_string())
803 .filter(|v| !v.is_empty())
804 };
805 failures.push(FailedSchemaPackage {
806 path: path.clone(),
807 name: peek("name"),
808 version: peek("version"),
809 error: e.to_string(),
810 });
811 }
812 }
813 }
814 (schemas, failures)
815}
816
817pub(super) fn resolve_builtin_schema_pin(
818 pin: &memstead_schema::SchemaRef,
819 catalogue: &[Arc<memstead_schema::Schema>],
820) -> Option<Arc<memstead_schema::Schema>> {
821 catalogue
822 .iter()
823 .find(|s| {
824 let id = s.id();
825 id.0 == pin.name && id.1 == pin.version
826 })
827 .cloned()
828}
829
830pub(super) fn collect_source_entries(
831 backend: &dyn MemBackend,
832) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
833 let paths = backend.list_entities()?;
834 let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
835 let mut errors: Vec<SourceReadError> = Vec::new();
836 for path in paths {
837 match backend.read_entity(&path) {
838 Ok(Some(bytes)) => match String::from_utf8(bytes) {
839 Ok(content) => entries.push(SourceEntry {
840 relative_path: path.to_string_lossy().into_owned(),
841 source_path: path.clone(),
842 content,
843 }),
844 Err(e) => errors.push(SourceReadError {
845 source_path: path,
846 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
847 }),
848 },
849 Ok(None) => {
850 }
852 Err(e) => errors.push(SourceReadError {
853 source_path: path,
854 error: std::io::Error::other(e.to_string()),
855 }),
856 }
857 }
858 Ok((entries, errors))
859}
860
861#[cfg(test)]
862mod tests {
863
864 use std::path::Path;
865
866 use memstead_schema::SchemaRef;
867 use tempfile::TempDir;
868
869 use crate::backend::MemBackend;
870 use crate::engine::test_helpers::*;
871 use crate::engine::{Engine, EngineError};
872 use crate::ops::WarningHint;
873 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
874 use crate::vcs::CommitContext;
875 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
876
877 #[test]
881 fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
882 let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
883 let resolver = super::SchemaResolver::new(&catalogue);
884
885 let ok: SchemaRef = "default@1.0.0".parse().unwrap();
886 assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
887
888 let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
889 let sources = resolver.resolve(&miss).unwrap_err();
890 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
891 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
892 assert!(sources.iter().all(|s| !s.pinned_version_match));
893 }
894
895 #[test]
896 fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
897 let engine = Engine::from_mounts(Vec::new()).unwrap();
898 assert!(engine.mem_names().is_empty());
899 match engine.list_entities("missing") {
900 Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
901 other => panic!("expected UnknownMem, got {other:?}"),
902 }
903 }
904
905 #[test]
906 fn duplicate_mem_names_rejected_at_construction() {
907 let tmp = TempDir::new().unwrap();
908 let writer1: Box<dyn MemBackend> =
909 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
910 let writer2: Box<dyn MemBackend> =
911 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
912 let err = Engine::from_mounts(vec![
913 (folder_mount("specs", tmp.path().to_path_buf()), writer1),
914 (folder_mount("specs", tmp.path().to_path_buf()), writer2),
915 ])
916 .unwrap_err();
917 assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
918 }
919
920 #[test]
921 fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
922 let tmp = TempDir::new().unwrap();
928 let mem_dir = tmp.path().to_path_buf();
929 let body =
930 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
931 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
932
933 let writer = FilesystemMemWriter::new(mem_dir.clone());
934 let engine = Engine::from_mounts(vec![(
935 folder_mount("specs", mem_dir),
936 Box::new(writer) as Box<dyn MemBackend>,
937 )])
938 .unwrap();
939
940 let warnings = engine.load_warnings();
941 assert!(
942 warnings
943 .iter()
944 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
945 "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
946 );
947 }
948
949 #[test]
957 fn generation_behind_hint_fires_for_old_builtin_pin_only() {
958 let tmp = TempDir::new().unwrap();
960 let mem_dir = tmp.path().to_path_buf();
961 let writer = FilesystemMemWriter::new(mem_dir.clone());
962 let mut engine = Engine::from_mounts(vec![(
963 folder_mount("specs", mem_dir),
964 Box::new(writer) as Box<dyn MemBackend>,
965 )])
966 .unwrap();
967 let behind: Vec<_> = engine
968 .load_warnings()
969 .iter()
970 .filter_map(|w| match w {
971 WarningHint::SchemaGenerationsBehind {
972 mem,
973 pinned,
974 newest,
975 } => Some((mem.clone(), pinned.clone(), newest.clone())),
976 _ => None,
977 })
978 .collect();
979 assert_eq!(
980 behind,
981 vec![(
982 "specs".to_string(),
983 "default@1.0.0".to_string(),
984 "1.3.0".to_string()
985 )],
986 "old built-in pin must surface the generation-behind hint"
987 );
988 assert!(
989 engine
990 .health()
991 .warnings
992 .iter()
993 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
994 "the hint rides health without an include gate"
995 );
996 engine
998 .create_entity_with_ctx(
999 crate::engine::CreateEntityArgs {
1000 anchors: Vec::new(),
1001 mem: "specs".to_string(),
1002 title: "Still writable".to_string(),
1003 entity_type: "spec".to_string(),
1004 sections: indexmap::IndexMap::from_iter([
1005 ("identity".to_string(), "i".to_string()),
1006 ("purpose".to_string(), "p".to_string()),
1007 ]),
1008 metadata: indexmap::IndexMap::new(),
1009 relations: Vec::new(),
1010 dry_run: false,
1011 },
1012 &crate::vcs::CommitContext::internal(),
1013 )
1014 .expect("generation-behind hint must never block mutations");
1015
1016 let tmp = TempDir::new().unwrap();
1018 let mem_dir = tmp.path().to_path_buf();
1019 let writer = FilesystemMemWriter::new(mem_dir.clone());
1020 let mut mount = folder_mount("specs", mem_dir);
1021 mount.schema = Some("default@1.3.0".parse().unwrap());
1022 let engine =
1023 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1024 assert!(
1025 !engine
1026 .load_warnings()
1027 .iter()
1028 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1029 "newest built-in pin must stay silent: {:?}",
1030 engine.load_warnings()
1031 );
1032 let health_json = serde_json::to_string(&engine.health().warnings).unwrap();
1033 assert!(
1034 !health_json.contains("SCHEMA_GENERATIONS_BEHIND"),
1035 "newest pin: health output carries no generation hint"
1036 );
1037 }
1038
1039 #[test]
1043 fn newest_builtin_version_orders_by_semver_not_string() {
1044 let manifest = |version: &str| {
1045 format!(
1046 r#"name: gen-test
1047version: {version}
1048description: test
1049when_to_use: test
1050types:
1051 - note
1052relationships:
1053 mode: strict
1054 definitions:
1055 - name: _default
1056 description: default
1057 default_weight: 1.0
1058 - name: PART_OF
1059 description: hier
1060 default_weight: 3.0
1061community:
1062 resolution: 1.0
1063 seed: 42
1064"#
1065 )
1066 };
1067 let type_yaml = r#"name: note
1068description: test
1069when_to_use: test
1070sections:
1071 - key: body
1072 heading: Body
1073 required: true
1074 search_weight: 10.0
1075 catch_all: true
1076metadata_fields: []
1077title_weight: 1.0
1078text_fields: [body]
1079hierarchy_relationship: PART_OF
1080no_self_loop_relationships: []
1081updatable_fields: [title, body]
1082health_required_fields: [body]
1083staleness_threshold_days: 30
1084write_rules: []
1085"#;
1086 let types = vec![("note".to_string(), type_yaml.to_string())];
1087 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = ["0.9.0", "0.10.0", "0.2.0"]
1088 .iter()
1089 .map(|v| {
1090 std::sync::Arc::new(
1091 memstead_schema::load_schema_from_memory(&manifest(v), &types)
1092 .expect("fixture schema loads"),
1093 )
1094 })
1095 .collect();
1096 let newest = super::newest_builtin_version("gen-test", &catalogue)
1097 .expect("name present in catalogue");
1098 assert_eq!(newest.to_string(), "0.10.0", "semver, not string, ordering");
1099 assert!(super::newest_builtin_version("absent", &catalogue).is_none());
1100 }
1101
1102 #[test]
1108 fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
1109 let tmp = TempDir::new().unwrap();
1110 let mem_dir = tmp.path().to_path_buf();
1111 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1114 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1115 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1116 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1117
1118 let writer = FilesystemMemWriter::new(mem_dir.clone());
1119 let engine = Engine::from_mounts(vec![(
1120 folder_mount("specs", mem_dir),
1121 Box::new(writer) as Box<dyn MemBackend>,
1122 )])
1123 .unwrap();
1124
1125 let source_id = crate::entity::EntityId::new("specs", "source");
1126 let target_id = crate::entity::EntityId::new("specs", "target");
1127 let source_entity = engine.get_entity(&source_id).expect("source loaded");
1128 assert!(
1131 source_entity.relationships.is_empty(),
1132 "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
1133 source_entity.relationships,
1134 );
1135 let outgoing: Vec<_> = engine
1137 .store()
1138 .outgoing(&source_id)
1139 .iter()
1140 .filter(|e| e.rel_type == "MADE_UP_TYPE")
1141 .collect();
1142 assert!(
1143 outgoing.is_empty(),
1144 "MADE_UP_TYPE edge must be dropped from the store"
1145 );
1146 let parsed_invalid: Vec<_> = engine
1148 .load_warnings()
1149 .iter()
1150 .filter_map(|w| match w {
1151 WarningHint::ParsedRelationInvalid {
1152 entity_id,
1153 rel_type,
1154 target,
1155 reason,
1156 origin,
1157 recovery,
1158 } => Some((
1159 entity_id.clone(),
1160 rel_type.clone(),
1161 target.clone(),
1162 reason.clone(),
1163 origin.clone(),
1164 recovery.clone(),
1165 )),
1166 _ => None,
1167 })
1168 .collect();
1169 assert_eq!(
1170 parsed_invalid.len(),
1171 1,
1172 "expected one warning, got {parsed_invalid:?}"
1173 );
1174 assert_eq!(parsed_invalid[0].0, source_id);
1175 assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
1176 assert_eq!(parsed_invalid[0].2, target_id);
1177 assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
1178 assert_eq!(parsed_invalid[0].4, "writable");
1179 let recovery = parsed_invalid[0]
1181 .5
1182 .as_ref()
1183 .expect("writable-origin warning must carry recovery");
1184 assert_eq!(
1185 recovery.kind,
1186 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1187 );
1188 assert_eq!(recovery.source_id, parsed_invalid[0].0);
1189 assert_eq!(recovery.target_id, parsed_invalid[0].2);
1190 assert_eq!(recovery.rel_type, parsed_invalid[0].1);
1191 }
1192
1193 #[test]
1201 fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
1202 let tmp = TempDir::new().unwrap();
1203 let mem_dir = tmp.path().to_path_buf();
1204 let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
1208 let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
1209 std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
1210 std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
1211
1212 let writer = FilesystemMemWriter::new(mem_dir.clone());
1213 let engine = Engine::from_mounts(vec![(
1214 folder_mount("specs", mem_dir),
1215 Box::new(writer) as Box<dyn MemBackend>,
1216 )])
1217 .unwrap();
1218
1219 let alpha_id = crate::entity::EntityId::new("specs", "alpha");
1220 let beta_id = crate::entity::EntityId::new("specs", "beta");
1221
1222 assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
1225 assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
1226
1227 let surviving: Vec<_> = engine
1230 .store()
1231 .all_entities()
1232 .flat_map(|e| {
1233 engine
1234 .store()
1235 .outgoing(&e.id)
1236 .iter()
1237 .filter(|edge| edge.rel_type == "PART_OF")
1238 .map(|edge| (e.id.clone(), edge.target.clone()))
1239 .collect::<Vec<_>>()
1240 })
1241 .collect();
1242 assert_eq!(
1243 surviving.len(),
1244 1,
1245 "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
1246 );
1247
1248 let cycle_drops: Vec<_> = engine
1251 .load_warnings()
1252 .iter()
1253 .filter_map(|w| match w {
1254 WarningHint::ParsedRelationInvalid {
1255 entity_id,
1256 rel_type,
1257 target,
1258 reason,
1259 ..
1260 } if reason == "cycle" => {
1261 Some((entity_id.clone(), rel_type.clone(), target.clone()))
1262 }
1263 _ => None,
1264 })
1265 .collect();
1266 assert_eq!(
1267 cycle_drops.len(),
1268 1,
1269 "exactly one cycle warning must fire, got {cycle_drops:?}",
1270 );
1271 let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
1273 assert_eq!(dropped_rel_type, "PART_OF");
1274 let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
1275 let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
1276 assert!(
1277 is_alpha_to_beta || is_beta_to_alpha,
1278 "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
1279 );
1280 assert_ne!(
1282 (&surviving[0].0, &surviving[0].1),
1283 (dropped_from, dropped_to),
1284 "surviving edge must differ from the dropped one",
1285 );
1286 }
1287
1288 #[test]
1296 fn nested_prefix_warning_exempts_real_cross_mem_target_loaded_later() {
1297 let tmp = TempDir::new().unwrap();
1298 let project_dir = tmp.path().join("project");
1299 let registry_dir = tmp.path().join("registry");
1300 std::fs::create_dir_all(&project_dir).unwrap();
1301 std::fs::create_dir_all(®istry_dir).unwrap();
1302
1303 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nReal: [[registry--registry-service]]. Drifted: [[registry--never-created]].\n";
1306 std::fs::write(project_dir.join("source.md"), source).unwrap();
1307
1308 let service = "---\ntype: spec\n---\n# Registry Service\n\n## Identity\n\nA real entity.\n";
1311 std::fs::write(registry_dir.join("registry-service.md"), service).unwrap();
1312
1313 let engine = Engine::from_mounts(vec![
1314 (
1315 folder_mount("project", project_dir.clone()),
1316 Box::new(FilesystemMemWriter::new(project_dir)) as Box<dyn MemBackend>,
1317 ),
1318 (
1319 folder_mount("registry", registry_dir.clone()),
1320 Box::new(FilesystemMemWriter::new(registry_dir)) as Box<dyn MemBackend>,
1321 ),
1322 ])
1323 .unwrap();
1324
1325 let nested: Vec<_> = engine
1326 .load_warnings()
1327 .iter()
1328 .filter_map(|w| match w {
1329 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1330 Some(resolved_id.to_string())
1331 }
1332 _ => None,
1333 })
1334 .collect();
1335 assert!(
1336 !nested.contains(&"registry--registry-service".to_string()),
1337 "a valid cross-mem id resolving to a real entity must not warn, got {nested:?}",
1338 );
1339 assert!(
1340 nested.contains(&"registry--never-created".to_string()),
1341 "a genuinely unresolved nested-prefix link must keep its warning, got {nested:?}",
1342 );
1343 }
1344
1345 #[test]
1355 fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
1356 use crate::engine::test_helpers::write_schema_files_with_default_type;
1357
1358 let tmp = TempDir::new().unwrap();
1359 let schemas_dir = tmp.path().join("schemas");
1360 std::fs::create_dir_all(&schemas_dir).unwrap();
1361 let manifest = r#"name: shape-test
1365version: 0.1.0
1366description: shape-constraint schema
1367when_to_use: tests
1368types:
1369 - doc
1370 - actor
1371relationships:
1372 mode: strict
1373 definitions:
1374 - name: OWNS
1375 description: actor owns doc
1376 default_weight: 1.0
1377 source_types: [actor]
1378 target_types: [doc]
1379 - name: _default
1380 description: fallback
1381 default_weight: 1.0
1382community:
1383 resolution: 1.0
1384 seed: 42
1385"#;
1386 write_schema_files_with_default_type(
1387 &schemas_dir,
1388 "shape-test",
1389 manifest,
1390 &["doc", "actor"],
1391 );
1392
1393 let mem_dir = tmp.path().join("mem");
1394 std::fs::create_dir_all(&mem_dir).unwrap();
1395 let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
1399 let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
1400 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1401 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1402
1403 let writer = FilesystemMemWriter::new(mem_dir.clone());
1404 let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
1405 let mount = Mount {
1406 mem: "specs".to_string(),
1407 schema: Some(pin),
1408 storage: MountStorage::Folder { path: mem_dir },
1409 capability: MountCapability::Write,
1410 lifecycle: MountLifecycle::Eager,
1411 cross_linkable: true,
1412 migration_target: None,
1413 };
1414 let engine = Engine::from_mounts_with_schemas_dir(
1415 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1416 Some(&schemas_dir),
1417 )
1418 .unwrap();
1419
1420 let source_id = crate::entity::EntityId::new("specs", "source");
1421 let target_id = crate::entity::EntityId::new("specs", "target");
1422
1423 let shape_drops: Vec<_> = engine
1424 .load_warnings()
1425 .iter()
1426 .filter_map(|w| match w {
1427 WarningHint::ParsedRelationInvalid {
1428 entity_id,
1429 rel_type,
1430 target,
1431 reason,
1432 origin,
1433 recovery,
1434 } if reason == "shape" => Some((
1435 entity_id.clone(),
1436 rel_type.clone(),
1437 target.clone(),
1438 origin.clone(),
1439 recovery.clone(),
1440 )),
1441 _ => None,
1442 })
1443 .collect();
1444 assert_eq!(
1445 shape_drops.len(),
1446 1,
1447 "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
1448 engine.load_warnings(),
1449 );
1450 let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
1451 shape_drops.into_iter().next().unwrap();
1452 assert_eq!(drop_from, source_id);
1453 assert_eq!(drop_type, "OWNS");
1454 assert_eq!(drop_to, target_id);
1455 assert_eq!(drop_origin, "writable");
1456 let recovery = drop_recovery.expect("writable origin must carry recovery");
1459 assert_eq!(
1460 recovery.kind,
1461 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1462 );
1463 assert_eq!(recovery.source_id, source_id);
1464 assert_eq!(recovery.target_id, target_id);
1465 assert_eq!(recovery.rel_type, "OWNS");
1466 }
1467
1468 #[test]
1474 fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
1475 let tmp = TempDir::new().unwrap();
1476 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1480 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1481 let archive_path = build_archive(
1482 tmp.path(),
1483 "ext",
1484 &[
1485 ("target.md", target.as_bytes()),
1486 ("source.md", source.as_bytes()),
1487 ],
1488 );
1489
1490 let engine = Engine::from_mounts(vec![(
1491 archive_mount("external", archive_path.clone()),
1492 Box::new(ArchiveBackend::new(archive_path)),
1493 )])
1494 .unwrap();
1495
1496 let invalid: Vec<_> = engine
1497 .load_warnings()
1498 .iter()
1499 .filter_map(|w| match w {
1500 WarningHint::ParsedRelationInvalid {
1501 rel_type,
1502 reason,
1503 origin,
1504 recovery,
1505 ..
1506 } => Some((
1507 rel_type.clone(),
1508 reason.clone(),
1509 origin.clone(),
1510 recovery.clone(),
1511 )),
1512 _ => None,
1513 })
1514 .collect();
1515 assert_eq!(
1516 invalid.len(),
1517 1,
1518 "expected one parse-time drop on the readonly mount, got {invalid:?}",
1519 );
1520 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1521 assert_eq!(invalid[0].1, "unknown_rel_type");
1522 assert_eq!(invalid[0].2, "readonly");
1523 assert!(
1524 invalid[0].3.is_none(),
1525 "readonly-origin warning must omit the recovery hint, got {:?}",
1526 invalid[0].3,
1527 );
1528 }
1529
1530 #[test]
1531 fn load_on_init_populates_store_from_folder_mount() {
1532 let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1535
1536 let tmp = TempDir::new().unwrap();
1537 let mem_dir = tmp.path().to_path_buf();
1538 let writer = FilesystemMemWriter::new(mem_dir.clone());
1539 <FilesystemMemWriter as MemWriter>::write_entity(
1540 &writer,
1541 Path::new("hello.md"),
1542 body.as_bytes(),
1543 )
1544 .unwrap();
1545 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1546 .unwrap();
1547
1548 let engine = Engine::from_mounts(vec![(
1549 folder_mount("specs", mem_dir),
1550 Box::new(writer) as Box<dyn MemBackend>,
1551 )])
1552 .unwrap();
1553
1554 assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1556 let id = crate::EntityId::new("specs", "hello");
1557 let entity = engine.get_entity(&id).expect("entity must be present");
1558 assert_eq!(entity.title, "Hello");
1559 assert_eq!(entity.entity_type, "spec");
1560 assert!(engine.load_errors().is_empty());
1561 assert_eq!(engine.schemas().len(), 1);
1563 assert!(engine.schemas().contains_key("specs"));
1564 }
1565
1566 #[test]
1567 fn load_on_init_populates_store_from_archive_mount() {
1568 let body =
1569 "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1570
1571 let tmp = TempDir::new().unwrap();
1572 let archive_path =
1573 build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1574
1575 let engine = Engine::from_mounts(vec![(
1576 archive_mount("external", archive_path.clone()),
1577 Box::new(ArchiveBackend::new(archive_path)),
1578 )])
1579 .unwrap();
1580
1581 let id = crate::EntityId::new("external", "from-archive");
1582 let entity = engine.get_entity(&id).expect("entity must be present");
1583 assert_eq!(entity.title, "From Archive");
1584 assert!(engine.load_errors().is_empty());
1585 }
1586
1587 #[test]
1588 fn load_on_init_populates_store_from_heterogeneous_mounts() {
1589 let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1590 let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1591
1592 let tmp = TempDir::new().unwrap();
1593
1594 let folder_dir = tmp.path().join("folder-mem");
1595 std::fs::create_dir_all(&folder_dir).unwrap();
1596 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1597 <FilesystemMemWriter as MemWriter>::write_entity(
1598 &folder_writer,
1599 Path::new("local.md"),
1600 folder_body.as_bytes(),
1601 )
1602 .unwrap();
1603 <FilesystemMemWriter as MemWriter>::commit(
1604 &folder_writer,
1605 "seed",
1606 &CommitContext::internal(),
1607 )
1608 .unwrap();
1609
1610 let archive_path = build_archive(
1611 tmp.path(),
1612 "external",
1613 &[("external.md", archive_body.as_bytes())],
1614 );
1615
1616 let engine = Engine::from_mounts(vec![
1617 (
1618 folder_mount("local", folder_dir),
1619 Box::new(folder_writer) as Box<dyn MemBackend>,
1620 ),
1621 (
1622 archive_mount("external", archive_path.clone()),
1623 Box::new(ArchiveBackend::new(archive_path)),
1624 ),
1625 ])
1626 .unwrap();
1627
1628 assert_eq!(engine.store().len(), 2);
1630 assert!(
1631 engine
1632 .get_entity(&crate::EntityId::new("local", "local"))
1633 .is_some()
1634 );
1635 assert!(
1636 engine
1637 .get_entity(&crate::EntityId::new("external", "external"))
1638 .is_some()
1639 );
1640 }
1641
1642 #[test]
1643 fn load_on_init_collects_per_file_parse_errors_without_failing() {
1644 let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1648 let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1649
1650 let tmp = TempDir::new().unwrap();
1651 let mem_dir = tmp.path().to_path_buf();
1652 let writer = FilesystemMemWriter::new(mem_dir.clone());
1653 <FilesystemMemWriter as MemWriter>::write_entity(
1654 &writer,
1655 Path::new("good.md"),
1656 good.as_bytes(),
1657 )
1658 .unwrap();
1659 <FilesystemMemWriter as MemWriter>::write_entity(
1660 &writer,
1661 Path::new("bad.md"),
1662 bad.as_bytes(),
1663 )
1664 .unwrap();
1665 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1666 .unwrap();
1667
1668 let engine = Engine::from_mounts(vec![(
1669 folder_mount("specs", mem_dir),
1670 Box::new(writer) as Box<dyn MemBackend>,
1671 )])
1672 .unwrap();
1673
1674 assert!(
1676 engine
1677 .get_entity(&crate::EntityId::new("specs", "good"))
1678 .is_some(),
1679 "good.md must parse and reach the store"
1680 );
1681 let bad_known_to_engine = engine
1686 .get_entity(&crate::EntityId::new("specs", "bad"))
1687 .is_some()
1688 || !engine.load_errors().is_empty();
1689 assert!(
1690 bad_known_to_engine,
1691 "bad.md must either parse or surface in load_errors"
1692 );
1693 }
1694
1695 #[test]
1696 fn empty_mount_list_yields_empty_store() {
1697 let engine = Engine::from_mounts(Vec::new()).unwrap();
1698 assert!(engine.store().is_empty());
1699 assert!(engine.schemas().is_empty());
1700 assert!(engine.load_errors().is_empty());
1701 }
1702
1703 #[test]
1706 fn from_workspace_root_errors_for_empty_layout() {
1707 let tmp = TempDir::new().unwrap();
1708 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1709 match err {
1710 crate::BootError::NotInitialised(p) => {
1711 assert_eq!(p, tmp.path());
1712 }
1713 other => panic!("expected NotInitialised, got {other:?}"),
1714 }
1715 }
1716
1717 #[test]
1718 fn from_workspace_root_loads_new_two_layer_layout() {
1719 let tmp = TempDir::new().unwrap();
1720 let mem_dir = tmp.path().join("mem");
1721 std::fs::create_dir_all(&mem_dir).unwrap();
1722 std::fs::write(
1723 mem_dir.join("hello.md"),
1724 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1725 )
1726 .unwrap();
1727
1728 let memstead = tmp.path().join(".memstead");
1729 std::fs::create_dir_all(&memstead).unwrap();
1730 std::fs::write(
1731 memstead.join("workspace.toml"),
1732 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1733 )
1734 .unwrap();
1735 use crate::workspace_store::WorkspaceStoreAdapter;
1738 let store = crate::FileWorkspaceStore::new();
1739 store
1740 .save_state(
1741 tmp.path(),
1742 &crate::workspace::Workspace {
1743 mounts: vec![folder_mount("specs", mem_dir)],
1744 settings: crate::workspace::WorkspaceSettings::default(),
1745 },
1746 )
1747 .unwrap();
1748
1749 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1750 assert_eq!(engine.mem_names(), vec!["specs"]);
1751 let entity = engine
1752 .get_entity(&crate::EntityId::new("specs", "hello"))
1753 .expect("seeded entity must load through from_workspace_root");
1754 assert_eq!(entity.title, "Hello");
1755 }
1756
1757 #[test]
1762 fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1763 use crate::pipeline::{MediumType, Projection};
1764 let tmp = TempDir::new().unwrap();
1765 let mem_dir = tmp.path().join("mem");
1766 std::fs::create_dir_all(&mem_dir).unwrap();
1767
1768 let memstead = tmp.path().join(".memstead");
1769 std::fs::create_dir_all(&memstead).unwrap();
1770 std::fs::write(
1771 memstead.join("workspace.toml"),
1772 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1773 )
1774 .unwrap();
1775 use crate::workspace_store::WorkspaceStoreAdapter;
1776 crate::FileWorkspaceStore::new()
1777 .save_state(
1778 tmp.path(),
1779 &crate::workspace::Workspace {
1780 mounts: vec![folder_mount("specs", mem_dir)],
1781 settings: crate::workspace::WorkspaceSettings::default(),
1782 },
1783 )
1784 .unwrap();
1785
1786 crate::pipeline_store::write_binding(
1788 tmp.path(),
1789 "specs",
1790 "graph",
1791 &sample_v2_binding("specs"),
1792 )
1793 .unwrap();
1794
1795 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1796 let pc = engine.pipeline_configs();
1797 assert_eq!(pc.bindings.len(), 1, "one binding enumerated");
1798 assert_eq!(pc.bindings[0].mem, "specs");
1799 assert_eq!(pc.bindings[0].name, "graph");
1800 assert_eq!(pc.bindings[0].config.destination_mem, "specs");
1801 assert_eq!(
1802 pc.bindings[0].config.sources[0].medium_type,
1803 MediumType::Codebase
1804 );
1805
1806 crate::pipeline_store::write_projection(
1813 tmp.path(),
1814 "specs",
1815 "legacy",
1816 &Projection {
1817 intent: None,
1818 source_facets: vec!["view".to_string()],
1819 reference_mems: Vec::new(),
1820 destination_mem: "specs".to_string(),
1821 rules: None,
1822 },
1823 )
1824 .unwrap();
1825 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1826 let pc = engine.pipeline_configs();
1827 assert_eq!(pc.bindings.len(), 1, "the healthy binding still serves");
1828 assert_eq!(pc.quarantined.len(), 1, "the legacy one quarantines");
1829 assert_eq!(pc.quarantined[0].name, "legacy");
1830 assert_eq!(pc.quarantined[0].reason_code, "PROJECTION_STORE_LEGACY");
1831 assert!(
1832 pc.quarantined[0]
1833 .reason_message
1834 .contains("memstead projection migrate"),
1835 "quarantine reason names the migrate command, got: {}",
1836 pc.quarantined[0].reason_message
1837 );
1838 }
1839
1840 fn sample_v2_binding(dest: &str) -> crate::binding::Binding {
1843 v2_binding_with_pointer(dest, "..")
1844 }
1845
1846 fn v2_binding_with_pointer(dest: &str, pointer: &str) -> crate::binding::Binding {
1847 use crate::binding::{BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations};
1848 use crate::pipeline::{IngestTrigger, MediumType, Source};
1849 Binding {
1850 version: BINDING_VERSION,
1851 intent: None,
1852 sources: vec![Source {
1853 name: "src".to_string(),
1854 medium_type: MediumType::Codebase,
1855 pointer: pointer.to_string(),
1856 change_detection: None,
1857 scope: Vec::new(),
1858 engagement: None,
1859 preparation: None,
1860 }],
1861 reference_mems: Vec::new(),
1862 destination_mem: dest.to_string(),
1863 deny_paths: Vec::new(),
1864 coverage_semantics: None,
1865 rules: None,
1866 prune: None,
1867 operations: Operations {
1868 build: Some(BuildOperation {
1869 mode: BuildMode::Discovery,
1870 trigger: IngestTrigger::Loop,
1871 batch_size: 10,
1872 post_actions: None,
1873 }),
1874 sync: None,
1875 verify: None,
1876 },
1877 }
1878 }
1879
1880 #[test]
1888 fn entity_anchors_resolve_live_state_for_path_medium() {
1889 use crate::anchor::{AnchorInput, AnchorState};
1890 use crate::vcs::Actor;
1891 use crate::workspace_store::WorkspaceStoreAdapter;
1892 use indexmap::IndexMap;
1893
1894 let tmp = TempDir::new().unwrap();
1895 let mem_dir = tmp.path().join("mem");
1896 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1897 std::fs::write(
1898 mem_dir.join(".memstead").join("config.json"),
1899 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1900 )
1901 .unwrap();
1902
1903 let memstead = tmp.path().join(".memstead");
1904 std::fs::create_dir_all(&memstead).unwrap();
1905 std::fs::write(
1906 memstead.join("workspace.toml"),
1907 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1908 )
1909 .unwrap();
1910 crate::FileWorkspaceStore::new()
1911 .save_state(
1912 tmp.path(),
1913 &crate::workspace::Workspace {
1914 mounts: vec![folder_mount("specs", mem_dir.clone())],
1915 settings: crate::workspace::WorkspaceSettings::default(),
1916 },
1917 )
1918 .unwrap();
1919
1920 crate::pipeline_store::write_binding(
1926 tmp.path(),
1927 "specs",
1928 "graph",
1929 &v2_binding_with_pointer("specs", "src"),
1930 )
1931 .unwrap();
1932 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1933 std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
1934 std::fs::write(tmp.path().join("src").join("gone.rs"), "fn gone() {}").unwrap();
1937
1938 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
1939
1940 let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
1941 artifact: Some(artifact.to_string()),
1942 grain: Some("file".to_string()),
1943 class: Some(class.to_string()),
1944 hash: hash.map(str::to_string),
1945 hash_stability: Some("stable".to_string()),
1946 ..Default::default()
1947 };
1948 let mut sections = IndexMap::new();
1949 sections.insert("identity".to_string(), "Covers src.".to_string());
1950 sections.insert("purpose".to_string(), "Track sources.".to_string());
1951 let present_hash = crate::anchor::prepared_content_hash(
1954 &std::fs::read(tmp.path().join("src").join("present.rs")).unwrap(),
1955 );
1956 let created = engine
1957 .create_entity(
1958 crate::CreateEntityArgs {
1959 mem: "specs".to_string(),
1960 title: "Covers".to_string(),
1961 entity_type: "spec".to_string(),
1962 sections,
1963 metadata: IndexMap::new(),
1964 relations: Vec::new(),
1965 anchors: vec![
1966 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")), ],
1971 dry_run: false,
1972 },
1973 Actor::Agent,
1974 None,
1975 None,
1976 )
1977 .unwrap();
1978
1979 std::fs::remove_file(tmp.path().join("src").join("gone.rs")).unwrap();
1980 let resolved = engine.entity_anchors_resolved(&created.id);
1981 assert_eq!(resolved.len(), 4);
1982 let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
1983 resolved
1984 .iter()
1985 .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
1986 .and_then(|r| r.state)
1987 };
1988 assert_eq!(
1989 state_of(
1990 "src/present.rs",
1991 crate::anchor::AnchorProvenanceClass::Anchored
1992 ),
1993 Some(AnchorState::Resolves),
1994 "recorded hash matches the observed prepared form → resolves"
1995 );
1996 assert_eq!(
1997 state_of(
1998 "src/present.rs",
1999 crate::anchor::AnchorProvenanceClass::Derived
2000 ),
2001 Some(AnchorState::Drifted),
2002 "recorded hash mismatches the observed prepared form on a stable medium → drifted"
2003 );
2004 assert_eq!(
2005 state_of(
2006 "src/present.rs",
2007 crate::anchor::AnchorProvenanceClass::InformedBy
2008 ),
2009 Some(AnchorState::Resolves),
2010 "present non-hash anchor resolves on existence"
2011 );
2012 assert_eq!(
2013 state_of(
2014 "src/gone.rs",
2015 crate::anchor::AnchorProvenanceClass::Anchored
2016 ),
2017 Some(AnchorState::Orphaned),
2018 "absent artifact is orphaned"
2019 );
2020 }
2021
2022 #[test]
2027 fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
2028 use crate::workspace_store::WorkspaceStoreAdapter;
2029
2030 let tmp = TempDir::new().unwrap();
2031 let mem_dir = tmp.path().join("mem");
2032 std::fs::create_dir_all(&mem_dir).unwrap();
2033 let memstead = tmp.path().join(".memstead");
2034 std::fs::create_dir_all(&memstead).unwrap();
2035 std::fs::write(
2036 memstead.join("workspace.toml"),
2037 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2038 )
2039 .unwrap();
2040 crate::FileWorkspaceStore::new()
2041 .save_state(
2042 tmp.path(),
2043 &crate::workspace::Workspace {
2044 mounts: vec![folder_mount("specs", mem_dir)],
2045 settings: crate::workspace::WorkspaceSettings::default(),
2046 },
2047 )
2048 .unwrap();
2049
2050 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2051 assert!(engine.pipeline_configs().bindings.is_empty());
2052
2053 engine
2055 .add_projection_json(
2056 "specs",
2057 "graph",
2058 r#"{
2059 "sources": [{ "name": "src", "type": "codebase", "pointer": "..",
2060 "scope": [{ "path": "**/*.rs", "mode": "allow" }] }],
2061 "destination_mem": "specs"
2062 }"#,
2063 None,
2064 )
2065 .unwrap();
2066 assert_eq!(engine.pipeline_configs().bindings.len(), 1);
2068 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph");
2069 assert_eq!(
2070 engine.pipeline_configs().bindings[0].config.sources[0].name,
2071 "src"
2072 );
2073
2074 let err = engine
2076 .add_projection_json("specs", "bad", "{ not json", None)
2077 .unwrap_err();
2078 assert!(
2079 matches!(
2080 err,
2081 crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
2082 ),
2083 "got {err:?}"
2084 );
2085
2086 engine
2088 .update_projection_json("specs", "graph", r#"{"intent":"i2"}"#, None)
2089 .unwrap();
2090 assert_eq!(
2091 engine.pipeline_configs().bindings[0]
2092 .config
2093 .intent
2094 .as_deref(),
2095 Some("i2")
2096 );
2097
2098 engine
2100 .rename_projection("specs", "graph", "graph2", None)
2101 .unwrap();
2102 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph2");
2103
2104 let json = engine.pipeline_configs_json();
2107 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2108 assert!(parsed.get("mediums").is_none(), "no mediums key: {json}");
2109 assert!(parsed.get("facets").is_none(), "no facets key: {json}");
2110 let bindings = parsed["bindings"].as_array().unwrap();
2111 assert_eq!(bindings.len(), 1);
2112 assert_eq!(bindings[0]["name"], "graph2");
2113 assert_eq!(bindings[0]["config"]["sources"][0]["type"], "codebase");
2114
2115 engine.delete_projection("specs", "graph2", None).unwrap();
2116 assert!(engine.pipeline_configs().bindings.is_empty());
2117 }
2118
2119 #[test]
2125 fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
2126 use crate::engine::test_helpers::write_schema_files_with_default_type;
2127
2128 let tmp = TempDir::new().unwrap();
2129 let mem_dir = tmp.path().join("mem");
2130 std::fs::create_dir_all(&mem_dir).unwrap();
2131
2132 let authored_dir = tmp.path().join(".memstead").join("schemas");
2134 let manifest = r#"name: authored
2135version: 0.1.0
2136description: an authored-in-workspace test schema
2137when_to_use: tests
2138types:
2139 - doc
2140relationships:
2141 mode: strict
2142 definitions:
2143 - name: _default
2144 description: fallback
2145 default_weight: 1.0
2146community:
2147 resolution: 1.0
2148 seed: 42
2149"#;
2150 write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
2151
2152 let memstead = tmp.path().join(".memstead");
2154 std::fs::create_dir_all(&memstead).unwrap();
2155 std::fs::write(
2156 memstead.join("workspace.toml"),
2157 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2158 )
2159 .unwrap();
2160 let mount = Mount {
2161 mem: "specs".to_string(),
2162 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2163 storage: MountStorage::Folder { path: mem_dir },
2164 capability: MountCapability::Write,
2165 lifecycle: MountLifecycle::Eager,
2166 cross_linkable: true,
2167 migration_target: None,
2168 };
2169 use crate::workspace_store::WorkspaceStoreAdapter;
2170 crate::FileWorkspaceStore::new()
2171 .save_state(
2172 tmp.path(),
2173 &crate::workspace::Workspace {
2174 mounts: vec![mount],
2175 settings: crate::workspace::WorkspaceSettings::default(),
2176 },
2177 )
2178 .unwrap();
2179
2180 let engine = Engine::from_workspace_root(tmp.path())
2183 .expect("authored schema at .memstead/schemas/ must resolve at boot");
2184 assert_eq!(engine.mem_names(), vec!["specs"]);
2185 }
2186
2187 #[test]
2193 fn health_reports_authoring_drift_for_stamped_schemas_only() {
2194 use crate::engine::test_helpers::write_schema_files_with_default_type;
2195
2196 let tmp = TempDir::new().unwrap();
2197 let mem_dir = tmp.path().join("mem");
2198 std::fs::create_dir_all(&mem_dir).unwrap();
2199 let manifest = r#"name: authored
2200version: 0.1.0
2201description: an authored-in-workspace test schema
2202when_to_use: tests
2203types:
2204 - doc
2205relationships:
2206 mode: strict
2207 definitions:
2208 - name: _default
2209 description: fallback
2210 default_weight: 1.0
2211community:
2212 resolution: 1.0
2213 seed: 42
2214"#;
2215 let sealed_root = tmp.path().join(".memstead").join("schemas");
2218 write_schema_files_with_default_type(&sealed_root, "authored@0.1.0", manifest, &["doc"]);
2219 let author_root = tmp.path().join("author");
2220 write_schema_files_with_default_type(&author_root, "authored@0.1.0", manifest, &["doc"]);
2221 let authoring_dir = author_root.join("authored@0.1.0");
2222 let sealed_dir = sealed_root.join("authored@0.1.0");
2223 let stamp_path = sealed_dir.join(memstead_schema::INSTALL_PROVENANCE_FILE);
2225 std::fs::write(
2226 &stamp_path,
2227 serde_json::to_vec_pretty(&serde_json::json!({
2228 "authoring_path": authoring_dir.display().to_string(),
2229 }))
2230 .unwrap(),
2231 )
2232 .unwrap();
2233
2234 let memstead = tmp.path().join(".memstead");
2235 std::fs::write(
2236 memstead.join("workspace.toml"),
2237 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2238 )
2239 .unwrap();
2240 let mount = Mount {
2241 mem: "specs".to_string(),
2242 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2243 storage: MountStorage::Folder { path: mem_dir },
2244 capability: MountCapability::Write,
2245 lifecycle: MountLifecycle::Eager,
2246 cross_linkable: true,
2247 migration_target: None,
2248 };
2249 use crate::workspace_store::WorkspaceStoreAdapter;
2250 crate::FileWorkspaceStore::new()
2251 .save_state(
2252 tmp.path(),
2253 &crate::workspace::Workspace {
2254 mounts: vec![mount],
2255 settings: crate::workspace::WorkspaceSettings::default(),
2256 },
2257 )
2258 .unwrap();
2259 let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
2260 let drift_codes = |e: &Engine| -> Vec<String> {
2261 e.health()
2262 .warnings
2263 .iter()
2264 .filter(|w| w.code().starts_with("SCHEMA_AUTHORING_SOURCE_"))
2265 .map(|w| w.code().to_string())
2266 .collect()
2267 };
2268
2269 let sealed_before = std::fs::read(sealed_dir.join("schema.yaml")).unwrap();
2272 let author_before = std::fs::read(authoring_dir.join("schema.yaml")).unwrap();
2273 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2274 assert_eq!(
2275 std::fs::read(sealed_dir.join("schema.yaml")).unwrap(),
2276 sealed_before,
2277 "health must not touch the sealed copy"
2278 );
2279 assert_eq!(
2280 std::fs::read(authoring_dir.join("schema.yaml")).unwrap(),
2281 author_before,
2282 "health must not touch the authoring copy"
2283 );
2284
2285 std::fs::write(
2289 authoring_dir.join("schema.yaml"),
2290 format!(
2291 "# yaml-language-server: $schema=../../.memstead/meta-schemas/schema-manifest.json\n# cosmetic comment\n{manifest}"
2292 ),
2293 )
2294 .unwrap();
2295 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2296
2297 std::fs::write(
2300 authoring_dir.join("schema.yaml"),
2301 manifest.replace(
2302 "an authored-in-workspace test schema",
2303 "a semantically different description",
2304 ),
2305 )
2306 .unwrap();
2307 let warnings = engine.health().warnings;
2308 let diverged = warnings
2309 .iter()
2310 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED")
2311 .expect("semantic change must surface as DIVERGED");
2312 let d = serde_json::to_value(diverged).unwrap();
2313 assert_eq!(d["details"]["schema_ref"], "authored@0.1.0");
2314 assert_eq!(d["details"]["mems"], serde_json::json!(["specs"]));
2315
2316 std::fs::remove_dir_all(&authoring_dir).unwrap();
2318 let warnings = engine.health().warnings;
2319 let missing = warnings
2320 .iter()
2321 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_MISSING")
2322 .expect("vanished authoring package must surface as MISSING");
2323 let m = serde_json::to_value(missing).unwrap();
2324 assert_eq!(m["details"]["schema_ref"], "authored@0.1.0");
2325 assert_eq!(m["details"]["mems"], serde_json::json!(["specs"]));
2326 assert_eq!(
2327 m["details"]["stamped_path"],
2328 authoring_dir.display().to_string()
2329 );
2330 assert!(
2331 !warnings
2332 .iter()
2333 .any(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED"),
2334 "missing and diverged are distinct findings"
2335 );
2336
2337 std::fs::remove_file(&stamp_path).unwrap();
2339 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2340 }
2341
2342 #[test]
2347 fn full_refresh_is_additive_and_reports_skipped_removals() {
2348 use crate::engine::test_helpers::write_schema_files_with_default_type;
2349 use crate::workspace_store::WorkspaceStoreAdapter;
2350
2351 let tmp = TempDir::new().unwrap();
2352 let root = tmp.path();
2353 let mem_a = root.join("mem-a");
2354 std::fs::create_dir_all(&mem_a).unwrap();
2355 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2356 std::fs::write(
2357 root.join(".memstead").join("workspace.toml"),
2358 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2359 )
2360 .unwrap();
2361 let mount = |mem: &str, dir: &Path, schema: &str, version: semver::Version| Mount {
2362 mem: mem.to_string(),
2363 schema: Some(SchemaRef::new(schema, version)),
2364 storage: MountStorage::Folder {
2365 path: dir.to_path_buf(),
2366 },
2367 capability: MountCapability::Write,
2368 lifecycle: MountLifecycle::Eager,
2369 cross_linkable: true,
2370 migration_target: None,
2371 };
2372 let save = |mounts: Vec<Mount>| {
2373 crate::FileWorkspaceStore::new()
2374 .save_state(
2375 root,
2376 &crate::workspace::Workspace {
2377 mounts,
2378 settings: crate::workspace::WorkspaceSettings::default(),
2379 },
2380 )
2381 .unwrap();
2382 };
2383 save(vec![mount(
2384 "specs",
2385 &mem_a,
2386 "default",
2387 semver::Version::new(1, 0, 0),
2388 )]);
2389 let mut engine = Engine::from_workspace_root(root).expect("workspace boots");
2390 assert_eq!(engine.mem_names(), vec!["specs"]);
2391
2392 let manifest = r#"name: authored
2395version: 0.1.0
2396description: an out-of-band installed schema
2397when_to_use: tests
2398types:
2399 - doc
2400relationships:
2401 mode: strict
2402 definitions:
2403 - name: _default
2404 description: fallback
2405 default_weight: 1.0
2406community:
2407 resolution: 1.0
2408 seed: 42
2409"#;
2410 write_schema_files_with_default_type(
2411 &root.join(".memstead").join("schemas"),
2412 "authored@0.1.0",
2413 manifest,
2414 &["doc"],
2415 );
2416 let mem_b = root.join("mem-b");
2417 std::fs::create_dir_all(&mem_b).unwrap();
2418 save(vec![
2419 mount("specs", &mem_a, "default", semver::Version::new(1, 0, 0)),
2420 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2421 ]);
2422
2423 let (actor, client) = cli_actor();
2426 let mut pre = crate::engine::test_helpers::empty_create_args("notes", "Too Early");
2427 pre.entity_type = "doc".to_string();
2428 pre.sections = indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2429 let err = engine
2430 .create_entity(pre.clone(), actor, Some(&client), None)
2431 .unwrap_err();
2432 assert_eq!(err.code(), "UNKNOWN_MEM", "{err:?}");
2433 assert!(
2434 !engine
2435 .workspace_schemas()
2436 .iter()
2437 .any(|s| s.id().0 == "authored"),
2438 "schema catalogue is fixed pre-refresh"
2439 );
2440
2441 let report = engine.full_refresh();
2443 assert_eq!(report.schemas_added, vec!["authored@0.1.0".to_string()]);
2444 assert_eq!(report.mems_mounted, vec!["notes".to_string()]);
2445 assert!(report.schema_removals_skipped.is_empty(), "{report:?}");
2446 assert!(report.mem_removals_skipped.is_empty(), "{report:?}");
2447 assert!(report.failures.is_empty(), "{report:?}");
2448 engine
2449 .create_entity(pre, actor, Some(&client), None)
2450 .expect("newly mounted mem accepts writes after the refresh");
2451
2452 std::fs::remove_dir_all(
2455 root.join(".memstead")
2456 .join("schemas")
2457 .join("authored@0.1.0"),
2458 )
2459 .unwrap();
2460 save(vec![mount(
2461 "notes",
2462 &mem_b,
2463 "authored",
2464 semver::Version::new(0, 1, 0),
2465 )]);
2466 let report = engine.full_refresh();
2467 assert_eq!(report.mem_removals_skipped, vec!["specs".to_string()]);
2468 assert_eq!(
2469 report.schema_removals_skipped,
2470 vec!["authored@0.1.0".to_string()]
2471 );
2472 assert!(report.schemas_added.is_empty());
2473 assert!(report.mems_mounted.is_empty());
2474 engine
2477 .create_entity(
2478 crate::engine::test_helpers::empty_create_args("specs", "Still Here"),
2479 actor,
2480 Some(&client),
2481 None,
2482 )
2483 .expect("skipped-removal mem stays writable");
2484 let mut into_notes =
2485 crate::engine::test_helpers::empty_create_args("notes", "Still Resolvable");
2486 into_notes.entity_type = "doc".to_string();
2487 into_notes.sections =
2488 indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2489 engine
2490 .create_entity(into_notes, actor, Some(&client), None)
2491 .expect("removed-from-source schema stays resolvable");
2492
2493 let broken = root.join("broken-mem");
2496 std::fs::write(&broken, b"not a directory").unwrap();
2497 save(vec![
2498 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2499 mount("broken", &broken, "default", semver::Version::new(1, 0, 0)),
2500 ]);
2501 let report = engine.full_refresh();
2502 assert!(
2503 report.failures.iter().any(|f| f.item == "mount:broken"),
2504 "failed mount must be reported per-item: {report:?}"
2505 );
2506 assert!(
2507 !report.mems_mounted.contains(&"broken".to_string()),
2508 "a failed mount never surfaces as newly available"
2509 );
2510 assert!(
2511 engine
2512 .get_entity(&crate::EntityId::new("broken", "anything"))
2513 .is_none()
2514 && engine.mem_names().contains(&"notes"),
2515 "other mounts unaffected"
2516 );
2517 }
2518
2519 #[test]
2520 fn from_workspace_root_propagates_mem_management_settings() {
2521 let tmp = TempDir::new().unwrap();
2526 let mem_dir = tmp.path().join("mem");
2527 std::fs::create_dir_all(&mem_dir).unwrap();
2528
2529 let memstead = tmp.path().join(".memstead");
2530 std::fs::create_dir_all(&memstead).unwrap();
2531 std::fs::write(
2532 memstead.join("workspace.toml"),
2533 r#"format = "memstead-git-branch-2"
2534
2535[persistence_adapter]
2536name = "file-two-layer"
2537
2538[[mem_management.create]]
2539pattern = "exec-*"
2540schemas = ["default@1.0.0"]
2541
2542[[mem_management.delete]]
2543pattern = "exec-*"
2544"#,
2545 )
2546 .unwrap();
2547 use crate::workspace_store::WorkspaceStoreAdapter;
2548 let store = crate::FileWorkspaceStore::new();
2549 store
2550 .save_state(
2551 tmp.path(),
2552 &crate::workspace::Workspace {
2553 mounts: vec![folder_mount("specs", mem_dir)],
2554 settings: crate::workspace::WorkspaceSettings::default(),
2555 },
2556 )
2557 .unwrap();
2558
2559 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2560 let s = engine.settings();
2561 assert_eq!(s.mem_create_rules.len(), 1);
2562 assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
2563 assert_eq!(
2564 s.mem_create_rules[0].schemas,
2565 vec!["default@1.0.0".to_string()]
2566 );
2567 assert_eq!(s.mem_delete_rules.len(), 1);
2568 assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
2569 }
2570
2571 #[test]
2579 fn from_mounts_quarantines_unknown_schema_pin() {
2580 let tmp = TempDir::new().unwrap();
2581 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
2582 let mount = Mount {
2583 mem: "specs".to_string(),
2584 schema: Some(SchemaRef::new(
2585 "totally-not-a-schema",
2586 semver::Version::new(1, 0, 0),
2587 )),
2588 storage: MountStorage::Folder {
2589 path: tmp.path().to_path_buf(),
2590 },
2591 capability: MountCapability::Write,
2592 lifecycle: MountLifecycle::Eager,
2593 cross_linkable: true,
2594 migration_target: None,
2595 };
2596 let engine = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2597 .expect("a broken mem quarantines, never fails the workspace");
2598
2599 let roster = engine.quarantined_mems();
2600 assert_eq!(roster.len(), 1);
2601 assert_eq!(roster[0].mount.mem, "specs");
2602 assert_eq!(roster[0].reason_code, "SCHEMA_NOT_FOUND");
2603 assert!(
2604 roster[0].reason_message.contains("totally-not-a-schema"),
2605 "reason carries the failing pin: {}",
2606 roster[0].reason_message
2607 );
2608 assert!(engine.mounts().iter().all(|m| m.mem != "specs"));
2610 let err = engine.unknown_mem_error("specs");
2613 assert_eq!(err.code(), "MEM_QUARANTINED");
2614 assert!(
2615 err.to_string().contains("SCHEMA_NOT_FOUND"),
2616 "quarantine refusal carries the underlying reason: {err}"
2617 );
2618 let health = engine.health();
2620 assert_eq!(health.quarantined.len(), 1);
2621 assert_eq!(health.quarantined[0].reason_code, "SCHEMA_NOT_FOUND");
2622 }
2623
2624 #[test]
2630 fn reload_returns_repaired_mem_from_quarantine() {
2631 let tmp = TempDir::new().unwrap();
2632 let dir = tmp.path().to_path_buf();
2633 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2634 let config_path = dir.join(".memstead").join("config.json");
2635 std::fs::write(&config_path, r#"{ "schema": "ghost@1.0.0" }"#).unwrap();
2636 let writer = FilesystemMemWriter::new(dir.clone());
2637 let mount = Mount {
2638 mem: "specs".to_string(),
2639 schema: None,
2640 storage: MountStorage::Folder { path: dir },
2641 capability: MountCapability::Write,
2642 lifecycle: MountLifecycle::Eager,
2643 cross_linkable: true,
2644 migration_target: None,
2645 };
2646 let mut engine =
2647 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2648 assert_eq!(engine.quarantined_mems().len(), 1);
2649 let err = engine.reload_one_mem("specs").unwrap_err();
2652 assert_eq!(err.code(), "MEM_QUARANTINED");
2653 assert_eq!(engine.quarantined_mems().len(), 1);
2654
2655 std::fs::write(&config_path, r#"{ "schema": "default@1.0.0" }"#).unwrap();
2658 engine
2659 .reload_one_mem("specs")
2660 .expect("repaired mem re-attaches on reload");
2661 assert!(
2662 engine.quarantined_mems().is_empty(),
2663 "roster entry disappears after re-attach"
2664 );
2665 let mut sections = indexmap::IndexMap::new();
2667 sections.insert("identity".to_string(), "back".to_string());
2668 sections.insert("purpose".to_string(), "post-repair service".to_string());
2669 engine
2670 .create_entity_with_ctx(
2671 crate::engine::CreateEntityArgs {
2672 anchors: Vec::new(),
2673 mem: "specs".to_string(),
2674 title: "Back".to_string(),
2675 entity_type: "spec".to_string(),
2676 sections,
2677 metadata: indexmap::IndexMap::new(),
2678 relations: Vec::new(),
2679 dry_run: false,
2680 },
2681 &crate::vcs::CommitContext::internal(),
2682 )
2683 .expect("reattached mem serves writes");
2684 }
2685
2686 #[test]
2691 fn cross_mem_link_into_quarantined_mem_degrades_without_cascade() {
2692 let tmp = TempDir::new().unwrap();
2693 let healthy_dir = tmp.path().join("healthy");
2694 std::fs::create_dir_all(&healthy_dir).unwrap();
2695 std::fs::write(
2696 healthy_dir.join("linker.md"),
2697 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n\
2698 # Linker\n\n## Identity\n\nsee [[badpin:target]] for detail.\n",
2699 )
2700 .unwrap();
2701 let badpin_dir = tmp.path().join("badpin");
2702 std::fs::create_dir_all(&badpin_dir).unwrap();
2703 let mount = |mem: &str, dir: std::path::PathBuf, pin: &str| {
2704 (
2705 Mount {
2706 mem: mem.to_string(),
2707 schema: Some(SchemaRef::new(pin, semver::Version::new(1, 0, 0))),
2708 storage: MountStorage::Folder { path: dir.clone() },
2709 capability: MountCapability::Write,
2710 lifecycle: MountLifecycle::Eager,
2711 cross_linkable: true,
2712 migration_target: None,
2713 },
2714 Box::new(FilesystemMemWriter::new(dir)) as Box<dyn MemBackend>,
2715 )
2716 };
2717 let engine = Engine::from_mounts(vec![
2718 mount("healthy", healthy_dir, "default"),
2719 mount("badpin", badpin_dir, "ghost"),
2720 ])
2721 .expect("boot survives the cross-mem link into the quarantined mem");
2722 assert_eq!(engine.quarantined_mems().len(), 1);
2723 let linker = engine
2727 .get_entity(&crate::EntityId::new("healthy", "linker"))
2728 .expect("linking entity loads");
2729 assert_eq!(linker.entity_type, "spec");
2730 assert!(
2731 engine
2732 .get_entity(&crate::EntityId::new("badpin", "target"))
2733 .is_none_or(|e| e.stub),
2734 "the quarantined-side target is at most a stub, never real data"
2735 );
2736 }
2737
2738 #[test]
2744 fn old_key_authored_schema_quarantines_pinning_mem_never_workspace() {
2745 let tmp = TempDir::new().unwrap();
2746 let root = tmp.path();
2747 std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
2749 std::fs::write(
2750 root.join(".memstead").join("workspace.toml"),
2751 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2752 )
2753 .unwrap();
2754 for m in ["healthy", "oldkey"] {
2755 std::fs::create_dir_all(root.join(m)).unwrap();
2756 }
2757 std::fs::write(
2758 root.join(".memstead").join("state").join("mounts.json"),
2759 r#"{ "format": "memstead-mounts-3", "mounts": [
2760 { "mem": "healthy", "schema": "default@1.1.0", "storage": { "type": "folder", "path": "healthy" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true },
2761 { "mem": "oldkey", "schema": "fieldschema@0.1.0", "storage": { "type": "folder", "path": "oldkey" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
2762 ] }"#,
2763 )
2764 .unwrap();
2765 let pkg = root
2767 .join(".memstead")
2768 .join("schemas")
2769 .join("fieldschema@0.1.0");
2770 std::fs::create_dir_all(pkg.join("types")).unwrap();
2771 std::fs::write(
2772 pkg.join("schema.yaml"),
2773 "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",
2774 )
2775 .unwrap();
2776 std::fs::write(
2777 pkg.join("types").join("thing.yaml"),
2778 "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",
2779 )
2780 .unwrap();
2781
2782 let engine = Engine::from_workspace_root(root)
2783 .expect("the old-key schema quarantines its mem, never the workspace");
2784 assert!(engine.mounts().iter().any(|m| m.mem == "healthy"));
2785 let q = engine
2786 .quarantine_reason("oldkey")
2787 .expect("oldkey mem is quarantined");
2788 assert_eq!(q.reason_code, "SCHEMA_LOAD_FAILED");
2789 assert!(
2790 q.reason_message.contains("no_self_loop_relationships"),
2791 "quarantine reason is the rename error naming the new key: {}",
2792 q.reason_message
2793 );
2794 }
2795
2796 #[test]
2801 fn ingest_0_3_entries_are_leaves_prior_version_unchanged() {
2802 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";
2803 let boot = |pin: &str| {
2804 let tmp = TempDir::new().unwrap();
2805 let dir = tmp.path().to_path_buf();
2806 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2807 std::fs::write(
2808 dir.join(".memstead").join("config.json"),
2809 format!("{{ \"schema\": \"{pin}\" }}"),
2810 )
2811 .unwrap();
2812 std::fs::write(dir.join("gap.md"), entry_md).unwrap();
2813 let writer = FilesystemMemWriter::new(dir.clone());
2814 let mount = Mount {
2815 mem: "proc".to_string(),
2816 schema: None,
2817 storage: MountStorage::Folder { path: dir },
2818 capability: MountCapability::Write,
2819 lifecycle: MountLifecycle::Eager,
2820 cross_linkable: true,
2821 migration_target: None,
2822 };
2823 let engine =
2824 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2825 .unwrap();
2826 (engine.health(), tmp)
2827 };
2828
2829 let (health_new, _t1) = boot("ingest@0.3.0");
2830 assert_eq!(
2831 health_new.orphan_count, 0,
2832 "0.3.0 entry types are leaves — zero false orphans"
2833 );
2834 assert_eq!(
2835 health_new
2836 .leaf_entities_by_type
2837 .get("ingest@0.3.0:coverage_gap"),
2838 Some(&1),
2839 "the population stays visible: {:?}",
2840 health_new.leaf_entities_by_type
2841 );
2842
2843 let (health_old, _t2) = boot("ingest@0.2.0");
2844 assert_eq!(
2845 health_old.orphan_count, 1,
2846 "the prior version's behaviour is unchanged"
2847 );
2848 assert!(health_old.leaf_entities_by_type.is_empty());
2849 }
2850
2851 #[test]
2857 fn broken_mem_quarantines_while_healthy_siblings_serve() {
2858 let tmp = TempDir::new().unwrap();
2859 let make_mount = |mem: &str, pin: Option<SchemaRef>| {
2860 let dir = tmp.path().join(mem);
2861 std::fs::create_dir_all(&dir).unwrap();
2862 let writer = FilesystemMemWriter::new(dir.clone());
2863 (
2864 Mount {
2865 mem: mem.to_string(),
2866 schema: pin,
2867 storage: MountStorage::Folder { path: dir },
2868 capability: MountCapability::Write,
2869 lifecycle: MountLifecycle::Eager,
2870 cross_linkable: true,
2871 migration_target: None,
2872 },
2873 Box::new(writer) as Box<dyn MemBackend>,
2874 )
2875 };
2876 let healthy = make_mount(
2877 "healthy",
2878 Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
2879 );
2880 let bad_pin = make_mount(
2881 "badpin",
2882 Some(SchemaRef::new("ghost", semver::Version::new(1, 0, 0))),
2883 );
2884 let missing_pin = make_mount("nopin", None);
2885 let bad_io_path = tmp.path().join("badio");
2888 std::fs::write(&bad_io_path, "not a directory").unwrap();
2889 let bad_io = (
2890 Mount {
2891 mem: "badio".to_string(),
2892 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
2893 storage: MountStorage::Folder {
2894 path: bad_io_path.clone(),
2895 },
2896 capability: MountCapability::Write,
2897 lifecycle: MountLifecycle::Eager,
2898 cross_linkable: true,
2899 migration_target: None,
2900 },
2901 Box::new(FilesystemMemWriter::new(bad_io_path)) as Box<dyn MemBackend>,
2902 );
2903 let mut engine = Engine::from_mounts(vec![healthy, bad_pin, missing_pin, bad_io])
2904 .expect("mixed workspace boots");
2905
2906 let codes: std::collections::HashMap<String, String> = engine
2908 .quarantined_mems()
2909 .iter()
2910 .map(|q| (q.mount.mem.clone(), q.reason_code.clone()))
2911 .collect();
2912 assert_eq!(
2913 codes.get("badpin").map(String::as_str),
2914 Some("SCHEMA_NOT_FOUND")
2915 );
2916 assert_eq!(
2917 codes.get("nopin").map(String::as_str),
2918 Some("MEM_CONFIG_INCOMPLETE")
2919 );
2920 assert!(
2921 codes.contains_key("badio"),
2922 "backend read failure quarantines too: {codes:?}"
2923 );
2924
2925 let mut sections = indexmap::IndexMap::new();
2927 sections.insert("identity".to_string(), "alive".to_string());
2928 sections.insert("purpose".to_string(), "proof of service".to_string());
2929 let created = engine
2930 .create_entity_with_ctx(
2931 crate::engine::CreateEntityArgs {
2932 anchors: Vec::new(),
2933 mem: "healthy".to_string(),
2934 title: "Alive".to_string(),
2935 entity_type: "spec".to_string(),
2936 sections,
2937 metadata: indexmap::IndexMap::new(),
2938 relations: Vec::new(),
2939 dry_run: false,
2940 },
2941 &crate::vcs::CommitContext::internal(),
2942 )
2943 .expect("healthy mem serves writes");
2944 assert_eq!(created.id.to_string(), "healthy--alive");
2945
2946 let mut sections = indexmap::IndexMap::new();
2948 sections.insert("identity".to_string(), "x".to_string());
2949 let err = engine
2950 .create_entity_with_ctx(
2951 crate::engine::CreateEntityArgs {
2952 anchors: Vec::new(),
2953 mem: "badpin".to_string(),
2954 title: "Nope".to_string(),
2955 entity_type: "spec".to_string(),
2956 sections,
2957 metadata: indexmap::IndexMap::new(),
2958 relations: Vec::new(),
2959 dry_run: false,
2960 },
2961 &crate::vcs::CommitContext::internal(),
2962 )
2963 .unwrap_err();
2964 assert_eq!(err.code(), "MEM_QUARANTINED");
2965 }
2966
2967 #[test]
2974 fn mem_config_schema_is_authoritative_over_mount_pin() {
2975 let tmp = TempDir::new().unwrap();
2976 let mem_dir = tmp.path().to_path_buf();
2977 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2978 std::fs::write(
2979 mem_dir.join(".memstead").join("config.json"),
2980 r#"{"schema":"software@0.1.0"}"#,
2981 )
2982 .unwrap();
2983 let writer = FilesystemMemWriter::new(mem_dir.clone());
2984 let mount = Mount {
2985 mem: "specs".to_string(),
2986 schema: Some(SchemaRef::new(
2987 "totally-not-a-schema",
2988 semver::Version::new(9, 9, 9),
2989 )),
2990 storage: MountStorage::Folder { path: mem_dir },
2991 capability: MountCapability::Write,
2992 lifecycle: MountLifecycle::Eager,
2993 cross_linkable: true,
2994 migration_target: None,
2995 };
2996 let engine = Engine::from_mounts(vec![(
2997 mount,
2998 Box::new(writer) as Box<dyn MemBackend>,
2999 )])
3000 .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
3001
3002 let mismatch = engine
3003 .load_warnings()
3004 .iter()
3005 .find_map(|w| match w {
3006 WarningHint::SchemaPinMismatch {
3007 mem,
3008 config_pin,
3009 mount_pin,
3010 } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
3011 _ => None,
3012 })
3013 .expect("SchemaPinMismatch warning must surface naming both pins");
3014 assert_eq!(mismatch.0, "specs");
3015 assert_eq!(mismatch.1, "software@0.1.0");
3016 assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
3017 }
3018
3019 #[test]
3020 fn from_workspace_root_quarantines_git_branch_mount_on_lean() {
3021 let tmp = TempDir::new().unwrap();
3022 let memstead = tmp.path().join(".memstead");
3023 std::fs::create_dir_all(&memstead).unwrap();
3024 std::fs::write(
3025 memstead.join("workspace.toml"),
3026 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3027 )
3028 .unwrap();
3029 let state_dir = memstead.join("state");
3032 std::fs::create_dir_all(&state_dir).unwrap();
3033 std::fs::write(
3034 state_dir.join("mounts.json"),
3035 r#"{
3036 "format": "memstead-mounts-3",
3037 "mounts": [
3038 {
3039 "mem": "specs",
3040 "schema": "default@1.0.0",
3041 "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
3042 "capability": "write",
3043 "lifecycle": "eager",
3044 "cross_linkable": true
3045 }
3046 ]
3047 }"#,
3048 )
3049 .unwrap();
3050 let engine = Engine::from_workspace_root(tmp.path())
3058 .expect("lean boot quarantines the git-branch mount, never fails the workspace");
3059 let roster = engine.quarantined_mems();
3060 assert_eq!(roster.len(), 1);
3061 assert_eq!(roster[0].mount.mem, "specs");
3062 assert_eq!(roster[0].reason_code, "UNSUPPORTED_WORKSPACE_SHAPE");
3063 assert_eq!(engine.unknown_mem_error("specs").code(), "MEM_QUARANTINED");
3064 }
3065
3066 #[test]
3067 fn from_workspace_root_roots_standalone_folder_mem() {
3068 let tmp = TempDir::new().unwrap();
3072 let root = tmp.path();
3073 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3074 std::fs::write(
3075 root.join(".memstead").join("config.json"),
3076 r#"{"schema":"default@1.0.0"}"#,
3077 )
3078 .unwrap();
3079 std::fs::write(
3081 root.join("hello.md"),
3082 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3083 )
3084 .unwrap();
3085
3086 let engine = Engine::from_workspace_root(root)
3087 .expect("a bare folder mem must root as a one-mount workspace");
3088 assert_eq!(engine.status().mem_count, 1, "exactly one mount");
3089 assert!(
3090 engine.status().entity_count >= 1,
3091 "the standalone mem's entity must load"
3092 );
3093 }
3094
3095 #[test]
3096 fn from_workspace_root_still_rejects_truly_empty_dir() {
3097 let tmp = TempDir::new().unwrap();
3101 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
3102 assert!(
3103 matches!(err, crate::BootError::NotInitialised(_)),
3104 "got {err:?}"
3105 );
3106 }
3107
3108 #[test]
3115 fn boot_keeps_loading_violating_schema_and_surfaces_health_finding() {
3116 let tmp = TempDir::new().unwrap();
3117 let schemas_dir = tmp.path().join("schemas");
3118 let pkg = schemas_dir.join("debate");
3119 std::fs::create_dir_all(pkg.join("types")).unwrap();
3120 std::fs::write(
3121 pkg.join("schema.yaml"),
3122 r#"name: debate
3123version: 0.1.0
3124description: sealed-violator fixture
3125when_to_use: tests
3126types:
3127 - question
3128relationships:
3129 mode: strict
3130 definitions:
3131 - name: PART_OF
3132 description: hier
3133 default_weight: 3.0
3134 - name: _default
3135 description: fallback
3136 default_weight: 1.0
3137community:
3138 resolution: 1.0
3139 seed: 42
3140"#,
3141 )
3142 .unwrap();
3143 std::fs::write(
3144 pkg.join("types").join("question.yaml"),
3145 r#"name: question
3146description: t
3147when_to_use: tests
3148sections:
3149 - key: answers
3150 heading: Answers argued
3151 required: true
3152 search_weight: 10.0
3153 write_rules: []
3154 - key: notes
3155 heading: Notes
3156 required: false
3157 search_weight: 3.0
3158 catch_all: true
3159 write_rules: []
3160metadata_fields: []
3161title_weight: 100.0
3162text_fields:
3163 - answers
3164 - notes
3165hierarchy_relationship: PART_OF
3166no_self_loop_relationships: []
3167updatable_fields:
3168 - title
3169 - answers
3170 - notes
3171health_required_fields:
3172 - answers
3173staleness_threshold_days: 90
3174write_rules: []
3175"#,
3176 )
3177 .unwrap();
3178
3179 let mem_dir = tmp.path().join("mem");
3180 std::fs::create_dir_all(&mem_dir).unwrap();
3181 std::fs::write(
3182 mem_dir.join("q.md"),
3183 "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n",
3184 )
3185 .unwrap();
3186
3187 let writer = FilesystemMemWriter::new(mem_dir.clone());
3188 let mount = Mount {
3189 mem: "debate-mem".to_string(),
3190 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3191 storage: MountStorage::Folder { path: mem_dir },
3192 capability: MountCapability::Write,
3193 lifecycle: MountLifecycle::Eager,
3194 cross_linkable: true,
3195 migration_target: None,
3196 };
3197 let engine = Engine::from_mounts_with_schemas_dir(
3198 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3199 Some(&schemas_dir),
3200 )
3201 .expect("a violating sealed schema must keep loading, never refuse boot");
3202
3203 assert!(
3205 engine.status().entity_count >= 1,
3206 "entities load despite the schema violation"
3207 );
3208
3209 let hits: Vec<_> = engine
3211 .load_warnings()
3212 .iter()
3213 .filter_map(|w| match w {
3214 WarningHint::SchemaHeadingRoundtripViolation {
3215 mem,
3216 schema_ref,
3217 violations,
3218 } => Some((mem.clone(), schema_ref.clone(), violations.clone())),
3219 _ => None,
3220 })
3221 .collect();
3222 assert_eq!(
3223 hits.len(),
3224 1,
3225 "exactly one schema-level finding; all warnings = {:?}",
3226 engine.load_warnings()
3227 );
3228 let (mem, schema_ref, violations) = &hits[0];
3229 assert_eq!(mem, "debate-mem");
3230 assert_eq!(schema_ref, "debate@0.1.0");
3231 assert_eq!(violations.len(), 1);
3232 assert_eq!(violations[0].type_name, "question");
3233 assert_eq!(violations[0].key, "answers");
3234 assert_eq!(violations[0].heading, "Answers argued");
3235 assert_eq!(violations[0].derived_key, "answers_argued");
3236 }
3237
3238 #[test]
3246 fn sealed_violator_mem_still_serves_writes() {
3247 let tmp = TempDir::new().unwrap();
3249 let schemas_dir = tmp.path().join("schemas");
3250 let pkg = schemas_dir.join("debate");
3251 std::fs::create_dir_all(pkg.join("types")).unwrap();
3252 std::fs::write(
3253 pkg.join("schema.yaml"),
3254 r#"name: debate
3255version: 0.1.0
3256description: sealed-violator fixture
3257when_to_use: tests
3258types:
3259 - question
3260relationships:
3261 mode: strict
3262 definitions:
3263 - name: PART_OF
3264 description: hier
3265 default_weight: 3.0
3266 - name: _default
3267 description: fallback
3268 default_weight: 1.0
3269community:
3270 resolution: 1.0
3271 seed: 42
3272"#,
3273 )
3274 .unwrap();
3275 std::fs::write(
3276 pkg.join("types").join("question.yaml"),
3277 r#"name: question
3278description: t
3279when_to_use: tests
3280sections:
3281 - key: answers
3282 heading: Answers argued
3283 required: true
3284 search_weight: 10.0
3285 write_rules: []
3286 - key: notes
3287 heading: Notes
3288 required: false
3289 search_weight: 3.0
3290 catch_all: true
3291 write_rules: []
3292metadata_fields: []
3293title_weight: 100.0
3294text_fields:
3295 - answers
3296 - notes
3297hierarchy_relationship: PART_OF
3298no_self_loop_relationships: []
3299updatable_fields:
3300 - title
3301 - answers
3302 - notes
3303health_required_fields:
3304 - answers
3305staleness_threshold_days: 90
3306write_rules: []
3307"#,
3308 )
3309 .unwrap();
3310
3311 let mem_dir = tmp.path().join("mem");
3312 std::fs::create_dir_all(&mem_dir).unwrap();
3313 std::fs::write(
3317 mem_dir.join("q.md"),
3318 "---\ntype: question\n---\n# Q\n\n## Answers\n\nTwo answers.\n",
3319 )
3320 .unwrap();
3321
3322 let writer = FilesystemMemWriter::new(mem_dir.clone());
3323 let mount = Mount {
3324 mem: "debate-mem".to_string(),
3325 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3326 storage: MountStorage::Folder { path: mem_dir },
3327 capability: MountCapability::Write,
3328 lifecycle: MountLifecycle::Eager,
3329 cross_linkable: true,
3330 migration_target: None,
3331 };
3332 let mut engine = Engine::from_mounts_with_schemas_dir(
3333 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3334 Some(&schemas_dir),
3335 )
3336 .expect("a violating sealed schema must keep loading");
3337
3338 let id = crate::EntityId::new("debate-mem", "q");
3339 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3340 let mut sections = indexmap::IndexMap::new();
3341 sections.insert("answers".to_string(), "Updated answers body.".to_string());
3342 let outcome = engine
3343 .update_entity(
3344 crate::engine::UpdateEntityArgs {
3345 anchors: Vec::new(),
3346 anchors_unset: Vec::new(),
3347 id: id.clone(),
3348 expected_hash: Some(hash),
3349 sections,
3350 append_sections: indexmap::IndexMap::new(),
3351 patch_sections: indexmap::IndexMap::new(),
3352 metadata: indexmap::IndexMap::new(),
3353 metadata_unset: Vec::new(),
3354 declare_relations: Vec::new(),
3355 dry_run: false,
3356 relations_unset: Vec::new(),
3357 },
3358 crate::vcs::Actor::Cli,
3359 None,
3360 None,
3361 )
3362 .expect("a write against a sealed-violator mem must NOT be refused");
3363 assert!(!outcome.commit_sha.is_empty(), "the write commits");
3364 assert!(
3365 outcome
3366 .warnings
3367 .iter()
3368 .any(|w| w.code() == "SECTION_HEADING_DIVERGENCE"),
3369 "the write-path divergence warning fires where its condition holds: {:?}",
3370 outcome.warnings
3371 );
3372
3373 assert!(
3375 engine.load_warnings().iter().any(|w| matches!(
3376 w,
3377 WarningHint::SchemaHeadingRoundtripViolation { mem, .. } if mem == "debate-mem"
3378 )),
3379 "the health finding persists across writes"
3380 );
3381 let entity = engine.get_entity(&id).unwrap();
3390 assert!(
3391 entity
3392 .sections
3393 .values()
3394 .any(|s| s.contains("Updated answers body.")),
3395 "written content survives the round-trip (in the catch-all): {:?}",
3396 entity.sections
3397 );
3398 }
3399
3400 #[test]
3407 fn search_mem_filter_gates_against_visible_roster() {
3408 use crate::vcs::Actor;
3409 use crate::workspace_store::WorkspaceStoreAdapter;
3410 use indexmap::IndexMap;
3411
3412 let tmp = TempDir::new().unwrap();
3413 let mem_dir = tmp.path().join("mem");
3414 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3415 std::fs::write(
3416 mem_dir.join(".memstead").join("config.json"),
3417 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3418 )
3419 .unwrap();
3420 let memstead = tmp.path().join(".memstead");
3421 std::fs::create_dir_all(&memstead).unwrap();
3422 std::fs::write(
3423 memstead.join("workspace.toml"),
3424 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3425 )
3426 .unwrap();
3427 crate::FileWorkspaceStore::new()
3428 .save_state(
3429 tmp.path(),
3430 &crate::workspace::Workspace {
3431 mounts: vec![folder_mount("specs", mem_dir.clone())],
3432 settings: crate::workspace::WorkspaceSettings::default(),
3433 },
3434 )
3435 .unwrap();
3436 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
3437 let mut sections = IndexMap::new();
3438 sections.insert(
3439 "identity".to_string(),
3440 "Zebra searching fixture.".to_string(),
3441 );
3442 sections.insert("purpose".to_string(), "Search gate test.".to_string());
3443 engine
3444 .create_entity(
3445 crate::CreateEntityArgs {
3446 mem: "specs".to_string(),
3447 title: "Zebra".to_string(),
3448 entity_type: "spec".to_string(),
3449 sections,
3450 metadata: IndexMap::new(),
3451 relations: Vec::new(),
3452 anchors: Vec::new(),
3453 dry_run: false,
3454 },
3455 Actor::Agent,
3456 None,
3457 None,
3458 )
3459 .unwrap();
3460
3461 let scope = |mem: Option<&str>, term: &str| crate::ops::SearchScope {
3462 query: Some(crate::ops::Query {
3463 any: vec![term.to_string()],
3464 ..Default::default()
3465 }),
3466 mem: mem.map(str::to_string),
3467 ..Default::default()
3468 };
3469
3470 let err = engine
3472 .search(&scope(Some("no-such-mem"), "zebra"))
3473 .expect_err("a nonexistent mem filter must refuse");
3474 assert_eq!(err.code(), "UNKNOWN_MEM", "got {err:?}");
3475
3476 let hit = engine.search(&scope(Some("specs"), "zebra")).unwrap();
3478 assert!(hit.total >= 1, "the fixture entity matches: {hit:?}");
3479
3480 let none = engine
3483 .search(&scope(Some("specs"), "quixotic-nonword"))
3484 .unwrap();
3485 assert_eq!(none.total, 0, "{none:?}");
3486 }
3487
3488 fn lazy_folder_mount(mem: &str, path: std::path::PathBuf) -> Mount {
3493 Mount {
3494 lifecycle: MountLifecycle::Lazy,
3495 ..folder_mount(mem, path)
3496 }
3497 }
3498
3499 fn write_spec(dir: &Path, slug: &str, title: &str, extra: &str) {
3500 std::fs::write(
3501 dir.join(format!("{slug}.md")),
3502 format!("---\ntype: spec\n---\n# {title}\n\n## Identity\n\nBody.\n{extra}"),
3503 )
3504 .unwrap();
3505 }
3506
3507 fn two_mem_dirs(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
3508 let eager_dir = tmp.path().join("eag");
3509 let lazy_dir = tmp.path().join("laz");
3510 std::fs::create_dir_all(&eager_dir).unwrap();
3511 std::fs::create_dir_all(&lazy_dir).unwrap();
3512 write_spec(&eager_dir, "alpha", "Alpha", "");
3513 write_spec(&lazy_dir, "omega", "Omega", "");
3514 (eager_dir, lazy_dir)
3515 }
3516
3517 fn mixed_engine(eager_dir: &Path, lazy_dir: &Path) -> Engine {
3518 Engine::from_mounts(vec![
3519 (
3520 folder_mount("eag", eager_dir.to_path_buf()),
3521 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
3522 ),
3523 (
3524 lazy_folder_mount("laz", lazy_dir.to_path_buf()),
3525 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
3526 ),
3527 ])
3528 .unwrap()
3529 }
3530
3531 #[test]
3537 fn lazy_mount_defers_and_first_read_loads() {
3538 let tmp = TempDir::new().unwrap();
3539 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3540 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3541
3542 assert!(
3544 engine
3545 .get_entity(&crate::EntityId::new("eag", "alpha"))
3546 .is_some()
3547 );
3548 assert_eq!(engine.deferred_mems(), vec!["laz"]);
3549 assert!(engine.mem_is_deferred("laz"));
3550 assert!(
3551 engine
3552 .get_entity(&crate::EntityId::new("laz", "omega"))
3553 .is_none(),
3554 "deferred mem's entities are not in the store yet"
3555 );
3556 assert!(engine.schema_for("laz").is_some());
3558
3559 engine.reload_if_stale(Some("laz"));
3561 assert!(engine.deferred_mems().is_empty());
3562 let omega = engine
3563 .get_entity(&crate::EntityId::new("laz", "omega"))
3564 .expect("first read loads the mem");
3565 assert_eq!(omega.title, "Omega");
3566
3567 let (actor, client) = cli_actor();
3569 engine
3570 .create_entity(
3571 empty_create_args("laz", "Later"),
3572 actor,
3573 Some(&client),
3574 None,
3575 )
3576 .unwrap();
3577 assert!(
3578 engine
3579 .get_entity(&crate::EntityId::new("laz", "later"))
3580 .is_some()
3581 );
3582 }
3583
3584 #[test]
3588 fn scoped_operation_never_loads_lazy_sibling() {
3589 let tmp = TempDir::new().unwrap();
3590 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3591 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3592
3593 engine.reload_if_stale(Some("eag"));
3594 assert_eq!(
3595 engine.deferred_mems(),
3596 vec!["laz"],
3597 "an eager-scoped operation must not load the lazy sibling"
3598 );
3599
3600 engine.reload_if_stale(None);
3601 assert!(
3602 engine.deferred_mems().is_empty(),
3603 "a workspace-scoped pass loads every deferred mem"
3604 );
3605 }
3606
3607 #[test]
3611 fn lazy_load_failure_quarantines_typed() {
3612 let tmp = TempDir::new().unwrap();
3613 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3614 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3615
3616 std::fs::remove_dir_all(&lazy_dir).unwrap();
3620 std::fs::write(&lazy_dir, b"not a directory").unwrap();
3621 engine.reload_if_stale(Some("laz"));
3622
3623 let q = engine
3624 .quarantine_reason("laz")
3625 .expect("failed deferred load quarantines, never serves empty");
3626 assert!(!q.reason_code.is_empty());
3627 assert!(
3628 !engine.mem_names().contains(&"laz"),
3629 "a quarantined mem leaves the serving roster"
3630 );
3631 assert!(engine.deferred_mems().is_empty());
3632 }
3633
3634 #[test]
3642 fn quarantine_entry_invalidates_both_memos() {
3643 let tmp = TempDir::new().unwrap();
3644 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3645 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3646
3647 let _ = engine.communities();
3649 let _ = engine.search_indexes();
3650 assert!(engine.search_indexes_memo.get().is_some());
3651
3652 std::fs::remove_dir_all(&lazy_dir).unwrap();
3654 std::fs::write(&lazy_dir, b"not a directory").unwrap();
3655 engine.reload_if_stale(Some("laz"));
3656 assert!(engine.quarantine_reason("laz").is_some());
3657 assert!(
3658 engine.search_indexes_memo.get().is_none(),
3659 "quarantine entry bumps the schemas epoch — the search memo must clear"
3660 );
3661 assert!(
3662 engine.community_memo.get().is_none(),
3663 "quarantine entry must clear the community memo too"
3664 );
3665 let _ = engine.search_indexes();
3667
3668 let _ = engine.communities();
3670 let _ = engine.search_indexes();
3671 let _ = engine.reload_one_mem("laz");
3672 assert!(engine.quarantine_reason("laz").is_some());
3673 assert!(
3674 engine.search_indexes_memo.get().is_none(),
3675 "a failed reattach bumps the epoch — the search memo must clear"
3676 );
3677 let _ = engine.search_indexes();
3678 }
3679
3680 #[test]
3685 fn quarantine_reattach_invalidates_both_memos() {
3686 let tmp = TempDir::new().unwrap();
3687 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3688 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3689
3690 std::fs::remove_dir_all(&lazy_dir).unwrap();
3692 std::fs::write(&lazy_dir, b"not a directory").unwrap();
3693 engine.reload_if_stale(Some("laz"));
3694 assert!(engine.quarantine_reason("laz").is_some());
3695
3696 let _ = engine.communities();
3698 let _ = engine.search_indexes();
3699 assert!(engine.community_memo.get().is_some());
3700 assert!(engine.search_indexes_memo.get().is_some());
3701
3702 std::fs::remove_file(&lazy_dir).unwrap();
3704 std::fs::create_dir_all(&lazy_dir).unwrap();
3705 write_spec(&lazy_dir, "omega", "Omega", "");
3706 engine
3707 .reload_one_mem("laz")
3708 .expect("reattach succeeds once the backend is back");
3709 assert!(engine.quarantine_reason("laz").is_none());
3710
3711 assert!(
3714 engine.community_memo.get().is_none(),
3715 "reattach must invalidate the community memo"
3716 );
3717 assert!(
3718 engine.search_indexes_memo.get().is_none(),
3719 "reattach must invalidate the search memo"
3720 );
3721 }
3722
3723 #[test]
3728 fn lazy_load_runs_the_same_validation_gauntlet() {
3729 let tmp = TempDir::new().unwrap();
3730 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3731 std::fs::write(
3733 lazy_dir.join("bad.md"),
3734 "---\ntype: spec\n---\n# Bad\n\n## Identity\n\nx.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[laz--omega]]\n",
3735 )
3736 .unwrap();
3737
3738 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3739 let warned_before = engine
3740 .load_warnings()
3741 .iter()
3742 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
3743 engine.reload_if_stale(Some("laz"));
3744 let warned_after = engine
3745 .load_warnings()
3746 .iter()
3747 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
3748 assert!(
3749 !warned_before && warned_after,
3750 "the gauntlet fires at load time: before={warned_before} after={warned_after}, \
3751 warnings: {:?}",
3752 engine.load_warnings()
3753 );
3754 let bad = engine
3756 .get_entity(&crate::EntityId::new("laz", "bad"))
3757 .unwrap();
3758 assert!(bad.relationships.is_empty());
3759 }
3760
3761 #[test]
3769 fn delete_guard_sees_referrers_in_deferred_mems() {
3770 use crate::engine::{DeleteEntityArgs, RelateEntityArgs};
3771
3772 let tmp = TempDir::new().unwrap();
3773 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3774 let (actor, client) = cli_actor();
3775
3776 {
3780 let mut authoring = Engine::from_mounts(vec![
3781 (
3782 folder_mount("eag", eager_dir.to_path_buf()),
3783 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf()))
3784 as Box<dyn MemBackend>,
3785 ),
3786 (
3787 folder_mount("laz", lazy_dir.to_path_buf()),
3788 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf()))
3789 as Box<dyn MemBackend>,
3790 ),
3791 ])
3792 .unwrap();
3793 let mut settings = crate::workspace::WorkspaceSettings::default();
3794 settings.cross_mem_links.insert(
3795 "laz".to_string(),
3796 memstead_schema::workspace_config::CrossLinkValue::List(vec!["eag".to_string()]),
3797 );
3798 authoring.set_settings(settings);
3799 authoring
3800 .relate_entity(
3801 RelateEntityArgs {
3802 source: crate::EntityId::new("laz", "omega"),
3803 expected_hash: None,
3804 rel_type: "USES".to_string(),
3805 target: crate::EntityId::new("eag", "alpha"),
3806 remove: false,
3807 description: None,
3808 dry_run: false,
3809 },
3810 actor,
3811 Some(&client),
3812 None,
3813 )
3814 .expect("cross-mem relate lands under the grant");
3815 }
3816
3817 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3821 assert!(engine.mem_is_deferred("laz"));
3822 let err = engine
3823 .delete_entity(
3824 DeleteEntityArgs {
3825 id: crate::EntityId::new("eag", "alpha"),
3826 expected_hash: None,
3827 },
3828 actor,
3829 Some(&client),
3830 None,
3831 )
3832 .expect_err("a referenced entity must refuse deletion, lazy referrer or not");
3833 assert!(
3834 matches!(err, EngineError::HasIncomingRefs { .. }),
3835 "expected HAS_INCOMING_REFS, got {err:?}"
3836 );
3837 assert!(
3838 engine
3839 .get_entity(&crate::EntityId::new("eag", "alpha"))
3840 .is_some(),
3841 "the entity survives"
3842 );
3843 }
3844
3845 #[test]
3856 fn acyclicity_guard_sees_edges_in_deferred_mems() {
3857 use crate::engine::RelateEntityArgs;
3858 use memstead_schema::workspace_config::CrossLinkValue;
3859
3860 let tmp = TempDir::new().unwrap();
3861 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
3862 .iter()
3863 .map(|m| {
3864 let d = tmp.path().join(m);
3865 std::fs::create_dir_all(&d).unwrap();
3866 d
3867 })
3868 .collect();
3869 write_spec(&dirs[0], "node", "Node A", "");
3870 write_spec(&dirs[1], "node", "Node B", "");
3871 write_spec(&dirs[2], "node", "Node C", "");
3872
3873 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
3874 ["ma", "mb", "mc"]
3875 .iter()
3876 .zip(dirs.iter())
3877 .map(|(m, d)| {
3878 let mut mount = folder_mount(m, d.clone());
3879 if lazy_mid && *m == "ma" {
3880 mount.lifecycle = MountLifecycle::Lazy;
3881 }
3882 (
3883 mount,
3884 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
3885 )
3886 })
3887 .collect()
3888 };
3889 let grants = || {
3890 let mut settings = crate::workspace::WorkspaceSettings::default();
3891 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
3892 settings
3893 .cross_mem_links
3894 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
3895 }
3896 settings
3897 };
3898 let relate = |engine: &mut Engine, from: &str, to: &str| {
3899 let (actor, client) = cli_actor();
3900 engine.relate_entity(
3901 RelateEntityArgs {
3902 source: crate::EntityId::new(from, "node"),
3903 expected_hash: None,
3904 rel_type: "DEPENDS_ON".to_string(),
3905 target: crate::EntityId::new(to, "node"),
3906 remove: false,
3907 description: None,
3908 dry_run: false,
3909 },
3910 actor,
3911 Some(&client),
3912 None,
3913 )
3914 };
3915
3916 {
3918 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
3919 authoring.set_settings(grants());
3920 relate(&mut authoring, "mb", "ma").expect("mb→ma lands");
3921 relate(&mut authoring, "ma", "mc").expect("ma→mc lands");
3922 }
3923
3924 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
3927 engine.set_settings(grants());
3928 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
3929 let err = relate(&mut engine, "mc", "mb")
3930 .expect_err("a cycle through a deferred mem must refuse, as eager refuses");
3931 assert!(
3932 matches!(err, EngineError::RelationshipCycle { .. }),
3933 "expected RELATIONSHIP_CYCLE, got {err:?}"
3934 );
3935 }
3936
3937 #[test]
3945 fn batch_acyclicity_guard_sees_edges_in_deferred_mems() {
3946 use crate::engine::RelateEntityArgs;
3947 use memstead_schema::workspace_config::CrossLinkValue;
3948
3949 let tmp = TempDir::new().unwrap();
3950 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
3951 .iter()
3952 .map(|m| {
3953 let d = tmp.path().join(m);
3954 std::fs::create_dir_all(&d).unwrap();
3955 d
3956 })
3957 .collect();
3958 write_spec(&dirs[0], "node", "Node A", "");
3959 write_spec(&dirs[1], "node", "Node B", "");
3960 write_spec(&dirs[2], "node", "Node C", "");
3961 write_spec(&dirs[2], "node2", "Node C2", "");
3966
3967 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
3968 ["ma", "mb", "mc"]
3969 .iter()
3970 .zip(dirs.iter())
3971 .map(|(m, d)| {
3972 let mut mount = folder_mount(m, d.clone());
3973 if lazy_mid && *m == "ma" {
3974 mount.lifecycle = MountLifecycle::Lazy;
3975 }
3976 (
3977 mount,
3978 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
3979 )
3980 })
3981 .collect()
3982 };
3983 let grants = || {
3984 let mut settings = crate::workspace::WorkspaceSettings::default();
3985 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
3986 settings
3987 .cross_mem_links
3988 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
3989 }
3990 settings
3991 };
3992 let relate_args = |from: &str, to: &str| RelateEntityArgs {
3993 source: crate::EntityId::new(from, "node"),
3994 expected_hash: None,
3995 rel_type: "DEPENDS_ON".to_string(),
3996 target: crate::EntityId::new(to, "node"),
3997 remove: false,
3998 description: None,
3999 dry_run: false,
4000 };
4001
4002 {
4004 let (actor, client) = cli_actor();
4005 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4006 authoring.set_settings(grants());
4007 authoring
4008 .relate_entity(relate_args("mb", "ma"), actor, Some(&client), None)
4009 .expect("mb→ma lands");
4010 let (actor2, client2) = cli_actor();
4011 authoring
4012 .relate_entity(relate_args("ma", "mc"), actor2, Some(&client2), None)
4013 .expect("ma→mc lands");
4014 }
4015
4016 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4020 engine.set_settings(grants());
4021 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4022 let (actor, client) = cli_actor();
4023 let result = engine
4024 .batch_relate(
4025 vec![
4026 (relate_args("mc", "mb"), None),
4027 (
4033 RelateEntityArgs {
4034 source: crate::EntityId::new("mc", "node2"),
4035 expected_hash: None,
4036 rel_type: "DEPENDS_ON".to_string(),
4037 target: crate::EntityId::new("mc", "node"),
4038 remove: false,
4039 description: None,
4040 dry_run: false,
4041 },
4042 None,
4043 ),
4044 ],
4045 actor,
4046 Some(&client),
4047 false,
4048 )
4049 .expect("the batch call itself returns a report-all envelope");
4050 assert!(
4051 !result.applied,
4052 "a batch closing a cycle through a deferred mem must refuse, as eager refuses; got applied with {} succeeded",
4053 result.succeeded
4054 );
4055 }
4056
4057 #[test]
4065 fn relate_into_deferred_mem_verifies_against_storage_without_load() {
4066 use crate::engine::RelateEntityArgs;
4067 use memstead_schema::workspace_config::CrossLinkValue;
4068
4069 let tmp = TempDir::new().unwrap();
4070 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4071 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4072 let mut settings = crate::workspace::WorkspaceSettings::default();
4073 settings.cross_mem_links.insert(
4074 "eag".to_string(),
4075 CrossLinkValue::List(vec!["laz".to_string()]),
4076 );
4077 engine.set_settings(settings);
4078
4079 let relate = |engine: &mut Engine, to: &str| {
4080 let (actor, client) = cli_actor();
4081 engine.relate_entity(
4082 RelateEntityArgs {
4083 source: crate::EntityId::new("eag", "alpha"),
4084 expected_hash: None,
4085 rel_type: "SUPPORTS".to_string(),
4086 target: crate::EntityId::new("laz", to),
4087 remove: false,
4088 description: None,
4089 dry_run: false,
4090 },
4091 actor,
4092 Some(&client),
4093 None,
4094 )
4095 };
4096
4097 let outcome = relate(&mut engine, "omega").expect("verified target admits");
4099 assert!(
4100 engine.mem_is_deferred("laz"),
4101 "verification never loads the mem"
4102 );
4103 assert!(
4104 !outcome
4105 .warnings
4106 .iter()
4107 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4108 "a storage-verified target is not an auto-stub case: {:?}",
4109 outcome.warnings
4110 );
4111 let stub = engine
4112 .store()
4113 .get(&crate::EntityId::new("laz", "omega"))
4114 .expect("until-load stub present");
4115 assert!(stub.stub);
4116 assert_eq!(
4117 stub.stub_kind,
4118 Some(crate::entity::StubKind::LoadTime),
4119 "verified-in-storage stub carries the load-time kind"
4120 );
4121
4122 let outcome = relate(&mut engine, "missing").expect("absent Write-mem target auto-stubs");
4124 assert!(engine.mem_is_deferred("laz"), "still no load");
4125 assert!(
4126 outcome
4127 .warnings
4128 .iter()
4129 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4130 "absent target keeps the auto-stub warning: {:?}",
4131 outcome.warnings
4132 );
4133 let stub = engine
4134 .store()
4135 .get(&crate::EntityId::new("laz", "missing"))
4136 .expect("forward-reference stub present");
4137 assert_eq!(
4138 stub.stub_kind,
4139 Some(crate::entity::StubKind::ForwardReference)
4140 );
4141 }
4142
4143 #[test]
4149 fn readonly_deferred_target_answers_from_storage() {
4150 use crate::engine::RelateEntityArgs;
4151 use memstead_schema::workspace_config::CrossLinkValue;
4152
4153 let tmp = TempDir::new().unwrap();
4154 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4155 let mut ro_mount = lazy_folder_mount("laz", lazy_dir.to_path_buf());
4156 ro_mount.capability = crate::workspace::MountCapability::ReadOnly;
4157 let mut engine = Engine::from_mounts(vec![
4158 (
4159 folder_mount("eag", eager_dir.to_path_buf()),
4160 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4161 ),
4162 (
4163 ro_mount,
4164 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4165 ),
4166 ])
4167 .unwrap();
4168 let mut settings = crate::workspace::WorkspaceSettings::default();
4169 settings.cross_mem_links.insert(
4170 "eag".to_string(),
4171 CrossLinkValue::List(vec!["laz".to_string()]),
4172 );
4173 engine.set_settings(settings);
4174
4175 let relate = |engine: &mut Engine, to: &str| {
4176 let (actor, client) = cli_actor();
4177 engine.relate_entity(
4178 RelateEntityArgs {
4179 source: crate::EntityId::new("eag", "alpha"),
4180 expected_hash: None,
4181 rel_type: "SUPPORTS".to_string(),
4182 target: crate::EntityId::new("laz", to),
4183 remove: false,
4184 description: None,
4185 dry_run: false,
4186 },
4187 actor,
4188 Some(&client),
4189 None,
4190 )
4191 };
4192
4193 relate(&mut engine, "omega").expect("present-in-storage RO target admits");
4194 assert!(
4195 engine.mem_is_deferred("laz"),
4196 "the admit never loads the mem"
4197 );
4198
4199 let err =
4200 relate(&mut engine, "missing").expect_err("absent RO target keeps the typed refusal");
4201 assert!(
4202 matches!(err, EngineError::CrossMemTargetNotFound { .. }),
4203 "expected CROSS_MEM_TARGET_NOT_FOUND, got {err:?}"
4204 );
4205 assert!(
4206 engine.mem_is_deferred("laz"),
4207 "the refusal never loads the mem either"
4208 );
4209 }
4210
4211 #[test]
4216 fn cross_mem_link_into_lazy_mem_resolves_on_load() {
4217 let tmp = TempDir::new().unwrap();
4218 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4219 write_spec(
4220 &eager_dir,
4221 "linker",
4222 "Linker",
4223 "\nSee [[laz--omega]] for detail.\n",
4224 );
4225
4226 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4227 let target = crate::EntityId::new("laz", "omega");
4228 assert!(
4229 engine.get_entity(&target).is_none_or(|e| e.stub),
4230 "before the lazy load the cross-mem target is at most a stub, never real"
4231 );
4232
4233 engine.reload_if_stale(Some("laz"));
4234 let resolved = engine.get_entity(&target).expect("target loaded");
4235 assert!(!resolved.stub, "after the load the target is real");
4236 assert!(
4237 !engine.load_warnings().iter().any(
4238 |w| matches!(w, WarningHint::SuspiciousNestedPrefix { resolved_id, .. } if resolved_id.as_ref() == target.as_ref())
4239 ),
4240 "no lingering nested-prefix warning for a resolved cross-mem link: {:?}",
4241 engine.load_warnings()
4242 );
4243 }
4244}