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())
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 = load_workspace_schemas(schemas_dir)?;
65 Self::from_mounts_inner(mounts, extra_schemas)
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 = load_workspace_schemas(schemas_dir)?;
82 local.append(&mut extra);
83 Self::from_mounts_inner(mounts, local)
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 ) -> Result<Self, EngineError> {
90 let mut seen: std::collections::HashSet<String> =
91 std::collections::HashSet::with_capacity(mounts.len());
92 let mut mounted: Vec<MountedBackend> = Vec::with_capacity(mounts.len());
93 for (mount, backend) in mounts {
94 if !seen.insert(mount.mem.clone()) {
95 return Err(EngineError::DuplicateMem(mount.mem));
96 }
97 let last_known_head = backend.current_head().ok().flatten();
103 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
112 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
113 memstead_schema::config::parse_mem_config(&value).ok()
114 });
115 let archive_provenance =
120 backend
121 .read_archive_provenance()
122 .ok()
123 .flatten()
124 .and_then(|bytes| {
125 memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok()
126 });
127 mounted.push(MountedBackend {
128 mount,
129 backend,
130 last_known_head,
131 mem_config,
132 archive_provenance,
133 });
134 }
135
136 let builtin_schemas_only = memstead_schema::builtins::load_builtin_schemas()
146 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
147 let workspace_schemas = extra_schemas.clone();
150 let mut catalogue: Vec<Arc<memstead_schema::Schema>> =
151 Vec::with_capacity(extra_schemas.len() + builtin_schemas_only.len());
152 catalogue.extend(extra_schemas);
153 catalogue.extend(builtin_schemas_only.clone());
154 let builtin_schemas = catalogue;
155 let mut store = Store::new();
156 let mut load_errors: Vec<(PathBuf, String)> = Vec::new();
157 let mut schemas: HashMap<String, Arc<Schema>> = HashMap::with_capacity(mounted.len());
158 let fallback = engine_fallback_type();
159
160 let mem_names: Vec<String> = mounted.iter().map(|m| m.mount.mem.clone()).collect();
167 let known_suffixes: Vec<String> = mem_names
168 .iter()
169 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
170 .collect();
171 let mut load_warnings: Vec<WarningHint> = Vec::new();
172
173 for m in &mounted {
174 let config_pin = m.mem_config.as_ref().and_then(|c| c.schema.as_ref());
183 let mount_pin = m.mount.schema.as_ref();
184 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
188 && cfg != mp
189 {
190 load_warnings.push(WarningHint::SchemaPinMismatch {
191 mem: m.mount.mem.clone(),
192 config_pin: cfg.as_display(),
193 mount_pin: mp.as_display(),
194 });
195 }
196 let settled_pin = config_pin.or(mount_pin);
199 let effective_pin = m
202 .mount
203 .migration_target
204 .as_ref()
205 .or(settled_pin)
206 .ok_or_else(|| EngineError::MemConfigIncomplete {
207 mem: m.mount.mem.clone(),
208 missing_fields: vec!["schema".to_string()],
209 })?;
210 let schema = SchemaResolver::new(&builtin_schemas)
211 .resolve(effective_pin)
212 .map_err(|sources| EngineError::SchemaNotFound {
213 mem: m.mount.mem.clone(),
214 pin: effective_pin.as_display(),
215 sources,
216 })?;
217 schemas.insert(m.mount.mem.clone(), schema.clone());
218
219 let (entries, read_errors) = collect_source_entries(m.backend.as_ref())?;
220 let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
221 push_entities_into_store(
227 &mut store,
228 load_result.entities,
229 fallback.as_ref(),
230 Some(crate::entity::store_builder::LoadCollector {
231 warnings: &mut load_warnings,
232 known_suffixes: &known_suffixes,
233 mem_names: &mem_names,
234 }),
235 );
236 load_errors.extend(load_result.errors);
237 }
238
239 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
247 mounted
248 .iter()
249 .map(|m| (m.mount.mem.clone(), m.mount.capability))
250 .collect();
251 crate::entity::store_builder::validate_loaded_relations(
252 &mut store,
253 &schemas,
254 &mount_caps,
255 &mut load_warnings,
256 );
257
258 crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
263
264 let mem_router = build_mem_router_from_mounts(&mounted);
270
271 Ok(Self {
272 mounts: mounted,
273 store,
274 schemas,
275 workspace_schemas,
276 builtin_schemas: builtin_schemas_only,
277 load_errors,
278 community_memo: OnceCell::new(),
279 #[cfg(not(target_arch = "wasm32"))]
280 search_indexes_memo: OnceCell::new(),
281 settings: WorkspaceSettings::default(),
282 create_rule_set_memo: OnceCell::new(),
283 workspace_root: None,
284 load_warnings,
285 pipeline_configs: crate::pipeline_store::PipelineConfigs::default(),
286 mem_router: Arc::new(mem_router),
287 backend_factory: crate::workspace_store::instantiate_lean_backend,
288 git_branch_ops: None,
289 event_subscribers: Arc::new(std::sync::Mutex::new(
290 crate::engine::events::SubscriberRegistry::new(),
291 )),
292 pending_mem_changed: Vec::new(),
293 })
294 }
295
296 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
315 use crate::workspace_store::{
316 FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
317 instantiate_lean_backend,
318 };
319
320 let workspace = match detect_layout(workspace_root) {
321 Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
325 Some(ws) => ws,
326 None => {
327 return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
328 }
329 },
330 Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
331 };
332
333 let settings = workspace.settings.clone();
334 let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
335 Vec::with_capacity(workspace.mounts.len());
336 for mount in workspace.mounts {
337 let backend = instantiate_lean_backend(&mount)?;
338 mounts.push((mount, backend));
339 }
340 use crate::schema_source::SchemaSource as _;
349 let local = crate::schema_source::FolderSchemaSource::for_workspace(workspace_root)
350 .read_schemas()
351 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
352 let mut engine = Engine::from_mounts_inner(mounts, local)?;
353 engine.set_settings(settings);
354 engine.workspace_root = Some(workspace_root.to_path_buf());
355 engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
368 workspace_root,
369 )?);
370 let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
375 Ok(engine)
376 }
377}
378
379fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
412 let mut router = MemRouterSnapshot::new();
413 for m in mounts {
414 match m.mount.capability {
415 MountCapability::Write => {
416 let dir: Option<PathBuf> = match &m.mount.storage {
417 MountStorage::Folder { path } => Some(path.clone()),
418 MountStorage::GitBranch { .. } => None,
419 MountStorage::Archive { .. } => None,
420 MountStorage::InMemory => None,
424 };
425 router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
426 }
427 MountCapability::ReadOnly => match &m.mount.storage {
428 MountStorage::Archive { path } => {
429 router.add_read_only(m.mount.mem.clone(), path.clone());
430 }
431 MountStorage::Folder { path } => {
432 router.add_read_only(m.mount.mem.clone(), path.clone());
433 }
434 MountStorage::GitBranch { gitdir, .. } => {
435 router.add_read_only(m.mount.mem.clone(), gitdir.clone());
436 }
437 MountStorage::InMemory => {}
443 },
444 }
445 }
446 router
447}
448
449pub fn resolve_builtin_schema_pin_pub(
454 pin: &memstead_schema::SchemaRef,
455 catalogue: &[Arc<memstead_schema::Schema>],
456) -> Option<Arc<memstead_schema::Schema>> {
457 resolve_builtin_schema_pin(pin, catalogue)
458}
459
460pub struct SchemaResolver<'a> {
475 catalogue: &'a [Arc<memstead_schema::Schema>],
476}
477
478impl<'a> SchemaResolver<'a> {
479 pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
482 Self { catalogue }
483 }
484
485 pub fn resolve(
489 &self,
490 pin: &memstead_schema::SchemaRef,
491 ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
492 {
493 resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
494 crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
495 &pin.name,
496 &pin.version,
497 self.catalogue,
498 )
499 })
500 }
501}
502
503pub(crate) fn load_workspace_schemas(
509 schemas_dir: Option<&Path>,
510) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
511 let Some(dir) = schemas_dir else {
512 return Ok(Vec::new());
513 };
514 if !dir.is_dir() {
515 return Ok(Vec::new());
516 }
517 let entries = match std::fs::read_dir(dir) {
518 Ok(e) => e,
519 Err(_) => return Ok(Vec::new()),
520 };
521 let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
522 for entry in entries.flatten() {
523 let path = entry.path();
524 if !path.is_dir() {
525 continue;
526 }
527 if !path.join("schema.yaml").is_file() {
528 continue;
529 }
530 let schema = memstead_schema::load_schema_from_dir(&path)
531 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
532 schemas.push(Arc::new(schema));
533 }
534 Ok(schemas)
535}
536
537pub(super) fn resolve_builtin_schema_pin(
538 pin: &memstead_schema::SchemaRef,
539 catalogue: &[Arc<memstead_schema::Schema>],
540) -> Option<Arc<memstead_schema::Schema>> {
541 catalogue
542 .iter()
543 .find(|s| {
544 let id = s.id();
545 id.0 == pin.name && id.1 == pin.version
546 })
547 .cloned()
548}
549
550pub(super) fn collect_source_entries(
551 backend: &dyn MemBackend,
552) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
553 let paths = backend.list_entities()?;
554 let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
555 let mut errors: Vec<SourceReadError> = Vec::new();
556 for path in paths {
557 match backend.read_entity(&path) {
558 Ok(Some(bytes)) => match String::from_utf8(bytes) {
559 Ok(content) => entries.push(SourceEntry {
560 relative_path: path.to_string_lossy().into_owned(),
561 source_path: path.clone(),
562 content,
563 }),
564 Err(e) => errors.push(SourceReadError {
565 source_path: path,
566 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
567 }),
568 },
569 Ok(None) => {
570 }
572 Err(e) => errors.push(SourceReadError {
573 source_path: path,
574 error: std::io::Error::other(e.to_string()),
575 }),
576 }
577 }
578 Ok((entries, errors))
579}
580
581#[cfg(test)]
582mod tests {
583
584 use std::path::Path;
585
586 use memstead_schema::SchemaRef;
587 use tempfile::TempDir;
588
589 use crate::backend::MemBackend;
590 use crate::engine::test_helpers::*;
591 use crate::engine::{Engine, EngineError};
592 use crate::ops::WarningHint;
593 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
594 use crate::vcs::CommitContext;
595 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
596
597 #[test]
601 fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
602 let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
603 let resolver = super::SchemaResolver::new(&catalogue);
604
605 let ok: SchemaRef = "default@1.0.0".parse().unwrap();
606 assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
607
608 let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
609 let sources = resolver.resolve(&miss).unwrap_err();
610 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
611 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
612 assert!(sources.iter().all(|s| !s.pinned_version_match));
613 }
614
615 #[test]
616 fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
617 let engine = Engine::from_mounts(Vec::new()).unwrap();
618 assert!(engine.mem_names().is_empty());
619 match engine.list_entities("missing") {
620 Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
621 other => panic!("expected UnknownMem, got {other:?}"),
622 }
623 }
624
625 #[test]
626 fn duplicate_mem_names_rejected_at_construction() {
627 let tmp = TempDir::new().unwrap();
628 let writer1: Box<dyn MemBackend> =
629 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
630 let writer2: Box<dyn MemBackend> =
631 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
632 let err = Engine::from_mounts(vec![
633 (folder_mount("specs", tmp.path().to_path_buf()), writer1),
634 (folder_mount("specs", tmp.path().to_path_buf()), writer2),
635 ])
636 .unwrap_err();
637 assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
638 }
639
640 #[test]
641 fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
642 let tmp = TempDir::new().unwrap();
648 let mem_dir = tmp.path().to_path_buf();
649 let body =
650 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
651 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
652
653 let writer = FilesystemMemWriter::new(mem_dir.clone());
654 let engine = Engine::from_mounts(vec![(
655 folder_mount("specs", mem_dir),
656 Box::new(writer) as Box<dyn MemBackend>,
657 )])
658 .unwrap();
659
660 let warnings = engine.load_warnings();
661 assert!(
662 warnings
663 .iter()
664 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
665 "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
666 );
667 }
668
669 #[test]
675 fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
676 let tmp = TempDir::new().unwrap();
677 let mem_dir = tmp.path().to_path_buf();
678 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
681 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
682 std::fs::write(mem_dir.join("target.md"), target).unwrap();
683 std::fs::write(mem_dir.join("source.md"), source).unwrap();
684
685 let writer = FilesystemMemWriter::new(mem_dir.clone());
686 let engine = Engine::from_mounts(vec![(
687 folder_mount("specs", mem_dir),
688 Box::new(writer) as Box<dyn MemBackend>,
689 )])
690 .unwrap();
691
692 let source_id = crate::entity::EntityId::new("specs", "source");
693 let target_id = crate::entity::EntityId::new("specs", "target");
694 let source_entity = engine.get_entity(&source_id).expect("source loaded");
695 assert!(
698 source_entity.relationships.is_empty(),
699 "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
700 source_entity.relationships,
701 );
702 let outgoing: Vec<_> = engine
704 .store()
705 .outgoing(&source_id)
706 .iter()
707 .filter(|e| e.rel_type == "MADE_UP_TYPE")
708 .collect();
709 assert!(
710 outgoing.is_empty(),
711 "MADE_UP_TYPE edge must be dropped from the store"
712 );
713 let parsed_invalid: Vec<_> = engine
715 .load_warnings()
716 .iter()
717 .filter_map(|w| match w {
718 WarningHint::ParsedRelationInvalid {
719 entity_id,
720 rel_type,
721 target,
722 reason,
723 origin,
724 recovery,
725 } => Some((
726 entity_id.clone(),
727 rel_type.clone(),
728 target.clone(),
729 reason.clone(),
730 origin.clone(),
731 recovery.clone(),
732 )),
733 _ => None,
734 })
735 .collect();
736 assert_eq!(
737 parsed_invalid.len(),
738 1,
739 "expected one warning, got {parsed_invalid:?}"
740 );
741 assert_eq!(parsed_invalid[0].0, source_id);
742 assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
743 assert_eq!(parsed_invalid[0].2, target_id);
744 assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
745 assert_eq!(parsed_invalid[0].4, "writable");
746 let recovery = parsed_invalid[0]
748 .5
749 .as_ref()
750 .expect("writable-origin warning must carry recovery");
751 assert_eq!(
752 recovery.kind,
753 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
754 );
755 assert_eq!(recovery.source_id, parsed_invalid[0].0);
756 assert_eq!(recovery.target_id, parsed_invalid[0].2);
757 assert_eq!(recovery.rel_type, parsed_invalid[0].1);
758 }
759
760 #[test]
768 fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
769 let tmp = TempDir::new().unwrap();
770 let mem_dir = tmp.path().to_path_buf();
771 let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
775 let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
776 std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
777 std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
778
779 let writer = FilesystemMemWriter::new(mem_dir.clone());
780 let engine = Engine::from_mounts(vec![(
781 folder_mount("specs", mem_dir),
782 Box::new(writer) as Box<dyn MemBackend>,
783 )])
784 .unwrap();
785
786 let alpha_id = crate::entity::EntityId::new("specs", "alpha");
787 let beta_id = crate::entity::EntityId::new("specs", "beta");
788
789 assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
792 assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
793
794 let surviving: Vec<_> = engine
797 .store()
798 .all_entities()
799 .flat_map(|e| {
800 engine
801 .store()
802 .outgoing(&e.id)
803 .iter()
804 .filter(|edge| edge.rel_type == "PART_OF")
805 .map(|edge| (e.id.clone(), edge.target.clone()))
806 .collect::<Vec<_>>()
807 })
808 .collect();
809 assert_eq!(
810 surviving.len(),
811 1,
812 "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
813 );
814
815 let cycle_drops: Vec<_> = engine
818 .load_warnings()
819 .iter()
820 .filter_map(|w| match w {
821 WarningHint::ParsedRelationInvalid {
822 entity_id,
823 rel_type,
824 target,
825 reason,
826 ..
827 } if reason == "cycle" => {
828 Some((entity_id.clone(), rel_type.clone(), target.clone()))
829 }
830 _ => None,
831 })
832 .collect();
833 assert_eq!(
834 cycle_drops.len(),
835 1,
836 "exactly one cycle warning must fire, got {cycle_drops:?}",
837 );
838 let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
840 assert_eq!(dropped_rel_type, "PART_OF");
841 let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
842 let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
843 assert!(
844 is_alpha_to_beta || is_beta_to_alpha,
845 "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
846 );
847 assert_ne!(
849 (&surviving[0].0, &surviving[0].1),
850 (dropped_from, dropped_to),
851 "surviving edge must differ from the dropped one",
852 );
853 }
854
855 #[test]
865 fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
866 use crate::engine::test_helpers::write_schema_files_with_default_type;
867
868 let tmp = TempDir::new().unwrap();
869 let schemas_dir = tmp.path().join("schemas");
870 std::fs::create_dir_all(&schemas_dir).unwrap();
871 let manifest = r#"name: shape-test
875version: 0.1.0
876description: shape-constraint schema
877when_to_use: tests
878types:
879 - doc
880 - actor
881relationships:
882 mode: strict
883 definitions:
884 - name: OWNS
885 description: actor owns doc
886 default_weight: 1.0
887 source_types: [actor]
888 target_types: [doc]
889 - name: _default
890 description: fallback
891 default_weight: 1.0
892community:
893 resolution: 1.0
894 seed: 42
895"#;
896 write_schema_files_with_default_type(
897 &schemas_dir,
898 "shape-test",
899 manifest,
900 &["doc", "actor"],
901 );
902
903 let mem_dir = tmp.path().join("mem");
904 std::fs::create_dir_all(&mem_dir).unwrap();
905 let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
909 let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
910 std::fs::write(mem_dir.join("target.md"), target).unwrap();
911 std::fs::write(mem_dir.join("source.md"), source).unwrap();
912
913 let writer = FilesystemMemWriter::new(mem_dir.clone());
914 let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
915 let mount = Mount {
916 mem: "specs".to_string(),
917 schema: Some(pin),
918 storage: MountStorage::Folder { path: mem_dir },
919 capability: MountCapability::Write,
920 lifecycle: MountLifecycle::Eager,
921 cross_linkable: true,
922 migration_target: None,
923 };
924 let engine = Engine::from_mounts_with_schemas_dir(
925 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
926 Some(&schemas_dir),
927 )
928 .unwrap();
929
930 let source_id = crate::entity::EntityId::new("specs", "source");
931 let target_id = crate::entity::EntityId::new("specs", "target");
932
933 let shape_drops: Vec<_> = engine
934 .load_warnings()
935 .iter()
936 .filter_map(|w| match w {
937 WarningHint::ParsedRelationInvalid {
938 entity_id,
939 rel_type,
940 target,
941 reason,
942 origin,
943 recovery,
944 } if reason == "shape" => Some((
945 entity_id.clone(),
946 rel_type.clone(),
947 target.clone(),
948 origin.clone(),
949 recovery.clone(),
950 )),
951 _ => None,
952 })
953 .collect();
954 assert_eq!(
955 shape_drops.len(),
956 1,
957 "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
958 engine.load_warnings(),
959 );
960 let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
961 shape_drops.into_iter().next().unwrap();
962 assert_eq!(drop_from, source_id);
963 assert_eq!(drop_type, "OWNS");
964 assert_eq!(drop_to, target_id);
965 assert_eq!(drop_origin, "writable");
966 let recovery = drop_recovery.expect("writable origin must carry recovery");
969 assert_eq!(
970 recovery.kind,
971 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
972 );
973 assert_eq!(recovery.source_id, source_id);
974 assert_eq!(recovery.target_id, target_id);
975 assert_eq!(recovery.rel_type, "OWNS");
976 }
977
978 #[test]
984 fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
985 let tmp = TempDir::new().unwrap();
986 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
990 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
991 let archive_path = build_archive(
992 tmp.path(),
993 "ext",
994 &[
995 ("target.md", target.as_bytes()),
996 ("source.md", source.as_bytes()),
997 ],
998 );
999
1000 let engine = Engine::from_mounts(vec![(
1001 archive_mount("external", archive_path.clone()),
1002 Box::new(ArchiveBackend::new(archive_path)),
1003 )])
1004 .unwrap();
1005
1006 let invalid: Vec<_> = engine
1007 .load_warnings()
1008 .iter()
1009 .filter_map(|w| match w {
1010 WarningHint::ParsedRelationInvalid {
1011 rel_type,
1012 reason,
1013 origin,
1014 recovery,
1015 ..
1016 } => Some((
1017 rel_type.clone(),
1018 reason.clone(),
1019 origin.clone(),
1020 recovery.clone(),
1021 )),
1022 _ => None,
1023 })
1024 .collect();
1025 assert_eq!(
1026 invalid.len(),
1027 1,
1028 "expected one parse-time drop on the readonly mount, got {invalid:?}",
1029 );
1030 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1031 assert_eq!(invalid[0].1, "unknown_rel_type");
1032 assert_eq!(invalid[0].2, "readonly");
1033 assert!(
1034 invalid[0].3.is_none(),
1035 "readonly-origin warning must omit the recovery hint, got {:?}",
1036 invalid[0].3,
1037 );
1038 }
1039
1040 #[test]
1041 fn load_on_init_populates_store_from_folder_mount() {
1042 let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1045
1046 let tmp = TempDir::new().unwrap();
1047 let mem_dir = tmp.path().to_path_buf();
1048 let writer = FilesystemMemWriter::new(mem_dir.clone());
1049 <FilesystemMemWriter as MemWriter>::write_entity(
1050 &writer,
1051 Path::new("hello.md"),
1052 body.as_bytes(),
1053 )
1054 .unwrap();
1055 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1056 .unwrap();
1057
1058 let engine = Engine::from_mounts(vec![(
1059 folder_mount("specs", mem_dir),
1060 Box::new(writer) as Box<dyn MemBackend>,
1061 )])
1062 .unwrap();
1063
1064 assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1066 let id = crate::EntityId::new("specs", "hello");
1067 let entity = engine.get_entity(&id).expect("entity must be present");
1068 assert_eq!(entity.title, "Hello");
1069 assert_eq!(entity.entity_type, "spec");
1070 assert!(engine.load_errors().is_empty());
1071 assert_eq!(engine.schemas().len(), 1);
1073 assert!(engine.schemas().contains_key("specs"));
1074 }
1075
1076 #[test]
1077 fn load_on_init_populates_store_from_archive_mount() {
1078 let body =
1079 "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1080
1081 let tmp = TempDir::new().unwrap();
1082 let archive_path =
1083 build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1084
1085 let engine = Engine::from_mounts(vec![(
1086 archive_mount("external", archive_path.clone()),
1087 Box::new(ArchiveBackend::new(archive_path)),
1088 )])
1089 .unwrap();
1090
1091 let id = crate::EntityId::new("external", "from-archive");
1092 let entity = engine.get_entity(&id).expect("entity must be present");
1093 assert_eq!(entity.title, "From Archive");
1094 assert!(engine.load_errors().is_empty());
1095 }
1096
1097 #[test]
1098 fn load_on_init_populates_store_from_heterogeneous_mounts() {
1099 let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1100 let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1101
1102 let tmp = TempDir::new().unwrap();
1103
1104 let folder_dir = tmp.path().join("folder-mem");
1105 std::fs::create_dir_all(&folder_dir).unwrap();
1106 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1107 <FilesystemMemWriter as MemWriter>::write_entity(
1108 &folder_writer,
1109 Path::new("local.md"),
1110 folder_body.as_bytes(),
1111 )
1112 .unwrap();
1113 <FilesystemMemWriter as MemWriter>::commit(
1114 &folder_writer,
1115 "seed",
1116 &CommitContext::internal(),
1117 )
1118 .unwrap();
1119
1120 let archive_path = build_archive(
1121 tmp.path(),
1122 "external",
1123 &[("external.md", archive_body.as_bytes())],
1124 );
1125
1126 let engine = Engine::from_mounts(vec![
1127 (
1128 folder_mount("local", folder_dir),
1129 Box::new(folder_writer) as Box<dyn MemBackend>,
1130 ),
1131 (
1132 archive_mount("external", archive_path.clone()),
1133 Box::new(ArchiveBackend::new(archive_path)),
1134 ),
1135 ])
1136 .unwrap();
1137
1138 assert_eq!(engine.store().len(), 2);
1140 assert!(
1141 engine
1142 .get_entity(&crate::EntityId::new("local", "local"))
1143 .is_some()
1144 );
1145 assert!(
1146 engine
1147 .get_entity(&crate::EntityId::new("external", "external"))
1148 .is_some()
1149 );
1150 }
1151
1152 #[test]
1153 fn load_on_init_collects_per_file_parse_errors_without_failing() {
1154 let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1158 let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1159
1160 let tmp = TempDir::new().unwrap();
1161 let mem_dir = tmp.path().to_path_buf();
1162 let writer = FilesystemMemWriter::new(mem_dir.clone());
1163 <FilesystemMemWriter as MemWriter>::write_entity(
1164 &writer,
1165 Path::new("good.md"),
1166 good.as_bytes(),
1167 )
1168 .unwrap();
1169 <FilesystemMemWriter as MemWriter>::write_entity(
1170 &writer,
1171 Path::new("bad.md"),
1172 bad.as_bytes(),
1173 )
1174 .unwrap();
1175 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1176 .unwrap();
1177
1178 let engine = Engine::from_mounts(vec![(
1179 folder_mount("specs", mem_dir),
1180 Box::new(writer) as Box<dyn MemBackend>,
1181 )])
1182 .unwrap();
1183
1184 assert!(
1186 engine
1187 .get_entity(&crate::EntityId::new("specs", "good"))
1188 .is_some(),
1189 "good.md must parse and reach the store"
1190 );
1191 let bad_known_to_engine = engine
1196 .get_entity(&crate::EntityId::new("specs", "bad"))
1197 .is_some()
1198 || !engine.load_errors().is_empty();
1199 assert!(
1200 bad_known_to_engine,
1201 "bad.md must either parse or surface in load_errors"
1202 );
1203 }
1204
1205 #[test]
1206 fn empty_mount_list_yields_empty_store() {
1207 let engine = Engine::from_mounts(Vec::new()).unwrap();
1208 assert!(engine.store().is_empty());
1209 assert!(engine.schemas().is_empty());
1210 assert!(engine.load_errors().is_empty());
1211 }
1212
1213 #[test]
1216 fn from_workspace_root_errors_for_empty_layout() {
1217 let tmp = TempDir::new().unwrap();
1218 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1219 match err {
1220 crate::BootError::NotInitialised(p) => {
1221 assert_eq!(p, tmp.path());
1222 }
1223 other => panic!("expected NotInitialised, got {other:?}"),
1224 }
1225 }
1226
1227 #[test]
1228 fn from_workspace_root_loads_new_two_layer_layout() {
1229 let tmp = TempDir::new().unwrap();
1230 let mem_dir = tmp.path().join("mem");
1231 std::fs::create_dir_all(&mem_dir).unwrap();
1232 std::fs::write(
1233 mem_dir.join("hello.md"),
1234 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1235 )
1236 .unwrap();
1237
1238 let memstead = tmp.path().join(".memstead");
1239 std::fs::create_dir_all(&memstead).unwrap();
1240 std::fs::write(
1241 memstead.join("workspace.toml"),
1242 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1243 )
1244 .unwrap();
1245 use crate::workspace_store::WorkspaceStoreAdapter;
1248 let store = crate::FileWorkspaceStore::new();
1249 store
1250 .save_state(
1251 tmp.path(),
1252 &crate::workspace::Workspace {
1253 mounts: vec![folder_mount("specs", mem_dir)],
1254 settings: crate::workspace::WorkspaceSettings::default(),
1255 },
1256 )
1257 .unwrap();
1258
1259 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1260 assert_eq!(engine.mem_names(), vec!["specs"]);
1261 let entity = engine
1262 .get_entity(&crate::EntityId::new("specs", "hello"))
1263 .expect("seeded entity must load through from_workspace_root");
1264 assert_eq!(entity.title, "Hello");
1265 }
1266
1267 #[test]
1272 fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1273 use crate::pipeline::{
1274 Facet, Ingest, IngestMode, IngestTrigger, Medium, MediumType, Projection,
1275 };
1276 let tmp = TempDir::new().unwrap();
1277 let mem_dir = tmp.path().join("mem");
1278 std::fs::create_dir_all(&mem_dir).unwrap();
1279
1280 let memstead = tmp.path().join(".memstead");
1281 std::fs::create_dir_all(&memstead).unwrap();
1282 std::fs::write(
1283 memstead.join("workspace.toml"),
1284 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1285 )
1286 .unwrap();
1287 use crate::workspace_store::WorkspaceStoreAdapter;
1288 crate::FileWorkspaceStore::new()
1289 .save_state(
1290 tmp.path(),
1291 &crate::workspace::Workspace {
1292 mounts: vec![folder_mount("specs", mem_dir)],
1293 settings: crate::workspace::WorkspaceSettings::default(),
1294 },
1295 )
1296 .unwrap();
1297
1298 crate::pipeline_store::write_medium(
1300 tmp.path(),
1301 "specs",
1302 "src",
1303 &Medium {
1304 name: "src".to_string(),
1305 medium_type: MediumType::Codebase,
1306 pointer: "..".to_string(),
1307 },
1308 )
1309 .unwrap();
1310 crate::pipeline_store::write_facet(
1311 tmp.path(),
1312 "specs",
1313 "view",
1314 &Facet {
1315 name: "view".to_string(),
1316 medium: "src".to_string(),
1317 scope: Vec::new(),
1318 engagement: None,
1319 preparation: None,
1320 },
1321 )
1322 .unwrap();
1323 crate::pipeline_store::write_projection(
1324 tmp.path(),
1325 "specs",
1326 "graph",
1327 &Projection {
1328 intent: None,
1329 source_facets: vec!["view".to_string()],
1330 reference_mems: Vec::new(),
1331 destination_mem: "specs".to_string(),
1332 },
1333 )
1334 .unwrap();
1335 crate::pipeline_store::write_ingest(
1336 tmp.path(),
1337 "specs-graph",
1338 &Ingest {
1339 projection: "specs/graph".to_string(),
1340 mode: IngestMode::Discovery,
1341 trigger: IngestTrigger::Loop,
1342 batch_size: 10,
1343 deny_paths: Vec::new(),
1344 },
1345 )
1346 .unwrap();
1347
1348 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1349 let pc = engine.pipeline_configs();
1350 assert_eq!(pc.mediums.len(), 1, "one medium enumerated");
1351 assert_eq!(pc.mediums[0].config.medium_type, MediumType::Codebase);
1352 assert_eq!(pc.facets.len(), 1, "one facet enumerated");
1353 assert_eq!(pc.facets[0].config.medium, "src");
1354 assert_eq!(pc.projections.len(), 1, "one projection enumerated");
1355 assert_eq!(pc.projections[0].config.destination_mem, "specs");
1356 assert_eq!(pc.ingests.len(), 1, "one ingest enumerated");
1357 assert_eq!(pc.ingests[0].name, "specs-graph");
1358 assert_eq!(pc.ingests[0].config.mode, IngestMode::Discovery);
1359 }
1360
1361 #[test]
1366 fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
1367 use crate::pipeline::{Facet, Medium, MediumType};
1368 use crate::workspace_store::WorkspaceStoreAdapter;
1369
1370 let tmp = TempDir::new().unwrap();
1371 let mem_dir = tmp.path().join("mem");
1372 std::fs::create_dir_all(&mem_dir).unwrap();
1373 let memstead = tmp.path().join(".memstead");
1374 std::fs::create_dir_all(&memstead).unwrap();
1375 std::fs::write(
1376 memstead.join("workspace.toml"),
1377 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1378 )
1379 .unwrap();
1380 crate::FileWorkspaceStore::new()
1381 .save_state(
1382 tmp.path(),
1383 &crate::workspace::Workspace {
1384 mounts: vec![folder_mount("specs", mem_dir)],
1385 settings: crate::workspace::WorkspaceSettings::default(),
1386 },
1387 )
1388 .unwrap();
1389
1390 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
1391 assert!(engine.pipeline_configs().mediums.is_empty());
1392
1393 engine
1394 .add_medium(
1395 "specs",
1396 "src",
1397 &Medium {
1398 name: "src".to_string(),
1399 medium_type: MediumType::Codebase,
1400 pointer: "..".to_string(),
1401 },
1402 )
1403 .unwrap();
1404 assert_eq!(engine.pipeline_configs().mediums.len(), 1);
1406 assert_eq!(engine.pipeline_configs().mediums[0].name, "src");
1407
1408 engine
1409 .add_facet(
1410 "specs",
1411 "view",
1412 &Facet {
1413 name: "view".to_string(),
1414 medium: "src".to_string(),
1415 scope: Vec::new(),
1416 engagement: None,
1417 preparation: None,
1418 },
1419 )
1420 .unwrap();
1421 let err = engine.delete_medium("specs", "src").unwrap_err();
1423 assert!(
1424 matches!(
1425 err,
1426 crate::pipeline_edit::PipelineEditError::Referenced { .. }
1427 ),
1428 "got {err:?}"
1429 );
1430 assert_eq!(
1431 engine.pipeline_configs().mediums.len(),
1432 1,
1433 "refused delete left the medium in place"
1434 );
1435
1436 engine
1438 .add_medium_json(
1439 "specs",
1440 "docs",
1441 r#"{"name":"docs","type":"filesystem","pointer":"./docs"}"#,
1442 )
1443 .unwrap();
1444 assert!(
1445 engine
1446 .pipeline_configs()
1447 .mediums
1448 .iter()
1449 .any(|m| m.name == "docs" && m.config.medium_type == MediumType::Filesystem),
1450 "add_medium_json should land a filesystem medium"
1451 );
1452 let err = engine
1454 .add_medium_json("specs", "bad", "{ not json")
1455 .unwrap_err();
1456 assert!(
1457 matches!(
1458 err,
1459 crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
1460 ),
1461 "got {err:?}"
1462 );
1463
1464 let json = engine.pipeline_configs_json();
1466 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1467 let mediums = parsed["mediums"].as_array().unwrap();
1468 assert_eq!(mediums.len(), 2, "src + docs");
1469 assert!(
1470 mediums
1471 .iter()
1472 .any(|m| m["name"] == "docs" && m["config"]["type"] == "filesystem"),
1473 "pipeline_configs_json should carry the docs medium: {json}"
1474 );
1475 }
1476
1477 #[test]
1483 fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
1484 use crate::engine::test_helpers::write_schema_files_with_default_type;
1485
1486 let tmp = TempDir::new().unwrap();
1487 let mem_dir = tmp.path().join("mem");
1488 std::fs::create_dir_all(&mem_dir).unwrap();
1489
1490 let authored_dir = tmp.path().join(".memstead").join("schemas");
1492 let manifest = r#"name: authored
1493version: 0.1.0
1494description: an authored-in-workspace test schema
1495when_to_use: tests
1496types:
1497 - doc
1498relationships:
1499 mode: strict
1500 definitions:
1501 - name: _default
1502 description: fallback
1503 default_weight: 1.0
1504community:
1505 resolution: 1.0
1506 seed: 42
1507"#;
1508 write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
1509
1510 let memstead = tmp.path().join(".memstead");
1512 std::fs::create_dir_all(&memstead).unwrap();
1513 std::fs::write(
1514 memstead.join("workspace.toml"),
1515 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1516 )
1517 .unwrap();
1518 let mount = Mount {
1519 mem: "specs".to_string(),
1520 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
1521 storage: MountStorage::Folder { path: mem_dir },
1522 capability: MountCapability::Write,
1523 lifecycle: MountLifecycle::Eager,
1524 cross_linkable: true,
1525 migration_target: None,
1526 };
1527 use crate::workspace_store::WorkspaceStoreAdapter;
1528 crate::FileWorkspaceStore::new()
1529 .save_state(
1530 tmp.path(),
1531 &crate::workspace::Workspace {
1532 mounts: vec![mount],
1533 settings: crate::workspace::WorkspaceSettings::default(),
1534 },
1535 )
1536 .unwrap();
1537
1538 let engine = Engine::from_workspace_root(tmp.path())
1541 .expect("authored schema at .memstead/schemas/ must resolve at boot");
1542 assert_eq!(engine.mem_names(), vec!["specs"]);
1543 }
1544
1545 #[test]
1546 fn from_workspace_root_propagates_mem_management_settings() {
1547 let tmp = TempDir::new().unwrap();
1552 let mem_dir = tmp.path().join("mem");
1553 std::fs::create_dir_all(&mem_dir).unwrap();
1554
1555 let memstead = tmp.path().join(".memstead");
1556 std::fs::create_dir_all(&memstead).unwrap();
1557 std::fs::write(
1558 memstead.join("workspace.toml"),
1559 r#"format = "memstead-git-branch-2"
1560
1561[persistence_adapter]
1562name = "file-two-layer"
1563
1564[[mem_management.create]]
1565pattern = "exec-*"
1566schemas = ["default@1.0.0"]
1567
1568[[mem_management.delete]]
1569pattern = "exec-*"
1570"#,
1571 )
1572 .unwrap();
1573 use crate::workspace_store::WorkspaceStoreAdapter;
1574 let store = crate::FileWorkspaceStore::new();
1575 store
1576 .save_state(
1577 tmp.path(),
1578 &crate::workspace::Workspace {
1579 mounts: vec![folder_mount("specs", mem_dir)],
1580 settings: crate::workspace::WorkspaceSettings::default(),
1581 },
1582 )
1583 .unwrap();
1584
1585 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1586 let s = engine.settings();
1587 assert_eq!(s.mem_create_rules.len(), 1);
1588 assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
1589 assert_eq!(
1590 s.mem_create_rules[0].schemas,
1591 vec!["default@1.0.0".to_string()]
1592 );
1593 assert_eq!(s.mem_delete_rules.len(), 1);
1594 assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
1595 }
1596
1597 #[test]
1598 fn from_mounts_rejects_unknown_schema_pin_with_typed_error() {
1599 let tmp = TempDir::new().unwrap();
1600 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1601 let mount = Mount {
1602 mem: "specs".to_string(),
1603 schema: Some(SchemaRef::new(
1604 "totally-not-a-schema",
1605 semver::Version::new(1, 0, 0),
1606 )),
1607 storage: MountStorage::Folder {
1608 path: tmp.path().to_path_buf(),
1609 },
1610 capability: MountCapability::Write,
1611 lifecycle: MountLifecycle::Eager,
1612 cross_linkable: true,
1613 migration_target: None,
1614 };
1615 let err = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
1616 .unwrap_err();
1617 match err {
1618 EngineError::SchemaNotFound { mem, pin, sources } => {
1619 assert_eq!(mem, "specs");
1620 assert_eq!(pin, "totally-not-a-schema@1.0.0");
1621 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
1624 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1625 assert!(sources.iter().all(|s| !s.pinned_version_match));
1626 assert_eq!(
1627 sources
1628 .iter()
1629 .find(|s| s.source == "remote")
1630 .unwrap()
1631 .status,
1632 Some("not_configured"),
1633 );
1634 }
1635 other => panic!("expected SchemaNotFound, got {other:?}"),
1636 }
1637 }
1638
1639 #[test]
1646 fn mem_config_schema_is_authoritative_over_mount_pin() {
1647 let tmp = TempDir::new().unwrap();
1648 let mem_dir = tmp.path().to_path_buf();
1649 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1650 std::fs::write(
1651 mem_dir.join(".memstead").join("config.json"),
1652 r#"{"schema":"software@0.1.0"}"#,
1653 )
1654 .unwrap();
1655 let writer = FilesystemMemWriter::new(mem_dir.clone());
1656 let mount = Mount {
1657 mem: "specs".to_string(),
1658 schema: Some(SchemaRef::new(
1659 "totally-not-a-schema",
1660 semver::Version::new(9, 9, 9),
1661 )),
1662 storage: MountStorage::Folder { path: mem_dir },
1663 capability: MountCapability::Write,
1664 lifecycle: MountLifecycle::Eager,
1665 cross_linkable: true,
1666 migration_target: None,
1667 };
1668 let engine = Engine::from_mounts(vec![(
1669 mount,
1670 Box::new(writer) as Box<dyn MemBackend>,
1671 )])
1672 .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
1673
1674 let mismatch = engine
1675 .load_warnings()
1676 .iter()
1677 .find_map(|w| match w {
1678 WarningHint::SchemaPinMismatch {
1679 mem,
1680 config_pin,
1681 mount_pin,
1682 } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
1683 _ => None,
1684 })
1685 .expect("SchemaPinMismatch warning must surface naming both pins");
1686 assert_eq!(mismatch.0, "specs");
1687 assert_eq!(mismatch.1, "software@0.1.0");
1688 assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
1689 }
1690
1691 #[test]
1692 fn from_workspace_root_rejects_git_branch_mount_with_typed_error() {
1693 let tmp = TempDir::new().unwrap();
1694 let memstead = tmp.path().join(".memstead");
1695 std::fs::create_dir_all(&memstead).unwrap();
1696 std::fs::write(
1697 memstead.join("workspace.toml"),
1698 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1699 )
1700 .unwrap();
1701 let state_dir = memstead.join("state");
1704 std::fs::create_dir_all(&state_dir).unwrap();
1705 std::fs::write(
1706 state_dir.join("mounts.json"),
1707 r#"{
1708 "format": "memstead-mounts-3",
1709 "mounts": [
1710 {
1711 "mem": "specs",
1712 "schema": "default@1.0.0",
1713 "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
1714 "capability": "write",
1715 "lifecycle": "eager",
1716 "cross_linkable": true
1717 }
1718 ]
1719 }"#,
1720 )
1721 .unwrap();
1722 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1723 match err {
1724 crate::BootError::Instantiate(
1725 crate::workspace_store::InstantiateError::GitBranchRequiresMemRepoFeature { mem },
1726 ) => {
1727 assert_eq!(mem, "specs");
1728 }
1729 other => panic!("expected Instantiate(GitBranchRequiresMemRepoFeature), got {other:?}"),
1730 }
1731 }
1732
1733 #[test]
1734 fn from_workspace_root_roots_standalone_folder_mem() {
1735 let tmp = TempDir::new().unwrap();
1739 let root = tmp.path();
1740 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1741 std::fs::write(
1742 root.join(".memstead").join("config.json"),
1743 r#"{"schema":"default@1.0.0"}"#,
1744 )
1745 .unwrap();
1746 std::fs::write(
1748 root.join("hello.md"),
1749 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
1750 )
1751 .unwrap();
1752
1753 let engine = Engine::from_workspace_root(root)
1754 .expect("a bare folder mem must root as a one-mount workspace");
1755 assert_eq!(engine.stats().mem_count, 1, "exactly one mount");
1756 assert!(
1757 engine.stats().entity_count >= 1,
1758 "the standalone mem's entity must load"
1759 );
1760 }
1761
1762 #[test]
1763 fn from_workspace_root_still_rejects_truly_empty_dir() {
1764 let tmp = TempDir::new().unwrap();
1768 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1769 assert!(
1770 matches!(err, crate::BootError::NotInitialised(_)),
1771 "got {err:?}"
1772 );
1773 }
1774
1775 }