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 declared_origins: HashMap::new(),
284 workspace_root: None,
285 load_warnings,
286 pipeline_configs: crate::pipeline_store::PipelineConfigs::default(),
287 mem_router: Arc::new(mem_router),
288 backend_factory: crate::workspace_store::instantiate_lean_backend,
289 git_branch_ops: None,
290 event_subscribers: Arc::new(std::sync::Mutex::new(
291 crate::engine::events::SubscriberRegistry::new(),
292 )),
293 pending_mem_changed: Vec::new(),
294 })
295 }
296
297 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
316 use crate::workspace_store::{
317 FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
318 instantiate_lean_backend,
319 };
320
321 let workspace = match detect_layout(workspace_root) {
322 Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
326 Some(ws) => ws,
327 None => {
328 return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
329 }
330 },
331 Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
332 };
333
334 let settings = workspace.settings.clone();
335 let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
336 Vec::with_capacity(workspace.mounts.len());
337 for mount in workspace.mounts {
338 let backend = instantiate_lean_backend(&mount)?;
339 mounts.push((mount, backend));
340 }
341 use crate::schema_source::SchemaSource as _;
350 let local = crate::schema_source::FolderSchemaSource::for_workspace(workspace_root)
351 .read_schemas()
352 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
353 let mut engine = Engine::from_mounts_inner(mounts, local)?;
354 engine.set_settings(settings);
355 engine.workspace_root = Some(workspace_root.to_path_buf());
356 engine.set_pipeline_configs(crate::pipeline_store::load_legacy_pipeline_configs(
378 workspace_root,
379 )?);
380 let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
385 Ok(engine)
386 }
387}
388
389fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
422 let mut router = MemRouterSnapshot::new();
423 for m in mounts {
424 match m.mount.capability {
425 MountCapability::Write => {
426 let dir: Option<PathBuf> = match &m.mount.storage {
427 MountStorage::Folder { path } => Some(path.clone()),
428 MountStorage::GitBranch { .. } => None,
429 MountStorage::Archive { .. } => None,
430 MountStorage::InMemory => None,
434 };
435 router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
436 }
437 MountCapability::ReadOnly => match &m.mount.storage {
438 MountStorage::Archive { path } => {
439 router.add_read_only(m.mount.mem.clone(), path.clone());
440 }
441 MountStorage::Folder { path } => {
442 router.add_read_only(m.mount.mem.clone(), path.clone());
443 }
444 MountStorage::GitBranch { gitdir, .. } => {
445 router.add_read_only(m.mount.mem.clone(), gitdir.clone());
446 }
447 MountStorage::InMemory => {}
453 },
454 }
455 }
456 router
457}
458
459pub fn resolve_builtin_schema_pin_pub(
464 pin: &memstead_schema::SchemaRef,
465 catalogue: &[Arc<memstead_schema::Schema>],
466) -> Option<Arc<memstead_schema::Schema>> {
467 resolve_builtin_schema_pin(pin, catalogue)
468}
469
470pub struct SchemaResolver<'a> {
485 catalogue: &'a [Arc<memstead_schema::Schema>],
486}
487
488impl<'a> SchemaResolver<'a> {
489 pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
492 Self { catalogue }
493 }
494
495 pub fn resolve(
499 &self,
500 pin: &memstead_schema::SchemaRef,
501 ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
502 {
503 resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
504 crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
505 &pin.name,
506 &pin.version,
507 self.catalogue,
508 )
509 })
510 }
511}
512
513pub(crate) fn load_workspace_schemas(
519 schemas_dir: Option<&Path>,
520) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
521 let Some(dir) = schemas_dir else {
522 return Ok(Vec::new());
523 };
524 if !dir.is_dir() {
525 return Ok(Vec::new());
526 }
527 let entries = match std::fs::read_dir(dir) {
528 Ok(e) => e,
529 Err(_) => return Ok(Vec::new()),
530 };
531 let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
532 for entry in entries.flatten() {
533 let path = entry.path();
534 if !path.is_dir() {
535 continue;
536 }
537 if !path.join("schema.yaml").is_file() {
538 continue;
539 }
540 let schema = memstead_schema::load_schema_from_dir(&path)
541 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
542 schemas.push(Arc::new(schema));
543 }
544 Ok(schemas)
545}
546
547pub(super) fn resolve_builtin_schema_pin(
548 pin: &memstead_schema::SchemaRef,
549 catalogue: &[Arc<memstead_schema::Schema>],
550) -> Option<Arc<memstead_schema::Schema>> {
551 catalogue
552 .iter()
553 .find(|s| {
554 let id = s.id();
555 id.0 == pin.name && id.1 == pin.version
556 })
557 .cloned()
558}
559
560pub(super) fn collect_source_entries(
561 backend: &dyn MemBackend,
562) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
563 let paths = backend.list_entities()?;
564 let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
565 let mut errors: Vec<SourceReadError> = Vec::new();
566 for path in paths {
567 match backend.read_entity(&path) {
568 Ok(Some(bytes)) => match String::from_utf8(bytes) {
569 Ok(content) => entries.push(SourceEntry {
570 relative_path: path.to_string_lossy().into_owned(),
571 source_path: path.clone(),
572 content,
573 }),
574 Err(e) => errors.push(SourceReadError {
575 source_path: path,
576 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
577 }),
578 },
579 Ok(None) => {
580 }
582 Err(e) => errors.push(SourceReadError {
583 source_path: path,
584 error: std::io::Error::other(e.to_string()),
585 }),
586 }
587 }
588 Ok((entries, errors))
589}
590
591#[cfg(test)]
592mod tests {
593
594 use std::path::Path;
595
596 use memstead_schema::SchemaRef;
597 use tempfile::TempDir;
598
599 use crate::backend::MemBackend;
600 use crate::engine::test_helpers::*;
601 use crate::engine::{Engine, EngineError};
602 use crate::ops::WarningHint;
603 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
604 use crate::vcs::CommitContext;
605 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
606
607 #[test]
611 fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
612 let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
613 let resolver = super::SchemaResolver::new(&catalogue);
614
615 let ok: SchemaRef = "default@1.0.0".parse().unwrap();
616 assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
617
618 let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
619 let sources = resolver.resolve(&miss).unwrap_err();
620 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
621 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
622 assert!(sources.iter().all(|s| !s.pinned_version_match));
623 }
624
625 #[test]
626 fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
627 let engine = Engine::from_mounts(Vec::new()).unwrap();
628 assert!(engine.mem_names().is_empty());
629 match engine.list_entities("missing") {
630 Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
631 other => panic!("expected UnknownMem, got {other:?}"),
632 }
633 }
634
635 #[test]
636 fn duplicate_mem_names_rejected_at_construction() {
637 let tmp = TempDir::new().unwrap();
638 let writer1: Box<dyn MemBackend> =
639 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
640 let writer2: Box<dyn MemBackend> =
641 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
642 let err = Engine::from_mounts(vec![
643 (folder_mount("specs", tmp.path().to_path_buf()), writer1),
644 (folder_mount("specs", tmp.path().to_path_buf()), writer2),
645 ])
646 .unwrap_err();
647 assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
648 }
649
650 #[test]
651 fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
652 let tmp = TempDir::new().unwrap();
658 let mem_dir = tmp.path().to_path_buf();
659 let body =
660 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
661 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
662
663 let writer = FilesystemMemWriter::new(mem_dir.clone());
664 let engine = Engine::from_mounts(vec![(
665 folder_mount("specs", mem_dir),
666 Box::new(writer) as Box<dyn MemBackend>,
667 )])
668 .unwrap();
669
670 let warnings = engine.load_warnings();
671 assert!(
672 warnings
673 .iter()
674 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
675 "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
676 );
677 }
678
679 #[test]
685 fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
686 let tmp = TempDir::new().unwrap();
687 let mem_dir = tmp.path().to_path_buf();
688 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
691 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
692 std::fs::write(mem_dir.join("target.md"), target).unwrap();
693 std::fs::write(mem_dir.join("source.md"), source).unwrap();
694
695 let writer = FilesystemMemWriter::new(mem_dir.clone());
696 let engine = Engine::from_mounts(vec![(
697 folder_mount("specs", mem_dir),
698 Box::new(writer) as Box<dyn MemBackend>,
699 )])
700 .unwrap();
701
702 let source_id = crate::entity::EntityId::new("specs", "source");
703 let target_id = crate::entity::EntityId::new("specs", "target");
704 let source_entity = engine.get_entity(&source_id).expect("source loaded");
705 assert!(
708 source_entity.relationships.is_empty(),
709 "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
710 source_entity.relationships,
711 );
712 let outgoing: Vec<_> = engine
714 .store()
715 .outgoing(&source_id)
716 .iter()
717 .filter(|e| e.rel_type == "MADE_UP_TYPE")
718 .collect();
719 assert!(
720 outgoing.is_empty(),
721 "MADE_UP_TYPE edge must be dropped from the store"
722 );
723 let parsed_invalid: Vec<_> = engine
725 .load_warnings()
726 .iter()
727 .filter_map(|w| match w {
728 WarningHint::ParsedRelationInvalid {
729 entity_id,
730 rel_type,
731 target,
732 reason,
733 origin,
734 recovery,
735 } => Some((
736 entity_id.clone(),
737 rel_type.clone(),
738 target.clone(),
739 reason.clone(),
740 origin.clone(),
741 recovery.clone(),
742 )),
743 _ => None,
744 })
745 .collect();
746 assert_eq!(
747 parsed_invalid.len(),
748 1,
749 "expected one warning, got {parsed_invalid:?}"
750 );
751 assert_eq!(parsed_invalid[0].0, source_id);
752 assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
753 assert_eq!(parsed_invalid[0].2, target_id);
754 assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
755 assert_eq!(parsed_invalid[0].4, "writable");
756 let recovery = parsed_invalid[0]
758 .5
759 .as_ref()
760 .expect("writable-origin warning must carry recovery");
761 assert_eq!(
762 recovery.kind,
763 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
764 );
765 assert_eq!(recovery.source_id, parsed_invalid[0].0);
766 assert_eq!(recovery.target_id, parsed_invalid[0].2);
767 assert_eq!(recovery.rel_type, parsed_invalid[0].1);
768 }
769
770 #[test]
778 fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
779 let tmp = TempDir::new().unwrap();
780 let mem_dir = tmp.path().to_path_buf();
781 let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
785 let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
786 std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
787 std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
788
789 let writer = FilesystemMemWriter::new(mem_dir.clone());
790 let engine = Engine::from_mounts(vec![(
791 folder_mount("specs", mem_dir),
792 Box::new(writer) as Box<dyn MemBackend>,
793 )])
794 .unwrap();
795
796 let alpha_id = crate::entity::EntityId::new("specs", "alpha");
797 let beta_id = crate::entity::EntityId::new("specs", "beta");
798
799 assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
802 assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
803
804 let surviving: Vec<_> = engine
807 .store()
808 .all_entities()
809 .flat_map(|e| {
810 engine
811 .store()
812 .outgoing(&e.id)
813 .iter()
814 .filter(|edge| edge.rel_type == "PART_OF")
815 .map(|edge| (e.id.clone(), edge.target.clone()))
816 .collect::<Vec<_>>()
817 })
818 .collect();
819 assert_eq!(
820 surviving.len(),
821 1,
822 "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
823 );
824
825 let cycle_drops: Vec<_> = engine
828 .load_warnings()
829 .iter()
830 .filter_map(|w| match w {
831 WarningHint::ParsedRelationInvalid {
832 entity_id,
833 rel_type,
834 target,
835 reason,
836 ..
837 } if reason == "cycle" => {
838 Some((entity_id.clone(), rel_type.clone(), target.clone()))
839 }
840 _ => None,
841 })
842 .collect();
843 assert_eq!(
844 cycle_drops.len(),
845 1,
846 "exactly one cycle warning must fire, got {cycle_drops:?}",
847 );
848 let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
850 assert_eq!(dropped_rel_type, "PART_OF");
851 let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
852 let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
853 assert!(
854 is_alpha_to_beta || is_beta_to_alpha,
855 "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
856 );
857 assert_ne!(
859 (&surviving[0].0, &surviving[0].1),
860 (dropped_from, dropped_to),
861 "surviving edge must differ from the dropped one",
862 );
863 }
864
865 #[test]
875 fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
876 use crate::engine::test_helpers::write_schema_files_with_default_type;
877
878 let tmp = TempDir::new().unwrap();
879 let schemas_dir = tmp.path().join("schemas");
880 std::fs::create_dir_all(&schemas_dir).unwrap();
881 let manifest = r#"name: shape-test
885version: 0.1.0
886description: shape-constraint schema
887when_to_use: tests
888types:
889 - doc
890 - actor
891relationships:
892 mode: strict
893 definitions:
894 - name: OWNS
895 description: actor owns doc
896 default_weight: 1.0
897 source_types: [actor]
898 target_types: [doc]
899 - name: _default
900 description: fallback
901 default_weight: 1.0
902community:
903 resolution: 1.0
904 seed: 42
905"#;
906 write_schema_files_with_default_type(
907 &schemas_dir,
908 "shape-test",
909 manifest,
910 &["doc", "actor"],
911 );
912
913 let mem_dir = tmp.path().join("mem");
914 std::fs::create_dir_all(&mem_dir).unwrap();
915 let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
919 let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
920 std::fs::write(mem_dir.join("target.md"), target).unwrap();
921 std::fs::write(mem_dir.join("source.md"), source).unwrap();
922
923 let writer = FilesystemMemWriter::new(mem_dir.clone());
924 let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
925 let mount = Mount {
926 mem: "specs".to_string(),
927 schema: Some(pin),
928 storage: MountStorage::Folder { path: mem_dir },
929 capability: MountCapability::Write,
930 lifecycle: MountLifecycle::Eager,
931 cross_linkable: true,
932 migration_target: None,
933 };
934 let engine = Engine::from_mounts_with_schemas_dir(
935 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
936 Some(&schemas_dir),
937 )
938 .unwrap();
939
940 let source_id = crate::entity::EntityId::new("specs", "source");
941 let target_id = crate::entity::EntityId::new("specs", "target");
942
943 let shape_drops: Vec<_> = engine
944 .load_warnings()
945 .iter()
946 .filter_map(|w| match w {
947 WarningHint::ParsedRelationInvalid {
948 entity_id,
949 rel_type,
950 target,
951 reason,
952 origin,
953 recovery,
954 } if reason == "shape" => Some((
955 entity_id.clone(),
956 rel_type.clone(),
957 target.clone(),
958 origin.clone(),
959 recovery.clone(),
960 )),
961 _ => None,
962 })
963 .collect();
964 assert_eq!(
965 shape_drops.len(),
966 1,
967 "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
968 engine.load_warnings(),
969 );
970 let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
971 shape_drops.into_iter().next().unwrap();
972 assert_eq!(drop_from, source_id);
973 assert_eq!(drop_type, "OWNS");
974 assert_eq!(drop_to, target_id);
975 assert_eq!(drop_origin, "writable");
976 let recovery = drop_recovery.expect("writable origin must carry recovery");
979 assert_eq!(
980 recovery.kind,
981 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
982 );
983 assert_eq!(recovery.source_id, source_id);
984 assert_eq!(recovery.target_id, target_id);
985 assert_eq!(recovery.rel_type, "OWNS");
986 }
987
988 #[test]
994 fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
995 let tmp = TempDir::new().unwrap();
996 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1000 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1001 let archive_path = build_archive(
1002 tmp.path(),
1003 "ext",
1004 &[
1005 ("target.md", target.as_bytes()),
1006 ("source.md", source.as_bytes()),
1007 ],
1008 );
1009
1010 let engine = Engine::from_mounts(vec![(
1011 archive_mount("external", archive_path.clone()),
1012 Box::new(ArchiveBackend::new(archive_path)),
1013 )])
1014 .unwrap();
1015
1016 let invalid: Vec<_> = engine
1017 .load_warnings()
1018 .iter()
1019 .filter_map(|w| match w {
1020 WarningHint::ParsedRelationInvalid {
1021 rel_type,
1022 reason,
1023 origin,
1024 recovery,
1025 ..
1026 } => Some((
1027 rel_type.clone(),
1028 reason.clone(),
1029 origin.clone(),
1030 recovery.clone(),
1031 )),
1032 _ => None,
1033 })
1034 .collect();
1035 assert_eq!(
1036 invalid.len(),
1037 1,
1038 "expected one parse-time drop on the readonly mount, got {invalid:?}",
1039 );
1040 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1041 assert_eq!(invalid[0].1, "unknown_rel_type");
1042 assert_eq!(invalid[0].2, "readonly");
1043 assert!(
1044 invalid[0].3.is_none(),
1045 "readonly-origin warning must omit the recovery hint, got {:?}",
1046 invalid[0].3,
1047 );
1048 }
1049
1050 #[test]
1051 fn load_on_init_populates_store_from_folder_mount() {
1052 let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1055
1056 let tmp = TempDir::new().unwrap();
1057 let mem_dir = tmp.path().to_path_buf();
1058 let writer = FilesystemMemWriter::new(mem_dir.clone());
1059 <FilesystemMemWriter as MemWriter>::write_entity(
1060 &writer,
1061 Path::new("hello.md"),
1062 body.as_bytes(),
1063 )
1064 .unwrap();
1065 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1066 .unwrap();
1067
1068 let engine = Engine::from_mounts(vec![(
1069 folder_mount("specs", mem_dir),
1070 Box::new(writer) as Box<dyn MemBackend>,
1071 )])
1072 .unwrap();
1073
1074 assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1076 let id = crate::EntityId::new("specs", "hello");
1077 let entity = engine.get_entity(&id).expect("entity must be present");
1078 assert_eq!(entity.title, "Hello");
1079 assert_eq!(entity.entity_type, "spec");
1080 assert!(engine.load_errors().is_empty());
1081 assert_eq!(engine.schemas().len(), 1);
1083 assert!(engine.schemas().contains_key("specs"));
1084 }
1085
1086 #[test]
1087 fn load_on_init_populates_store_from_archive_mount() {
1088 let body =
1089 "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1090
1091 let tmp = TempDir::new().unwrap();
1092 let archive_path =
1093 build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1094
1095 let engine = Engine::from_mounts(vec![(
1096 archive_mount("external", archive_path.clone()),
1097 Box::new(ArchiveBackend::new(archive_path)),
1098 )])
1099 .unwrap();
1100
1101 let id = crate::EntityId::new("external", "from-archive");
1102 let entity = engine.get_entity(&id).expect("entity must be present");
1103 assert_eq!(entity.title, "From Archive");
1104 assert!(engine.load_errors().is_empty());
1105 }
1106
1107 #[test]
1108 fn load_on_init_populates_store_from_heterogeneous_mounts() {
1109 let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1110 let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1111
1112 let tmp = TempDir::new().unwrap();
1113
1114 let folder_dir = tmp.path().join("folder-mem");
1115 std::fs::create_dir_all(&folder_dir).unwrap();
1116 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1117 <FilesystemMemWriter as MemWriter>::write_entity(
1118 &folder_writer,
1119 Path::new("local.md"),
1120 folder_body.as_bytes(),
1121 )
1122 .unwrap();
1123 <FilesystemMemWriter as MemWriter>::commit(
1124 &folder_writer,
1125 "seed",
1126 &CommitContext::internal(),
1127 )
1128 .unwrap();
1129
1130 let archive_path = build_archive(
1131 tmp.path(),
1132 "external",
1133 &[("external.md", archive_body.as_bytes())],
1134 );
1135
1136 let engine = Engine::from_mounts(vec![
1137 (
1138 folder_mount("local", folder_dir),
1139 Box::new(folder_writer) as Box<dyn MemBackend>,
1140 ),
1141 (
1142 archive_mount("external", archive_path.clone()),
1143 Box::new(ArchiveBackend::new(archive_path)),
1144 ),
1145 ])
1146 .unwrap();
1147
1148 assert_eq!(engine.store().len(), 2);
1150 assert!(
1151 engine
1152 .get_entity(&crate::EntityId::new("local", "local"))
1153 .is_some()
1154 );
1155 assert!(
1156 engine
1157 .get_entity(&crate::EntityId::new("external", "external"))
1158 .is_some()
1159 );
1160 }
1161
1162 #[test]
1163 fn load_on_init_collects_per_file_parse_errors_without_failing() {
1164 let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1168 let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1169
1170 let tmp = TempDir::new().unwrap();
1171 let mem_dir = tmp.path().to_path_buf();
1172 let writer = FilesystemMemWriter::new(mem_dir.clone());
1173 <FilesystemMemWriter as MemWriter>::write_entity(
1174 &writer,
1175 Path::new("good.md"),
1176 good.as_bytes(),
1177 )
1178 .unwrap();
1179 <FilesystemMemWriter as MemWriter>::write_entity(
1180 &writer,
1181 Path::new("bad.md"),
1182 bad.as_bytes(),
1183 )
1184 .unwrap();
1185 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1186 .unwrap();
1187
1188 let engine = Engine::from_mounts(vec![(
1189 folder_mount("specs", mem_dir),
1190 Box::new(writer) as Box<dyn MemBackend>,
1191 )])
1192 .unwrap();
1193
1194 assert!(
1196 engine
1197 .get_entity(&crate::EntityId::new("specs", "good"))
1198 .is_some(),
1199 "good.md must parse and reach the store"
1200 );
1201 let bad_known_to_engine = engine
1206 .get_entity(&crate::EntityId::new("specs", "bad"))
1207 .is_some()
1208 || !engine.load_errors().is_empty();
1209 assert!(
1210 bad_known_to_engine,
1211 "bad.md must either parse or surface in load_errors"
1212 );
1213 }
1214
1215 #[test]
1216 fn empty_mount_list_yields_empty_store() {
1217 let engine = Engine::from_mounts(Vec::new()).unwrap();
1218 assert!(engine.store().is_empty());
1219 assert!(engine.schemas().is_empty());
1220 assert!(engine.load_errors().is_empty());
1221 }
1222
1223 #[test]
1226 fn from_workspace_root_errors_for_empty_layout() {
1227 let tmp = TempDir::new().unwrap();
1228 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1229 match err {
1230 crate::BootError::NotInitialised(p) => {
1231 assert_eq!(p, tmp.path());
1232 }
1233 other => panic!("expected NotInitialised, got {other:?}"),
1234 }
1235 }
1236
1237 #[test]
1238 fn from_workspace_root_loads_new_two_layer_layout() {
1239 let tmp = TempDir::new().unwrap();
1240 let mem_dir = tmp.path().join("mem");
1241 std::fs::create_dir_all(&mem_dir).unwrap();
1242 std::fs::write(
1243 mem_dir.join("hello.md"),
1244 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1245 )
1246 .unwrap();
1247
1248 let memstead = tmp.path().join(".memstead");
1249 std::fs::create_dir_all(&memstead).unwrap();
1250 std::fs::write(
1251 memstead.join("workspace.toml"),
1252 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1253 )
1254 .unwrap();
1255 use crate::workspace_store::WorkspaceStoreAdapter;
1258 let store = crate::FileWorkspaceStore::new();
1259 store
1260 .save_state(
1261 tmp.path(),
1262 &crate::workspace::Workspace {
1263 mounts: vec![folder_mount("specs", mem_dir)],
1264 settings: crate::workspace::WorkspaceSettings::default(),
1265 },
1266 )
1267 .unwrap();
1268
1269 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1270 assert_eq!(engine.mem_names(), vec!["specs"]);
1271 let entity = engine
1272 .get_entity(&crate::EntityId::new("specs", "hello"))
1273 .expect("seeded entity must load through from_workspace_root");
1274 assert_eq!(entity.title, "Hello");
1275 }
1276
1277 #[test]
1282 fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1283 use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, Projection};
1284 use crate::pipeline_store::{LegacyIngest, LegacyIngestMode};
1285 let tmp = TempDir::new().unwrap();
1286 let mem_dir = tmp.path().join("mem");
1287 std::fs::create_dir_all(&mem_dir).unwrap();
1288
1289 let memstead = tmp.path().join(".memstead");
1290 std::fs::create_dir_all(&memstead).unwrap();
1291 std::fs::write(
1292 memstead.join("workspace.toml"),
1293 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1294 )
1295 .unwrap();
1296 use crate::workspace_store::WorkspaceStoreAdapter;
1297 crate::FileWorkspaceStore::new()
1298 .save_state(
1299 tmp.path(),
1300 &crate::workspace::Workspace {
1301 mounts: vec![folder_mount("specs", mem_dir)],
1302 settings: crate::workspace::WorkspaceSettings::default(),
1303 },
1304 )
1305 .unwrap();
1306
1307 crate::pipeline_store::write_medium(
1309 tmp.path(),
1310 "specs",
1311 "src",
1312 &Medium {
1313 name: "src".to_string(),
1314 medium_type: MediumType::Codebase,
1315 pointer: "..".to_string(),
1316 change_detection: None,
1317 },
1318 )
1319 .unwrap();
1320 crate::pipeline_store::write_facet(
1321 tmp.path(),
1322 "specs",
1323 "view",
1324 &Facet {
1325 name: "view".to_string(),
1326 medium: "src".to_string(),
1327 scope: Vec::new(),
1328 engagement: None,
1329 preparation: None,
1330 },
1331 )
1332 .unwrap();
1333 crate::pipeline_store::write_projection(
1334 tmp.path(),
1335 "specs",
1336 "graph",
1337 &Projection {
1338 intent: None,
1339 source_facets: vec!["view".to_string()],
1340 reference_mems: Vec::new(),
1341 destination_mem: "specs".to_string(),
1342 rules: None,
1343 },
1344 )
1345 .unwrap();
1346 crate::pipeline_store::write_ingest(
1347 tmp.path(),
1348 "specs-graph",
1349 &LegacyIngest {
1350 projection: "specs/graph".to_string(),
1351 mode: LegacyIngestMode::Discovery,
1352 trigger: IngestTrigger::Loop,
1353 batch_size: 10,
1354 deny_paths: Vec::new(),
1355 post_actions: None,
1356 },
1357 )
1358 .unwrap();
1359
1360 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1361 let pc = engine.pipeline_configs();
1362 assert_eq!(pc.mediums.len(), 1, "one medium enumerated");
1363 assert_eq!(pc.mediums[0].config.medium_type, MediumType::Codebase);
1364 assert_eq!(pc.facets.len(), 1, "one facet enumerated");
1365 assert_eq!(pc.facets[0].config.medium, "src");
1366 assert_eq!(pc.projections.len(), 1, "one projection enumerated");
1367 assert_eq!(pc.projections[0].config.destination_mem, "specs");
1368 assert_eq!(pc.ingests.len(), 1, "one ingest enumerated");
1369 assert_eq!(pc.ingests[0].name, "specs-graph");
1370 assert_eq!(pc.ingests[0].config.mode, LegacyIngestMode::Discovery);
1371 }
1372
1373 #[test]
1379 fn entity_anchors_resolve_live_state_for_path_medium() {
1380 use crate::anchor::{AnchorInput, AnchorState};
1381 use crate::pipeline::{Medium, MediumType};
1382 use crate::vcs::Actor;
1383 use crate::workspace_store::WorkspaceStoreAdapter;
1384 use indexmap::IndexMap;
1385
1386 let tmp = TempDir::new().unwrap();
1387 let mem_dir = tmp.path().join("mem");
1388 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1389 std::fs::write(
1390 mem_dir.join(".memstead").join("config.json"),
1391 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1392 )
1393 .unwrap();
1394
1395 let memstead = tmp.path().join(".memstead");
1396 std::fs::create_dir_all(&memstead).unwrap();
1397 std::fs::write(
1398 memstead.join("workspace.toml"),
1399 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1400 )
1401 .unwrap();
1402 crate::FileWorkspaceStore::new()
1403 .save_state(
1404 tmp.path(),
1405 &crate::workspace::Workspace {
1406 mounts: vec![folder_mount("specs", mem_dir.clone())],
1407 settings: crate::workspace::WorkspaceSettings::default(),
1408 },
1409 )
1410 .unwrap();
1411
1412 crate::pipeline_store::write_medium(
1416 tmp.path(),
1417 "specs",
1418 "src",
1419 &Medium {
1420 name: "src".to_string(),
1421 medium_type: MediumType::Codebase,
1422 pointer: "src".to_string(),
1423 change_detection: None,
1424 },
1425 )
1426 .unwrap();
1427 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1428 std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
1429
1430 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
1431
1432 let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
1433 artifact: Some(artifact.to_string()),
1434 grain: Some("file".to_string()),
1435 class: Some(class.to_string()),
1436 hash: hash.map(str::to_string),
1437 hash_stability: Some("stable".to_string()),
1438 ..Default::default()
1439 };
1440 let mut sections = IndexMap::new();
1441 sections.insert("identity".to_string(), "Covers src.".to_string());
1442 sections.insert("purpose".to_string(), "Track sources.".to_string());
1443 let created = engine
1444 .create_entity(
1445 crate::CreateEntityArgs {
1446 mem: "specs".to_string(),
1447 title: "Covers".to_string(),
1448 entity_type: "spec".to_string(),
1449 sections,
1450 metadata: IndexMap::new(),
1451 relations: Vec::new(),
1452 anchors: vec![
1453 anchor("present.rs", "anchored", Some("h1")), anchor("present.rs", "informed-by", None), anchor("gone.rs", "anchored", Some("h2")), ],
1457 dry_run: false,
1458 },
1459 Actor::Agent,
1460 None,
1461 None,
1462 )
1463 .unwrap();
1464
1465 let resolved = engine.entity_anchors_resolved(&created.id);
1466 assert_eq!(resolved.len(), 3);
1467 let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
1468 resolved
1469 .iter()
1470 .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
1471 .and_then(|r| r.state)
1472 };
1473 assert_eq!(
1474 state_of("present.rs", crate::anchor::AnchorProvenanceClass::Anchored),
1475 Some(AnchorState::Recheck),
1476 "present hash-bearing anchor cannot adjudicate the prepared hash here → recheck"
1477 );
1478 assert_eq!(
1479 state_of(
1480 "present.rs",
1481 crate::anchor::AnchorProvenanceClass::InformedBy
1482 ),
1483 Some(AnchorState::Resolves),
1484 "present non-hash anchor resolves on existence"
1485 );
1486 assert_eq!(
1487 state_of("gone.rs", crate::anchor::AnchorProvenanceClass::Anchored),
1488 Some(AnchorState::Orphaned),
1489 "absent artifact is orphaned"
1490 );
1491 }
1492
1493 #[test]
1498 fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
1499 use crate::pipeline::{Facet, Medium, MediumType};
1500 use crate::workspace_store::WorkspaceStoreAdapter;
1501
1502 let tmp = TempDir::new().unwrap();
1503 let mem_dir = tmp.path().join("mem");
1504 std::fs::create_dir_all(&mem_dir).unwrap();
1505 let memstead = tmp.path().join(".memstead");
1506 std::fs::create_dir_all(&memstead).unwrap();
1507 std::fs::write(
1508 memstead.join("workspace.toml"),
1509 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1510 )
1511 .unwrap();
1512 crate::FileWorkspaceStore::new()
1513 .save_state(
1514 tmp.path(),
1515 &crate::workspace::Workspace {
1516 mounts: vec![folder_mount("specs", mem_dir)],
1517 settings: crate::workspace::WorkspaceSettings::default(),
1518 },
1519 )
1520 .unwrap();
1521
1522 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
1523 assert!(engine.pipeline_configs().mediums.is_empty());
1524
1525 engine
1526 .add_medium(
1527 "specs",
1528 "src",
1529 &Medium {
1530 name: "src".to_string(),
1531 medium_type: MediumType::Codebase,
1532 pointer: "..".to_string(),
1533 change_detection: None,
1534 },
1535 None,
1536 )
1537 .unwrap();
1538 assert_eq!(engine.pipeline_configs().mediums.len(), 1);
1540 assert_eq!(engine.pipeline_configs().mediums[0].name, "src");
1541
1542 engine
1543 .add_facet(
1544 "specs",
1545 "view",
1546 &Facet {
1547 name: "view".to_string(),
1548 medium: "src".to_string(),
1549 scope: Vec::new(),
1550 engagement: None,
1551 preparation: None,
1552 },
1553 None,
1554 )
1555 .unwrap();
1556 let err = engine.delete_medium("specs", "src", None).unwrap_err();
1558 assert!(
1559 matches!(
1560 err,
1561 crate::pipeline_edit::PipelineEditError::Referenced { .. }
1562 ),
1563 "got {err:?}"
1564 );
1565 assert_eq!(
1566 engine.pipeline_configs().mediums.len(),
1567 1,
1568 "refused delete left the medium in place"
1569 );
1570
1571 engine
1573 .add_medium_json(
1574 "specs",
1575 "docs",
1576 r#"{"name":"docs","type":"filesystem","pointer":"./docs"}"#,
1577 None,
1578 )
1579 .unwrap();
1580 assert!(
1581 engine
1582 .pipeline_configs()
1583 .mediums
1584 .iter()
1585 .any(|m| m.name == "docs" && m.config.medium_type == MediumType::Filesystem),
1586 "add_medium_json should land a filesystem medium"
1587 );
1588 let err = engine
1590 .add_medium_json("specs", "bad", "{ not json", None)
1591 .unwrap_err();
1592 assert!(
1593 matches!(
1594 err,
1595 crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
1596 ),
1597 "got {err:?}"
1598 );
1599
1600 let json = engine.pipeline_configs_json();
1602 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1603 let mediums = parsed["mediums"].as_array().unwrap();
1604 assert_eq!(mediums.len(), 2, "src + docs");
1605 assert!(
1606 mediums
1607 .iter()
1608 .any(|m| m["name"] == "docs" && m["config"]["type"] == "filesystem"),
1609 "pipeline_configs_json should carry the docs medium: {json}"
1610 );
1611 }
1612
1613 #[test]
1619 fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
1620 use crate::engine::test_helpers::write_schema_files_with_default_type;
1621
1622 let tmp = TempDir::new().unwrap();
1623 let mem_dir = tmp.path().join("mem");
1624 std::fs::create_dir_all(&mem_dir).unwrap();
1625
1626 let authored_dir = tmp.path().join(".memstead").join("schemas");
1628 let manifest = r#"name: authored
1629version: 0.1.0
1630description: an authored-in-workspace test schema
1631when_to_use: tests
1632types:
1633 - doc
1634relationships:
1635 mode: strict
1636 definitions:
1637 - name: _default
1638 description: fallback
1639 default_weight: 1.0
1640community:
1641 resolution: 1.0
1642 seed: 42
1643"#;
1644 write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
1645
1646 let memstead = tmp.path().join(".memstead");
1648 std::fs::create_dir_all(&memstead).unwrap();
1649 std::fs::write(
1650 memstead.join("workspace.toml"),
1651 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1652 )
1653 .unwrap();
1654 let mount = Mount {
1655 mem: "specs".to_string(),
1656 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
1657 storage: MountStorage::Folder { path: mem_dir },
1658 capability: MountCapability::Write,
1659 lifecycle: MountLifecycle::Eager,
1660 cross_linkable: true,
1661 migration_target: None,
1662 };
1663 use crate::workspace_store::WorkspaceStoreAdapter;
1664 crate::FileWorkspaceStore::new()
1665 .save_state(
1666 tmp.path(),
1667 &crate::workspace::Workspace {
1668 mounts: vec![mount],
1669 settings: crate::workspace::WorkspaceSettings::default(),
1670 },
1671 )
1672 .unwrap();
1673
1674 let engine = Engine::from_workspace_root(tmp.path())
1677 .expect("authored schema at .memstead/schemas/ must resolve at boot");
1678 assert_eq!(engine.mem_names(), vec!["specs"]);
1679 }
1680
1681 #[test]
1682 fn from_workspace_root_propagates_mem_management_settings() {
1683 let tmp = TempDir::new().unwrap();
1688 let mem_dir = tmp.path().join("mem");
1689 std::fs::create_dir_all(&mem_dir).unwrap();
1690
1691 let memstead = tmp.path().join(".memstead");
1692 std::fs::create_dir_all(&memstead).unwrap();
1693 std::fs::write(
1694 memstead.join("workspace.toml"),
1695 r#"format = "memstead-git-branch-2"
1696
1697[persistence_adapter]
1698name = "file-two-layer"
1699
1700[[mem_management.create]]
1701pattern = "exec-*"
1702schemas = ["default@1.0.0"]
1703
1704[[mem_management.delete]]
1705pattern = "exec-*"
1706"#,
1707 )
1708 .unwrap();
1709 use crate::workspace_store::WorkspaceStoreAdapter;
1710 let store = crate::FileWorkspaceStore::new();
1711 store
1712 .save_state(
1713 tmp.path(),
1714 &crate::workspace::Workspace {
1715 mounts: vec![folder_mount("specs", mem_dir)],
1716 settings: crate::workspace::WorkspaceSettings::default(),
1717 },
1718 )
1719 .unwrap();
1720
1721 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1722 let s = engine.settings();
1723 assert_eq!(s.mem_create_rules.len(), 1);
1724 assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
1725 assert_eq!(
1726 s.mem_create_rules[0].schemas,
1727 vec!["default@1.0.0".to_string()]
1728 );
1729 assert_eq!(s.mem_delete_rules.len(), 1);
1730 assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
1731 }
1732
1733 #[test]
1734 fn from_mounts_rejects_unknown_schema_pin_with_typed_error() {
1735 let tmp = TempDir::new().unwrap();
1736 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1737 let mount = Mount {
1738 mem: "specs".to_string(),
1739 schema: Some(SchemaRef::new(
1740 "totally-not-a-schema",
1741 semver::Version::new(1, 0, 0),
1742 )),
1743 storage: MountStorage::Folder {
1744 path: tmp.path().to_path_buf(),
1745 },
1746 capability: MountCapability::Write,
1747 lifecycle: MountLifecycle::Eager,
1748 cross_linkable: true,
1749 migration_target: None,
1750 };
1751 let err = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
1752 .unwrap_err();
1753 match err {
1754 EngineError::SchemaNotFound { mem, pin, sources } => {
1755 assert_eq!(mem, "specs");
1756 assert_eq!(pin, "totally-not-a-schema@1.0.0");
1757 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
1760 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1761 assert!(sources.iter().all(|s| !s.pinned_version_match));
1762 assert_eq!(
1763 sources
1764 .iter()
1765 .find(|s| s.source == "remote")
1766 .unwrap()
1767 .status,
1768 Some("not_configured"),
1769 );
1770 }
1771 other => panic!("expected SchemaNotFound, got {other:?}"),
1772 }
1773 }
1774
1775 #[test]
1782 fn mem_config_schema_is_authoritative_over_mount_pin() {
1783 let tmp = TempDir::new().unwrap();
1784 let mem_dir = tmp.path().to_path_buf();
1785 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1786 std::fs::write(
1787 mem_dir.join(".memstead").join("config.json"),
1788 r#"{"schema":"software@0.1.0"}"#,
1789 )
1790 .unwrap();
1791 let writer = FilesystemMemWriter::new(mem_dir.clone());
1792 let mount = Mount {
1793 mem: "specs".to_string(),
1794 schema: Some(SchemaRef::new(
1795 "totally-not-a-schema",
1796 semver::Version::new(9, 9, 9),
1797 )),
1798 storage: MountStorage::Folder { path: mem_dir },
1799 capability: MountCapability::Write,
1800 lifecycle: MountLifecycle::Eager,
1801 cross_linkable: true,
1802 migration_target: None,
1803 };
1804 let engine = Engine::from_mounts(vec![(
1805 mount,
1806 Box::new(writer) as Box<dyn MemBackend>,
1807 )])
1808 .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
1809
1810 let mismatch = engine
1811 .load_warnings()
1812 .iter()
1813 .find_map(|w| match w {
1814 WarningHint::SchemaPinMismatch {
1815 mem,
1816 config_pin,
1817 mount_pin,
1818 } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
1819 _ => None,
1820 })
1821 .expect("SchemaPinMismatch warning must surface naming both pins");
1822 assert_eq!(mismatch.0, "specs");
1823 assert_eq!(mismatch.1, "software@0.1.0");
1824 assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
1825 }
1826
1827 #[test]
1828 fn from_workspace_root_rejects_git_branch_mount_with_typed_error() {
1829 let tmp = TempDir::new().unwrap();
1830 let memstead = tmp.path().join(".memstead");
1831 std::fs::create_dir_all(&memstead).unwrap();
1832 std::fs::write(
1833 memstead.join("workspace.toml"),
1834 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1835 )
1836 .unwrap();
1837 let state_dir = memstead.join("state");
1840 std::fs::create_dir_all(&state_dir).unwrap();
1841 std::fs::write(
1842 state_dir.join("mounts.json"),
1843 r#"{
1844 "format": "memstead-mounts-3",
1845 "mounts": [
1846 {
1847 "mem": "specs",
1848 "schema": "default@1.0.0",
1849 "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
1850 "capability": "write",
1851 "lifecycle": "eager",
1852 "cross_linkable": true
1853 }
1854 ]
1855 }"#,
1856 )
1857 .unwrap();
1858 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1859 match err {
1860 crate::BootError::Instantiate(
1861 crate::workspace_store::InstantiateError::GitBranchRequiresMemRepoFeature { mem },
1862 ) => {
1863 assert_eq!(mem, "specs");
1864 }
1865 other => panic!("expected Instantiate(GitBranchRequiresMemRepoFeature), got {other:?}"),
1866 }
1867 }
1868
1869 #[test]
1870 fn from_workspace_root_roots_standalone_folder_mem() {
1871 let tmp = TempDir::new().unwrap();
1875 let root = tmp.path();
1876 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1877 std::fs::write(
1878 root.join(".memstead").join("config.json"),
1879 r#"{"schema":"default@1.0.0"}"#,
1880 )
1881 .unwrap();
1882 std::fs::write(
1884 root.join("hello.md"),
1885 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
1886 )
1887 .unwrap();
1888
1889 let engine = Engine::from_workspace_root(root)
1890 .expect("a bare folder mem must root as a one-mount workspace");
1891 assert_eq!(engine.status().mem_count, 1, "exactly one mount");
1892 assert!(
1893 engine.status().entity_count >= 1,
1894 "the standalone mem's entity must load"
1895 );
1896 }
1897
1898 #[test]
1899 fn from_workspace_root_still_rejects_truly_empty_dir() {
1900 let tmp = TempDir::new().unwrap();
1904 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1905 assert!(
1906 matches!(err, crate::BootError::NotInitialised(_)),
1907 "got {err:?}"
1908 );
1909 }
1910
1911 }