1use std::cell::OnceCell;
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use crate::backend::{BackendError, MemBackend};
19use crate::engine_fallback_type;
20use crate::entity::EntityId;
21use crate::entity::generator::generate_markdown;
22use crate::entity::loader::parse_entries;
23use crate::entity::store_builder::push_entities_into_store;
24use crate::mem::MemOrigin;
25use crate::ops::WarningHint;
26use crate::workspace::{Mount, MountStorage, WorkspaceSettings};
27
28use super::boot::collect_source_entries;
29use super::{BackendFactory, Engine, EngineError, GitBranchOps, MountedBackend};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum SchemaStaging {
34 AlreadyResolvable,
38 Staged,
41 NoEmbeddedSchema,
44}
45
46impl Engine {
47 pub fn set_settings(&mut self, settings: WorkspaceSettings) {
56 self.settings = settings;
57 self.create_rule_set_memo = OnceCell::new();
58 }
59
60 pub fn set_backend_factory(&mut self, factory: BackendFactory) {
66 self.backend_factory = factory;
67 }
68
69 pub(crate) fn schemas_insert(
75 &mut self,
76 mem: String,
77 schema: std::sync::Arc<memstead_schema::Schema>,
78 ) {
79 self.schemas_epoch += 1;
80 self.schemas.insert(mem, schema);
81 }
82
83 pub(crate) fn schemas_remove(&mut self, mem: &str) {
86 self.schemas_epoch += 1;
87 self.schemas.remove(mem);
88 }
89
90 pub fn set_unmounted_storage_prober(&mut self, prober: super::UnmountedStorageProber) {
94 self.unmounted_storage_prober = Some(prober);
95 }
96
97 pub fn set_mutation_clock(&mut self, clock: crate::engine::MutationClock) {
108 self.mutation_clock = clock;
109 }
110
111 pub fn set_role(&mut self, role: crate::vcs::Role) {
117 self.current_role = role;
118 }
119
120 pub fn current_role(&self) -> crate::vcs::Role {
122 self.current_role
123 }
124
125 pub(crate) fn now_iso(&self) -> String {
129 crate::engine::mutation::iso_from_system_time((self.mutation_clock)())
130 }
131
132 pub fn set_git_branch_ops(&mut self, ops: GitBranchOps) {
138 self.git_branch_ops = Some(ops);
139 }
140
141 pub fn install_schema(
156 &self,
157 name: &str,
158 version: &str,
159 files: &[(String, Vec<u8>)],
160 ) -> Result<String, EngineError> {
161 Self::validate_schema_package(name, version, files)?;
167 let gitdir = self
173 .mounts
174 .iter()
175 .find_map(|m| match &m.mount.storage {
176 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
177 _ => None,
178 })
179 .or_else(|| {
180 self.workspace_root()
181 .map(|r| r.join("mem-repo").join(".git"))
182 })
183 .ok_or_else(|| {
184 EngineError::Mem(
185 "schema install requires a mem-repo workspace (no git-branch mount and \
186 no workspace root to resolve the mem-repo gitdir)"
187 .to_string(),
188 )
189 })?;
190 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
191 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
192 })?;
193 (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)
201 }
202
203 pub fn stage_sealed_schema(
231 &mut self,
232 mem: &str,
233 pin: &memstead_schema::SchemaRef,
234 files: &[(String, Vec<u8>)],
235 ) -> Result<SchemaStaging, EngineError> {
236 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
237 .workspace_schemas
238 .iter()
239 .chain(self.builtin_schemas.iter())
240 .cloned()
241 .collect();
242 if crate::engine::SchemaResolver::new(&catalogue)
243 .resolve(pin)
244 .is_ok()
245 {
246 return Ok(SchemaStaging::AlreadyResolvable);
247 }
248 if files.is_empty() {
249 return Ok(SchemaStaging::NoEmbeddedSchema);
253 }
254
255 let unloadable = |reason: String| EngineError::EmbeddedSchemaInvalid {
256 mem: mem.to_string(),
257 pin: pin.as_display(),
258 reason,
259 };
260 let schema =
263 memstead_schema::load_sealed_package(files).map_err(|e| unloadable(e.to_string()))?;
264 let (name, version) = schema.id();
265 if name != pin.name || version != pin.version {
266 return Err(unloadable(format!(
267 "the package declares '{name}@{version}' but the mem pins {}",
268 pin.as_display()
269 )));
270 }
271
272 self.write_to_local_schema_source(&name, &version.to_string(), files)?;
273 self.workspace_schemas.push(std::sync::Arc::new(schema));
274 Ok(SchemaStaging::Staged)
275 }
276
277 fn write_to_local_schema_source(
283 &self,
284 name: &str,
285 version: &str,
286 files: &[(String, Vec<u8>)],
287 ) -> Result<(), EngineError> {
288 let gitdir = self
289 .mounts
290 .iter()
291 .find_map(|m| match &m.mount.storage {
292 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
293 _ => None,
294 })
295 .or_else(|| {
296 self.workspace_root()
297 .map(|r| r.join("mem-repo").join(".git"))
298 })
299 .filter(|g| g.is_dir())
300 .ok_or_else(|| {
301 EngineError::Mem(
302 "staging a schema requires a mem-repo workspace — the folder workspace's \
303 `.memstead/schemas/` is the authoring tier and never holds sealed \
304 third-party packages"
305 .to_string(),
306 )
307 })?;
308 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
309 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
310 })?;
311 (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)?;
312 Ok(())
313 }
314
315 pub fn validate_schema_package(
326 name: &str,
327 version: &str,
328 files: &[(String, Vec<u8>)],
329 ) -> Result<(), EngineError> {
330 let invalid = |message: String| EngineError::SchemaPackageInvalid {
331 name: name.to_string(),
332 version: version.to_string(),
333 message,
334 };
335 let manifest_yaml = files
336 .iter()
337 .find(|(rel, _)| rel == "schema.yaml")
338 .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned())
339 .ok_or_else(|| invalid("package has no schema.yaml".to_string()))?;
340 let types: Vec<(String, String)> = files
341 .iter()
342 .filter_map(|(rel, bytes)| {
343 rel.strip_prefix("types/")
344 .and_then(|f| f.strip_suffix(".yaml"))
345 .map(|stem| {
346 (
347 stem.to_string(),
348 String::from_utf8_lossy(bytes).into_owned(),
349 )
350 })
351 })
352 .collect();
353 let schema = memstead_schema::load_schema_from_memory_with_format(
357 &manifest_yaml,
358 &types,
359 memstead_schema::loader::MetadataPolarityFormat::RequiredOptIn,
360 )
361 .map_err(|e| invalid(e.to_string()))?;
362 memstead_schema::check_section_heading_roundtrip(&schema)
363 .map_err(|e| invalid(e.to_string()))?;
364 memstead_schema::check_reserved_metadata_keys(&schema)
365 .map_err(|e| invalid(e.to_string()))?;
366 memstead_schema::check_section_formats(&schema).map_err(|e| invalid(e.to_string()))?;
367 let (declared_name, declared_version) =
368 (schema.manifest.name.as_str(), schema.version.to_string());
369 if declared_name != name || declared_version != version {
370 return Err(invalid(format!(
371 "manifest declares '{declared_name}@{declared_version}' but the package is \
372 being installed as '{name}@{version}'"
373 )));
374 }
375 Self::validate_schema_exemplars(&std::sync::Arc::new(schema)).map_err(invalid)?;
376 Ok(())
377 }
378
379 pub fn validate_schema_exemplars(
401 schema: &std::sync::Arc<memstead_schema::Schema>,
402 ) -> Result<(), String> {
403 let with_exemplars: Vec<&str> = schema
404 .manifest
405 .types
406 .iter()
407 .filter(|t| {
408 schema
409 .types
410 .get(t.as_str())
411 .is_some_and(|td| td.exemplar.is_some())
412 })
413 .map(String::as_str)
414 .collect();
415 if with_exemplars.is_empty() {
416 return Ok(());
417 }
418
419 let (name, version) = schema.id();
420 let mem = "exemplar";
421 let mount = crate::workspace::Mount {
422 mem: mem.to_string(),
423 schema: Some(memstead_schema::SchemaRef::new(name, version)),
424 storage: crate::workspace::MountStorage::InMemory,
425 capability: crate::workspace::MountCapability::Write,
426 lifecycle: crate::workspace::MountLifecycle::Eager,
427 cross_linkable: true,
428 migration_target: None,
429 };
430 let backend = Box::new(crate::storage::InMemoryBackend::new()) as Box<dyn MemBackend>;
431 let mut engine = Engine::from_mounts_with_schemas_dir_and_extra(
432 vec![(mount, backend)],
433 None,
434 vec![schema.clone()],
435 )
436 .map_err(|e| format!("exemplar validation could not boot: {e}"))?;
437
438 for type_name in with_exemplars {
439 let td = schema
440 .types
441 .get(type_name)
442 .expect("filtered on presence above");
443 let ex = td.exemplar.as_ref().expect("filtered on presence above");
444 let mut relations = Vec::with_capacity(ex.relations.len());
445 for r in &ex.relations {
446 if r.to.contains("--") || r.to.trim().is_empty() {
447 return Err(format!(
448 "type '{type_name}' exemplar relation target '{}' must be a bare \
449 placeholder slug (no `--`, non-empty) — exemplars live outside \
450 any mem",
451 r.to
452 ));
453 }
454 relations.push(crate::ops::RelateArg {
455 to: crate::entity::EntityId::new(mem, &r.to),
456 rel_type: r.rel_type.clone(),
457 description: r.description.clone(),
458 });
459 }
460 let args = crate::engine::CreateEntityArgs {
461 anchors: Vec::new(),
462 mem: mem.to_string(),
463 title: ex.title.clone(),
464 entity_type: type_name.to_string(),
465 sections: ex.sections.clone(),
466 metadata: ex.metadata.clone(),
467 relations,
468 dry_run: true,
469 };
470 if let Err(e) = engine.create_entity(args, crate::vcs::Actor::Cli, None, None) {
471 return Err(format!(
472 "type '{type_name}' exemplar does not conform: [{}] {e}",
473 e.code()
474 ));
475 }
476 }
477 Ok(())
478 }
479 pub fn unregister_writable_mem(
498 &mut self,
499 mem_name: &str,
500 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
501 let pos = self.mounts.iter().position(|m| m.mount.mem == mem_name);
502 let Some(idx) = pos else {
503 return Ok(None);
504 };
505
506 let mount = self.mounts.remove(idx);
512
513 self.schemas_remove(&mount.mount.mem);
516
517 let _removed = self.store.remove_entities_by_mem(mem_name);
522
523 self.load_warnings
534 .retain(|w| w.source_mem() != Some(mem_name));
535
536 Arc::make_mut(&mut self.mem_router).remove_writable(mem_name);
544
545 self.invalidate_communities();
550 self.invalidate_search_indexes();
551
552 Ok(Some(mount.backend))
553 }
554
555 pub fn register_read_mount(
563 &mut self,
564 mount: Mount,
565 backend: Box<dyn MemBackend>,
566 origin: MemOrigin,
567 ) -> Result<(), EngineError> {
568 self.register_writable_mem_inner(mount, backend, origin, true)
569 }
570
571 pub fn unregister_read_mount(
579 &mut self,
580 mem_name: &str,
581 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
582 let pos = self.mounts.iter().position(|m| {
583 m.mount.mem == mem_name
584 && m.mount.capability == crate::workspace::MountCapability::ReadOnly
585 });
586 let Some(idx) = pos else {
587 return Ok(None);
588 };
589 let mount = self.mounts.remove(idx);
590 self.schemas_remove(&mount.mount.mem);
591 let _removed = self.store.remove_entities_by_mem(mem_name);
592 self.load_warnings
593 .retain(|w| w.source_mem() != Some(mem_name));
594 Arc::make_mut(&mut self.mem_router).remove_read_only(mem_name);
595 self.invalidate_communities();
596 self.invalidate_search_indexes();
597 Ok(Some(mount.backend))
598 }
599
600 pub fn push_load_warning(&mut self, warning: crate::ops::WarningHint) {
605 self.load_warnings.push(warning);
606 }
607
608 pub fn register_writable_mem(
647 &mut self,
648 mount: Mount,
649 backend: Box<dyn MemBackend>,
650 origin: MemOrigin,
651 ) -> Result<(), EngineError> {
652 self.register_writable_mem_inner(mount, backend, origin, true)
653 }
654
655 fn register_writable_mem_inner(
665 &mut self,
666 mount: Mount,
667 backend: Box<dyn MemBackend>,
668 origin: MemOrigin,
669 run_global_passes: bool,
670 ) -> Result<(), EngineError> {
671 if let Some(existing) = self.mem_router.origin_for_mem(&mount.mem) {
673 return Err(EngineError::MemNameCollision {
674 name: mount.mem.clone(),
675 source_origin: existing.render_source(),
676 });
677 }
678 if self.mem_router.archive_path_for_mem(&mount.mem).is_some() {
679 return Err(EngineError::MemNameCollision {
680 name: mount.mem.clone(),
681 source_origin: "attached read mem".to_string(),
682 });
683 }
684
685 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
691 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
692 memstead_schema::config::parse_mem_config(&value).ok()
693 });
694
695 let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
705 self.workspace_schemas().to_vec();
706 builtin_schemas.extend(
707 memstead_schema::builtins::load_builtin_schemas()
708 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?,
709 );
710 let config_pin = mem_config.as_ref().and_then(|c| c.schema.as_ref());
711 let mount_pin = mount.schema.as_ref();
712 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
713 && cfg != mp
714 {
715 self.load_warnings
716 .push(crate::ops::WarningHint::SchemaPinMismatch {
717 mem: mount.mem.clone(),
718 config_pin: cfg.as_display(),
719 mount_pin: mp.as_display(),
720 });
721 }
722 let settled_pin = config_pin.or(mount_pin);
723 let effective_pin = mount
724 .migration_target
725 .as_ref()
726 .or(settled_pin)
727 .ok_or_else(|| EngineError::MemConfigIncomplete {
728 mem: mount.mem.clone(),
729 missing_fields: vec!["schema".to_string()],
730 })?
731 .clone();
732 let schema = crate::engine::SchemaResolver::new(&builtin_schemas)
733 .resolve(&effective_pin)
734 .map_err(|sources| {
735 EngineError::SchemaNotFound {
736 mem: mount.mem.clone(),
737 pin: effective_pin.as_display(),
738 sources,
739 install_hint: None,
740 }
741 .with_schema_install_probe(self.workspace_root())
742 })?;
743
744 let (entries, read_errors) = collect_source_entries(backend.as_ref())?;
752 let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
753
754 let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
755 mem_names.push(mount.mem.clone());
756 let known_suffixes: Vec<String> = mem_names
757 .iter()
758 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
759 .collect();
760 let fallback = engine_fallback_type();
761 push_entities_into_store(
762 &mut self.store,
763 load_result.entities,
764 fallback.as_ref(),
765 Some(crate::entity::store_builder::LoadCollector {
766 warnings: &mut self.load_warnings,
767 known_suffixes: &known_suffixes,
768 mem_names: &mem_names,
769 }),
770 );
771 self.load_errors.extend(load_result.errors);
772
773 self.schemas_insert(mount.mem.clone(), schema);
775
776 if run_global_passes {
787 let mut mount_caps: std::collections::HashMap<
788 String,
789 crate::workspace::MountCapability,
790 > = self
791 .mounts
792 .iter()
793 .map(|m| (m.mount.mem.clone(), m.mount.capability))
794 .collect();
795 mount_caps.insert(mount.mem.clone(), mount.capability);
796 crate::entity::store_builder::validate_loaded_relations(
797 &mut self.store,
798 &self.schemas,
799 &mount_caps,
800 &mut self.load_warnings,
801 );
802 crate::entity::store_builder::remap_alias_target_edge_sources(
803 &mut self.store,
804 &self.schemas,
805 );
806 }
807
808 let last_known_head = backend.current_head().ok().flatten();
810 let mem_name_for_router = mount.mem.clone();
811 let storage_for_router = mount.storage.clone();
812 let mount_capability_for_router = mount.capability;
813 self.mounts.push(MountedBackend {
814 mount,
815 backend,
816 last_known_head,
817 mem_config,
818 archive_provenance: None,
821 deferred: false,
823 });
824
825 match (&mount_capability_for_router, &storage_for_router) {
833 (crate::workspace::MountCapability::ReadOnly, MountStorage::Archive { path }) => {
834 Arc::make_mut(&mut self.mem_router)
835 .add_read_only(mem_name_for_router, path.clone());
836 }
837 _ => {
838 let dir: Option<PathBuf> = match &storage_for_router {
839 MountStorage::Folder { path } => Some(path.clone()),
840 MountStorage::GitBranch { .. }
841 | MountStorage::Archive { .. }
842 | MountStorage::InMemory => None,
843 };
844 Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
845 }
846 }
847
848 if run_global_passes {
850 self.invalidate_communities();
851 self.invalidate_search_indexes();
852 }
853
854 Ok(())
855 }
856
857 fn finish_batched_registrations(&mut self) {
862 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
863 .mounts
864 .iter()
865 .map(|m| (m.mount.mem.clone(), m.mount.capability))
866 .collect();
867 crate::entity::store_builder::validate_loaded_relations(
868 &mut self.store,
869 &self.schemas,
870 &mount_caps,
871 &mut self.load_warnings,
872 );
873 crate::entity::store_builder::remap_alias_target_edge_sources(
874 &mut self.store,
875 &self.schemas,
876 );
877 self.invalidate_communities();
878 self.invalidate_search_indexes();
879 }
880
881 pub fn full_refresh(&mut self) -> crate::ops::FullRefreshReport {
906 let started = std::time::Instant::now();
907 let mut report = crate::ops::FullRefreshReport::default();
908
909 let Some(root) = self.workspace_root.clone() else {
910 report.failures.push(crate::ops::RefreshFailure {
911 item: "workspace".to_string(),
912 error: "engine has no workspace root (ad-hoc mount-list construction) — \
913 nothing to re-scan"
914 .to_string(),
915 });
916 report.elapsed_ms = started.elapsed().as_millis() as u64;
917 return report;
918 };
919
920 self.refresh_workspace_settings_if_possible();
923
924 use crate::schema_source::SchemaSource as _;
926 let mut fresh: Vec<std::sync::Arc<memstead_schema::Schema>> = Vec::new();
927 let mut sources_complete = true;
928 match crate::schema_source::FolderSchemaSource::for_workspace(&root).read_schemas() {
929 Ok(mut s) => fresh.append(&mut s),
930 Err(e) => {
931 sources_complete = false;
932 report.failures.push(crate::ops::RefreshFailure {
933 item: "schema-source:folder".to_string(),
934 error: e.to_string(),
935 });
936 }
937 }
938 if let Some(ops) = self.git_branch_ops() {
939 match (ops.read_ref_schemas)(&root) {
940 Ok(mut s) => fresh.append(&mut s),
941 Err(e) => {
942 sources_complete = false;
943 report.failures.push(crate::ops::RefreshFailure {
944 item: "schema-source:memstead-ref".to_string(),
945 error: e.to_string(),
946 });
947 }
948 }
949 }
950 let key = |s: &memstead_schema::Schema| {
951 let (name, version) = s.id();
952 format!("{name}@{version}")
953 };
954 let existing: std::collections::HashSet<String> =
955 self.workspace_schemas.iter().map(|s| key(s)).collect();
956 let fresh_keys: std::collections::HashSet<String> = fresh.iter().map(|s| key(s)).collect();
957 for schema in fresh {
958 let k = key(&schema);
959 if !existing.contains(&k) && !report.schemas_added.contains(&k) {
960 report.schemas_added.push(k);
961 self.workspace_schemas.push(schema);
962 }
963 }
964 report.schemas_added.sort();
965 if sources_complete {
969 report.schema_removals_skipped = existing
970 .difference(&fresh_keys)
971 .cloned()
972 .collect::<Vec<_>>();
973 report.schema_removals_skipped.sort();
974 }
975
976 let store = crate::workspace_store::FileWorkspaceStore::new();
978 match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
979 Err(e) => {
980 report.failures.push(crate::ops::RefreshFailure {
981 item: "mount-manifest".to_string(),
982 error: e.to_string(),
983 });
984 }
985 Ok(workspace) => {
986 let manifest_names: std::collections::HashSet<String> =
987 workspace.mounts.iter().map(|m| m.mem.clone()).collect();
988 let mut any_mounted = false;
989 for mount in workspace.mounts {
990 if self.mounts.iter().any(|m| m.mount.mem == mount.mem) {
991 continue;
992 }
993 let name = mount.mem.clone();
994 if mount.capability != crate::workspace::MountCapability::Write {
995 report.failures.push(crate::ops::RefreshFailure {
996 item: format!("mount:{name}"),
997 error: "only writable mounts attach on a warm refresh — \
998 restart the process to attach this mount"
999 .to_string(),
1000 });
1001 continue;
1002 }
1003 let backend = match (self.backend_factory)(&mount) {
1004 Ok(b) => b,
1005 Err(e) => {
1006 report.failures.push(crate::ops::RefreshFailure {
1007 item: format!("mount:{name}"),
1008 error: e.to_string(),
1009 });
1010 continue;
1011 }
1012 };
1013 match self.register_writable_mem_inner(
1014 mount,
1015 backend,
1016 crate::mem::MemOrigin::ExplicitToml,
1017 false,
1018 ) {
1019 Ok(()) => {
1020 any_mounted = true;
1021 report.mems_mounted.push(name);
1022 }
1023 Err(e) => report.failures.push(crate::ops::RefreshFailure {
1024 item: format!("mount:{name}"),
1025 error: e.to_string(),
1026 }),
1027 }
1028 }
1029 report.mem_removals_skipped = self
1034 .mounts
1035 .iter()
1036 .filter(|m| {
1037 m.mount.capability == crate::workspace::MountCapability::Write
1038 && !manifest_names.contains(&m.mount.mem)
1039 })
1040 .map(|m| m.mount.mem.clone())
1041 .collect();
1042 report.mem_removals_skipped.sort();
1043 if any_mounted {
1044 self.finish_batched_registrations();
1046 }
1047 }
1048 }
1049
1050 report.elapsed_ms = started.elapsed().as_millis() as u64;
1051 report
1052 }
1053
1054 pub fn set_workspace_root(&mut self, root: PathBuf) {
1060 self.workspace_root = Some(root);
1061 }
1062
1063 pub fn persist_state(&self) -> Result<(), EngineError> {
1086 let Some(root) = self.workspace_root.as_ref() else {
1087 return Ok(());
1088 };
1089 let workspace = crate::workspace::Workspace {
1090 mounts: self.mounts.iter().map(|m| m.mount.clone()).collect(),
1091 settings: self.settings.clone(),
1092 };
1093 let store = crate::FileWorkspaceStore::new();
1094 crate::workspace_store::WorkspaceStoreAdapter::save_state(&store, root, &workspace)
1095 .map_err(|e| EngineError::Mem(format!("persist workspace state: {e}")))
1096 }
1097 pub fn set_mem_schema(
1117 &mut self,
1118 mem: &str,
1119 target: &memstead_schema::SchemaRef,
1120 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1121 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1122 if self.quarantine_reason(mem).is_some() {
1128 return self.set_schema_on_quarantined(mem, target);
1129 }
1130 let mount_idx = self
1131 .mounts
1132 .iter()
1133 .position(|m| m.mount.mem == mem)
1134 .ok_or_else(|| self.unknown_mem_error(mem))?;
1135 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1141 return Err(EngineError::ReadOnlyMount(mem.to_string()));
1142 }
1143
1144 let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
1147 let consulted: Vec<_> = self
1150 .workspace_schemas
1151 .iter()
1152 .chain(self.builtin_schemas.iter())
1153 .cloned()
1154 .collect();
1155 EngineError::SchemaNotFound {
1156 mem: mem.to_string(),
1157 pin: target.as_display(),
1158 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1159 &target.name,
1160 &target.version,
1161 &consulted,
1162 ),
1163 install_hint: None,
1164 }
1165 .with_schema_install_probe(self.workspace_root())
1166 })?;
1167
1168 let current_pin = self.mounts[mount_idx].mount.schema.clone();
1173 let current_pin_display = current_pin
1174 .as_ref()
1175 .map(|p| p.as_display())
1176 .unwrap_or_else(|| "<unset>".to_string());
1177 let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1178
1179 if current_pin.as_ref() == Some(target) {
1180 return Ok(SetSchemaOutcome {
1181 mem: mem.to_string(),
1182 schema_pin: current_pin_display,
1183 migration_target: in_flight.map(|t| t.as_display()),
1184 outcome: SetSchemaResult::Noop,
1185 findings: Vec::new(),
1186 });
1187 }
1188
1189 let findings = crate::ops::integrity::conformance_findings(
1195 &self.store,
1196 mem,
1197 target_schema.as_ref(),
1198 &self.schemas,
1199 );
1200
1201 if findings.is_empty() {
1202 self.persist_mem_schema_pin(mount_idx, target)?;
1210 self.mounts[mount_idx].mount.schema = Some(target.clone());
1211 self.mounts[mount_idx].mount.migration_target = None;
1212 self.schemas_insert(mem.to_string(), target_schema);
1213 self.invalidate_communities();
1214 self.invalidate_search_indexes();
1219 self.persist_state()?;
1220 return Ok(SetSchemaOutcome {
1221 mem: mem.to_string(),
1222 schema_pin: target.as_display(),
1223 migration_target: None,
1224 outcome: SetSchemaResult::Switched,
1225 findings: Vec::new(),
1226 });
1227 }
1228
1229 let outcome = if in_flight.as_ref() == Some(target) {
1230 SetSchemaResult::MigrationPending
1231 } else {
1232 SetSchemaResult::MigrationStarted
1233 };
1234 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1235 self.schemas_insert(mem.to_string(), target_schema);
1238 self.invalidate_communities();
1239 self.invalidate_search_indexes();
1241 self.persist_state()?;
1242 Ok(SetSchemaOutcome {
1243 mem: mem.to_string(),
1244 schema_pin: current_pin_display,
1245 migration_target: Some(target.as_display()),
1246 outcome,
1247 findings,
1248 })
1249 }
1250
1251 fn persist_mem_schema_pin(
1268 &mut self,
1269 mount_idx: usize,
1270 target: &memstead_schema::SchemaRef,
1271 ) -> Result<(), EngineError> {
1272 let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1273 if let Some(value) = value
1276 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1277 {
1278 self.mounts[mount_idx].mem_config = Some(cfg);
1279 }
1280 Ok(())
1281 }
1282
1283 pub fn export_markdown(
1312 &self,
1313 mem_filter: Option<&str>,
1314 schema_filter: Option<&str>,
1315 ) -> Result<crate::ops::ExportResult, EngineError> {
1316 use crate::workspace::MountStorage;
1317 let fallback = engine_fallback_type();
1318 let supported_backends = vec!["folder".to_string()];
1319
1320 if let Some(name) = mem_filter {
1321 let mount = self
1322 .mounts
1323 .iter()
1324 .find(|m| m.mount.mem == name)
1325 .ok_or_else(|| self.unknown_mem_error(name))?;
1326 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1327 return Err(EngineError::MarkdownExportUnsupportedBackend {
1328 mem: name.to_string(),
1329 active_backend: mount.mount.storage.backend_id().to_string(),
1330 supported_backends,
1331 });
1332 }
1333 }
1334
1335 let mut total_written = 0;
1336 let mut total_unchanged = 0;
1337 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1338
1339 for mount in &self.mounts {
1340 let mem_name = mount.mount.mem.as_str();
1341 if let Some(filter) = mem_filter
1342 && mem_name != filter
1343 {
1344 continue;
1345 }
1346 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1347 skipped_mounts.push(crate::ops::SkippedMount {
1348 mem: mem_name.to_string(),
1349 active_backend: mount.mount.storage.backend_id().to_string(),
1350 reason: "backend_does_not_support_markdown_export".to_string(),
1351 });
1352 continue;
1353 };
1354 let schema = match self.schemas.get(mem_name) {
1355 Some(s) => s,
1356 None => continue,
1357 };
1358
1359 for entity in self.store.all_entities() {
1360 if entity.stub || entity.file_path.is_empty() {
1361 continue;
1362 }
1363 if entity.id.mem() != mem_name {
1364 continue;
1365 }
1366 if let Some(filter) = schema_filter
1367 && entity.entity_type != filter
1368 {
1369 continue;
1370 }
1371 let type_def = schema
1372 .get_type(&entity.entity_type)
1373 .unwrap_or_else(|| fallback.clone());
1374 let generated = generate_markdown(entity, type_def.as_ref());
1375
1376 let full_path = mem_dir.join(&entity.file_path);
1377 let needs_write = match std::fs::read_to_string(&full_path) {
1378 Ok(existing) => existing != generated,
1379 Err(_) => true,
1380 };
1381 if needs_write {
1382 let _ = crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref());
1383 total_written += 1;
1384 } else {
1385 total_unchanged += 1;
1386 }
1387 }
1388 }
1389
1390 Ok(crate::ops::ExportResult {
1391 written: total_written,
1392 unchanged: total_unchanged,
1393 skipped_mounts,
1394 })
1395 }
1396
1397 pub fn export_mem(
1415 &self,
1416 mem_name: &str,
1417 output_path: &std::path::Path,
1418 ) -> Result<crate::ops::MemExportResult, EngineError> {
1419 let mount = self
1420 .mounts
1421 .iter()
1422 .find(|m| m.mount.mem == mem_name)
1423 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1424 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1425 EngineError::InvalidInput(format!(
1426 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1427 ))
1428 })?;
1429 if config.version.is_none() {
1437 return Err(EngineError::MemConfigIncomplete {
1438 mem: mem_name.to_string(),
1439 missing_fields: vec!["version".to_string()],
1440 });
1441 }
1442 let workspace_root = self.workspace_root.as_deref();
1443 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1447 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1448 match &mount.mount.storage {
1449 MountStorage::Folder { path } => crate::ops::export::export_mem(
1450 path,
1451 config,
1452 output_path,
1453 workspace_root,
1454 workspace_schemas_dir,
1455 )
1456 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1457 MountStorage::GitBranch { gitdir, branch } => {
1458 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1459 EngineError::Backend(BackendError::Other(
1460 "git-branch export hook not installed (full flavour not loaded)"
1461 .to_string(),
1462 ))
1463 })?;
1464 let provenance_bytes = mount
1468 .backend
1469 .read_provenance(None)
1470 .ok()
1471 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
1472 .and_then(|prov| prov.to_archive_bytes().ok());
1473 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1476 (hook.export)(
1477 gitdir,
1478 branch,
1479 mem_name,
1480 config,
1481 output_path,
1482 workspace_root,
1483 workspace_schemas_dir,
1484 provenance_bytes.as_deref(),
1485 anchors_bytes.as_deref(),
1486 )
1487 .map_err(EngineError::Backend)
1488 }
1489 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1490 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1495 "export not yet supported for in-memory backend".to_string(),
1496 ))),
1497 }
1498 }
1499
1500 pub fn record_pipeline_edit_provenance(
1524 &self,
1525 mem: &str,
1526 kind: &str,
1527 edits: &[(String, Option<Vec<u8>>)],
1528 note: Option<&str>,
1529 verb: &str,
1530 ) -> Result<(), crate::backend::BackendError> {
1531 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1532 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1533 None => Ok(()),
1534 }
1535 }
1536
1537 pub fn set_mem_version(
1538 &mut self,
1539 mem_name: &str,
1540 new_version: semver::Version,
1541 note: Option<&str>,
1542 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1543 let mount_idx = self
1546 .mounts
1547 .iter()
1548 .position(|m| m.mount.mem == mem_name)
1549 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1550 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1551 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1552 }
1553
1554 let mut warnings = self.reload_if_stale(Some(mem_name));
1562 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1567 warnings.push(w);
1568 }
1569
1570 let mounted = &mut self.mounts[mount_idx];
1571 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1572 EngineError::InvalidInput(format!(
1573 "mem '{mem_name}' has no loaded MemConfig — \
1574 cannot set version (initialize the mem via `memstead init` \
1575 or `memstead mem create` first)"
1576 ))
1577 })?;
1578 let old_version = config.version.clone();
1579 config.version = Some(new_version.clone());
1580
1581 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1582 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1583 })?;
1584 bytes.push(b'\n');
1585 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1586 mounted.mem_config = Some(config);
1587
1588 let new_head = mounted.backend.current_head().ok().flatten();
1594 if let Some(sha) = new_head {
1595 mounted.last_known_head = Some(sha);
1596 }
1597
1598 Ok(crate::ops::SetMemVersionOutcome {
1599 mem: mem_name.to_string(),
1600 old_version,
1601 new_version,
1602 warnings,
1603 })
1604 }
1605
1606 pub fn set_mem_description(
1613 &mut self,
1614 mem_name: &str,
1615 new_description: Option<String>,
1616 note: Option<&str>,
1617 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1618 let mount_idx = self
1619 .mounts
1620 .iter()
1621 .position(|m| m.mount.mem == mem_name)
1622 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1623 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1624 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1625 }
1626
1627 let mut warnings = self.reload_if_stale(Some(mem_name));
1628 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1629 warnings.push(w);
1630 }
1631
1632 let mounted = &mut self.mounts[mount_idx];
1633 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1634 EngineError::InvalidInput(format!(
1635 "mem '{mem_name}' has no loaded MemConfig — \
1636 cannot set description (initialize the mem via `memstead init` \
1637 or `memstead mem create` first)"
1638 ))
1639 })?;
1640 let old_description = config.description.clone();
1641 config.description = new_description.clone();
1642
1643 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1644 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1645 })?;
1646 bytes.push(b'\n');
1647 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1648 mounted.mem_config = Some(config);
1649
1650 let new_head = mounted.backend.current_head().ok().flatten();
1651 if let Some(sha) = new_head {
1652 mounted.last_known_head = Some(sha);
1653 }
1654
1655 Ok(crate::ops::SetMemDescriptionOutcome {
1656 mem: mem_name.to_string(),
1657 old_description,
1658 new_description,
1659 warnings,
1660 })
1661 }
1662
1663 pub fn set_mem_title(
1668 &mut self,
1669 mem_name: &str,
1670 new_title: Option<String>,
1671 note: Option<&str>,
1672 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1673 let mount_idx = self
1674 .mounts
1675 .iter()
1676 .position(|m| m.mount.mem == mem_name)
1677 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1678 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1679 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1680 }
1681
1682 let mut warnings = self.reload_if_stale(Some(mem_name));
1683 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1684 warnings.push(w);
1685 }
1686
1687 let mounted = &mut self.mounts[mount_idx];
1688 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1689 EngineError::InvalidInput(format!(
1690 "mem '{mem_name}' has no loaded MemConfig — cannot set title"
1691 ))
1692 })?;
1693 let old_title = config.title.clone();
1694 config.title = new_title.clone();
1695
1696 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1697 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1698 })?;
1699 bytes.push(b'\n');
1700 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1701 mounted.mem_config = Some(config);
1702
1703 let new_head = mounted.backend.current_head().ok().flatten();
1704 if let Some(sha) = new_head {
1705 mounted.last_known_head = Some(sha);
1706 }
1707
1708 Ok(crate::ops::SetMemTitleOutcome {
1709 mem: mem_name.to_string(),
1710 old_title,
1711 new_title,
1712 warnings,
1713 })
1714 }
1715
1716 pub fn set_mem_subject(
1720 &mut self,
1721 mem_name: &str,
1722 new_subject: Option<memstead_schema::MemSubject>,
1723 note: Option<&str>,
1724 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1725 let mount_idx = self
1726 .mounts
1727 .iter()
1728 .position(|m| m.mount.mem == mem_name)
1729 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1730 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1731 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1732 }
1733
1734 let mut warnings = self.reload_if_stale(Some(mem_name));
1735 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
1736 warnings.push(w);
1737 }
1738
1739 let mounted = &mut self.mounts[mount_idx];
1740 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1741 EngineError::InvalidInput(format!(
1742 "mem '{mem_name}' has no loaded MemConfig — cannot set subject"
1743 ))
1744 })?;
1745 let old_subject = config.subject.clone();
1746 config.subject = new_subject.clone();
1747
1748 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1749 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1750 })?;
1751 bytes.push(b'\n');
1752 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1753 mounted.mem_config = Some(config);
1754
1755 let new_head = mounted.backend.current_head().ok().flatten();
1756 if let Some(sha) = new_head {
1757 mounted.last_known_head = Some(sha);
1758 }
1759
1760 Ok(crate::ops::SetMemSubjectOutcome {
1761 mem: mem_name.to_string(),
1762 old_subject,
1763 new_subject,
1764 warnings,
1765 })
1766 }
1767
1768 pub fn set_mem_internal(
1780 &mut self,
1781 mem_name: &str,
1782 internal: bool,
1783 note: Option<&str>,
1784 ) -> Result<bool, EngineError> {
1785 let mount_idx = self
1786 .mounts
1787 .iter()
1788 .position(|m| m.mount.mem == mem_name)
1789 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1790 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1791 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1792 }
1793
1794 let _ = self.reload_if_stale(Some(mem_name));
1795
1796 let mounted = &mut self.mounts[mount_idx];
1797 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1798 EngineError::InvalidInput(format!(
1799 "mem '{mem_name}' has no loaded MemConfig — initialize the mem first"
1800 ))
1801 })?;
1802 if internal {
1803 config
1804 .extra
1805 .insert("internal".to_string(), serde_json::Value::Bool(true));
1806 } else {
1807 config.extra.remove("internal");
1808 }
1809
1810 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1811 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1812 })?;
1813 bytes.push(b'\n');
1814 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1815 mounted.mem_config = Some(config);
1816
1817 let new_head = mounted.backend.current_head().ok().flatten();
1818 if let Some(sha) = new_head {
1819 mounted.last_known_head = Some(sha);
1820 }
1821
1822 Ok(internal)
1823 }
1824
1825 pub fn set_mem_sync_state(
1850 &mut self,
1851 mem_name: &str,
1852 key: &str,
1853 token: &str,
1854 note: Option<&str>,
1855 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
1856 let mount_idx = self
1859 .mounts
1860 .iter()
1861 .position(|m| m.mount.mem == mem_name)
1862 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1863 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1864 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1865 }
1866
1867 let mut warnings = self.reload_if_stale(Some(mem_name));
1871 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
1872 warnings.push(w);
1873 }
1874
1875 let mounted = &mut self.mounts[mount_idx];
1876 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1877 EngineError::InvalidInput(format!(
1878 "mem '{mem_name}' has no loaded MemConfig — \
1879 cannot set sync state (initialize the mem via `memstead init` \
1880 or `memstead mem create` first)"
1881 ))
1882 })?;
1883
1884 let removed;
1888 let previous;
1889 if token.is_empty() {
1890 previous = config.sync_state.remove(key);
1891 removed = previous.is_some();
1892 } else {
1893 previous = config.sync_state.insert(key.to_string(), token.to_string());
1894 removed = false;
1895 }
1896
1897 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1898 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1899 })?;
1900 bytes.push(b'\n');
1901 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1902 mounted.mem_config = Some(config);
1903
1904 let new_head = mounted.backend.current_head().ok().flatten();
1907 if let Some(sha) = new_head {
1908 mounted.last_known_head = Some(sha);
1909 }
1910
1911 Ok(crate::ops::SetMemSyncStateOutcome {
1912 mem: mem_name.to_string(),
1913 key: key.to_string(),
1914 previous,
1915 removed,
1916 warnings,
1917 })
1918 }
1919
1920 pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
1967 let pending: Vec<String> = self
1968 .mounts
1969 .iter()
1970 .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
1971 .map(|m| m.mount.mem.clone())
1972 .collect();
1973 for name in pending {
1974 match self.reload_one_mem(&name) {
1975 Ok(_) => {
1976 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
1977 state.deferred = false;
1978 }
1979 let store = &self.store;
1985 self.load_warnings.retain(|w| match w {
1986 crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1987 store.get(resolved_id).is_none_or(|e| e.stub)
1988 }
1989 _ => true,
1990 });
1991 }
1992 Err(e) => {
1993 let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
1998 continue;
1999 };
2000 let removed = self.mounts.remove(idx);
2001 self.schemas_remove(&name);
2002 self.quarantined.push(crate::engine::QuarantinedMem {
2003 mount: removed.mount,
2004 reason_code: e.code().to_string(),
2005 reason_message: e.to_string(),
2006 });
2007 self.mem_router = std::sync::Arc::new(
2008 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2009 );
2010 self.invalidate_communities();
2011 self.invalidate_search_indexes();
2017 }
2018 }
2019 }
2020 }
2021
2022 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2023 if self.quarantine_reason(mem).is_some() {
2046 return self.reattach_quarantined_mem(mem);
2047 }
2048 let mut sink: Vec<WarningHint> = Vec::new();
2049 let result = self.reload_one_mem_inner(mem, &mut sink)?;
2050 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2051 self.load_warnings
2052 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2053 Ok(result)
2054 }
2055
2056 fn set_schema_on_quarantined(
2066 &mut self,
2067 mem: &str,
2068 target: &memstead_schema::SchemaRef,
2069 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2070 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2071 if self.resolve_schema_by_ref(target).is_none() {
2073 let consulted: Vec<_> = self
2074 .workspace_schemas
2075 .iter()
2076 .chain(self.builtin_schemas.iter())
2077 .cloned()
2078 .collect();
2079 return Err(EngineError::SchemaNotFound {
2080 mem: mem.to_string(),
2081 pin: target.as_display(),
2082 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2083 &target.name,
2084 &target.version,
2085 &consulted,
2086 ),
2087 install_hint: None,
2088 }
2089 .with_schema_install_probe(self.workspace_root()));
2090 }
2091 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2092 return Err(self.unknown_mem_error(mem));
2093 };
2094 self.quarantined[q_idx].mount.schema = Some(target.clone());
2098 self.quarantined[q_idx].mount.migration_target = None;
2099 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2100 let _ = bump_backend_schema_pin(backend.as_ref(), target);
2101 }
2102 self.persist_state()?;
2103 let _ = self.reattach_quarantined_mem(mem);
2108 Ok(SetSchemaOutcome {
2109 mem: mem.to_string(),
2110 schema_pin: target.as_display(),
2111 migration_target: None,
2112 outcome: SetSchemaResult::Switched,
2113 findings: Vec::new(),
2114 })
2115 }
2116
2117 fn reattach_quarantined_mem(
2125 &mut self,
2126 mem: &str,
2127 ) -> Result<crate::ops::ReloadResult, EngineError> {
2128 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2129 return Err(self.unknown_mem_error(mem));
2130 };
2131 let mount = self.quarantined[q_idx].mount.clone();
2132
2133 let requarantine = |this: &mut Self, e: &EngineError| {
2134 this.quarantined[q_idx].reason_code = e.code().to_string();
2135 this.quarantined[q_idx].reason_message = e.to_string();
2136 };
2137
2138 let backend = match (self.backend_factory)(&mount) {
2139 Ok(b) => b,
2140 Err(e) => {
2141 let err = EngineError::Mem(e.to_string());
2142 requarantine(self, &err);
2143 return Err(self.unknown_mem_error(mem));
2144 }
2145 };
2146
2147 let last_known_head = backend.current_head().ok().flatten();
2149 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2150 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2151 memstead_schema::config::parse_mem_config(&value).ok()
2152 });
2153 let archive_provenance = backend
2154 .read_archive_provenance()
2155 .ok()
2156 .flatten()
2157 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2158 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2159 let effective_pin = mount
2160 .migration_target
2161 .clone()
2162 .or(config_pin)
2163 .or(mount.schema.clone());
2164 let Some(effective_pin) = effective_pin else {
2165 let err = EngineError::MemConfigIncomplete {
2166 mem: mem.to_string(),
2167 missing_fields: vec!["schema".to_string()],
2168 };
2169 requarantine(self, &err);
2170 return Err(self.unknown_mem_error(mem));
2171 };
2172 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2173 .workspace_schemas
2174 .iter()
2175 .chain(self.builtin_schemas.iter())
2176 .cloned()
2177 .collect();
2178 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2179 Ok(s) => s,
2180 Err(sources) => {
2181 let err = EngineError::SchemaNotFound {
2182 mem: mem.to_string(),
2183 pin: effective_pin.as_display(),
2184 sources,
2185 install_hint: None,
2186 }
2187 .with_schema_install_probe(self.workspace_root());
2188 requarantine(self, &err);
2189 return Err(self.unknown_mem_error(mem));
2190 }
2191 };
2192
2193 self.quarantined.remove(q_idx);
2197 self.schemas_insert(mem.to_string(), schema);
2198 self.mounts.push(crate::engine::MountedBackend {
2199 mount,
2200 backend,
2201 last_known_head,
2202 mem_config,
2203 archive_provenance,
2204 deferred: false,
2207 });
2208 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2209 &self.mounts,
2210 ));
2211 let mut sink: Vec<WarningHint> = Vec::new();
2212 match self.reload_one_mem_inner(mem, &mut sink) {
2213 Ok(result) => {
2214 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2215 self.load_warnings
2216 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2217 self.invalidate_communities();
2218 Ok(result)
2219 }
2220 Err(e) => {
2221 let mount_idx = self.mounts.len() - 1;
2222 let mounted = self.mounts.remove(mount_idx);
2223 self.schemas_remove(mem);
2224 self.mem_router = std::sync::Arc::new(
2225 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2226 );
2227 self.quarantined.push(crate::engine::QuarantinedMem {
2228 mount: mounted.mount,
2229 reason_code: e.code().to_string(),
2230 reason_message: e.to_string(),
2231 });
2232 self.invalidate_communities();
2236 self.invalidate_search_indexes();
2237 Err(e)
2238 }
2239 }
2240 }
2241
2242 fn reload_one_mem_inner(
2248 &mut self,
2249 mem: &str,
2250 warnings_sink: &mut Vec<WarningHint>,
2251 ) -> Result<crate::ops::ReloadResult, EngineError> {
2252 let mount_idx = self
2255 .mounts
2256 .iter()
2257 .position(|m| m.mount.mem == mem)
2258 .ok_or_else(|| self.unknown_mem_error(mem))?;
2259 let schema = self
2260 .schemas
2261 .get(mem)
2262 .cloned()
2263 .ok_or_else(|| self.unknown_mem_error(mem))?;
2264
2265 let pre: HashMap<EntityId, String> = self
2267 .store
2268 .all_entities()
2269 .filter(|e| !e.stub && e.mem == mem)
2270 .map(|e| (e.id.clone(), e.content_hash.clone()))
2271 .collect();
2272 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2273
2274 let backend = self.mounts[mount_idx].backend.as_ref();
2277 let (entries, read_errors) = collect_source_entries(backend)?;
2278 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2279
2280 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2284 let known_suffixes: Vec<String> = mem_names
2285 .iter()
2286 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2287 .collect();
2288
2289 self.store.remove_entities_by_mem(mem);
2291 let fallback = engine_fallback_type();
2292 push_entities_into_store(
2293 &mut self.store,
2294 load_result.entities,
2295 fallback.as_ref(),
2296 Some(crate::entity::store_builder::LoadCollector {
2297 warnings: warnings_sink,
2298 known_suffixes: &known_suffixes,
2299 mem_names: &mem_names,
2300 }),
2301 );
2302 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2309 .mounts
2310 .iter()
2311 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2312 .collect();
2313 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2321 crate::entity::store_builder::validate_loaded_relations(
2322 &mut self.store,
2323 &self.schemas,
2324 &mount_caps,
2325 warnings_sink,
2326 );
2327 crate::entity::store_builder::remap_alias_target_edge_sources(
2328 &mut self.store,
2329 &self.schemas,
2330 );
2331 if let crate::workspace::MountStorage::Folder { path } =
2341 &self.mounts[mount_idx].mount.storage
2342 {
2343 let root = path.clone();
2344 self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2345 self.load_errors
2349 .extend(load_result.errors.into_iter().map(|(p, m)| {
2350 let abs = if p.is_relative() { root.join(&p) } else { p };
2351 (abs, m)
2352 }));
2353 } else {
2354 self.load_errors.extend(load_result.errors);
2355 }
2356
2357 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2365 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2366 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2367 {
2368 self.mounts[mount_idx].mem_config = Some(cfg);
2369 }
2370
2371 let mut added: Vec<EntityId> = Vec::new();
2373 let mut changed: Vec<EntityId> = Vec::new();
2374 for entity in self.store.all_entities() {
2375 if entity.stub || entity.mem != mem {
2376 continue;
2377 }
2378 match pre.get(&entity.id) {
2379 None => added.push(entity.id.clone()),
2380 Some(prev_hash) if prev_hash != &entity.content_hash => {
2381 changed.push(entity.id.clone());
2382 }
2383 Some(_) => {}
2384 }
2385 }
2386 let post_ids: std::collections::HashSet<EntityId> = self
2387 .store
2388 .all_entities()
2389 .filter(|e| !e.stub && e.mem == mem)
2390 .map(|e| e.id.clone())
2391 .collect();
2392 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2393 added.sort_by(|a, b| a.0.cmp(&b.0));
2394 changed.sort_by(|a, b| a.0.cmp(&b.0));
2395 removed.sort_by(|a, b| a.0.cmp(&b.0));
2396
2397 self.invalidate_communities();
2398 self.invalidate_search_indexes();
2399
2400 Ok(crate::ops::ReloadResult {
2401 added,
2402 changed,
2403 removed,
2404 })
2405 }
2406
2407 pub fn reload_one_mem_report(
2435 &mut self,
2436 mem: &str,
2437 ) -> Result<crate::ops::ReloadReport, EngineError> {
2438 let tracks_head = self
2448 .mounts
2449 .iter()
2450 .find(|m| m.mount.mem == mem)
2451 .and_then(|m| m.backend.current_head().ok().flatten())
2452 .is_some();
2453 let head_before = if tracks_head {
2454 self.mounts
2455 .iter()
2456 .find(|m| m.mount.mem == mem)
2457 .and_then(|m| m.last_known_head.clone())
2458 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2459 } else {
2460 crate::ops::EMPTY_TREE_SHA.to_string()
2461 };
2462
2463 let result = self.reload_one_mem(mem)?;
2464
2465 let head_after_raw = self
2472 .mounts
2473 .iter()
2474 .find(|m| m.mount.mem == mem)
2475 .and_then(|m| m.backend.current_head().ok().flatten());
2476 if let Some(new_head) = head_after_raw.clone()
2477 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2478 {
2479 m.last_known_head = Some(new_head);
2480 }
2481 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2482
2483 let entities_loaded = self
2484 .store
2485 .all_entities()
2486 .filter(|e| !e.stub && e.mem == mem)
2487 .count();
2488
2489 let mut changed_entity_ids: Vec<EntityId> = result
2494 .added
2495 .into_iter()
2496 .chain(result.changed)
2497 .chain(result.removed)
2498 .collect();
2499 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2500
2501 Ok(crate::ops::ReloadReport {
2502 mem: mem.to_string(),
2503 head_before,
2504 head_after,
2505 entities_loaded,
2506 changed_entity_ids,
2507 })
2508 }
2509
2510 pub fn reload_each_writable_mem_reports(
2550 &mut self,
2551 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2552 self.refresh_workspace_settings_if_possible();
2553 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2554 let mut out = Vec::with_capacity(names.len());
2555 for name in names {
2556 let report = self.reload_one_mem_report(&name)?;
2557 out.push(report);
2558 }
2559 Ok(out)
2560 }
2561
2562 fn refresh_workspace_settings_if_possible(&mut self) {
2574 let Some(root) = self.workspace_root.clone() else {
2575 return;
2576 };
2577 let store = crate::workspace_store::FileWorkspaceStore::new();
2578 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2579 Ok(w) => w,
2580 Err(_) => return,
2581 };
2582 self.set_settings(workspace.settings);
2583 }
2584
2585 pub fn reload_each_writable_mem(
2601 &mut self,
2602 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2603 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2604 let mut sink = std::mem::take(&mut self.load_warnings);
2610 sink.clear();
2611 let mut out = Vec::with_capacity(names.len());
2612 let mut loop_err = None;
2613 for name in names {
2614 match self.reload_one_mem_inner(&name, &mut sink) {
2615 Ok(result) => out.push((name, result)),
2616 Err(e) => {
2617 loop_err = Some(e);
2618 break;
2619 }
2620 }
2621 }
2622 self.load_warnings = sink;
2623 if let Some(e) = loop_err {
2624 return Err(e);
2625 }
2626 Ok(out)
2627 }
2628}
2629
2630pub fn bump_backend_schema_pin(
2643 backend: &dyn crate::backend::MemBackend,
2644 target: &memstead_schema::SchemaRef,
2645) -> Result<Option<serde_json::Value>, EngineError> {
2646 let Some(bytes) = backend
2647 .read_mem_config()
2648 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2649 else {
2650 return Ok(None);
2651 };
2652 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2653 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2654 value["schema"] = serde_json::Value::String(target.as_display());
2655 let new_bytes = serde_json::to_vec_pretty(&value)
2656 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2657 backend
2658 .write_mem_config(&new_bytes)
2659 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2660 Ok(Some(value))
2661}
2662
2663#[cfg(test)]
2664mod tests {
2665
2666 use tempfile::TempDir;
2667
2668 use crate::backend::{BackendError, MemBackend};
2669 use crate::engine::test_helpers::*;
2670 use crate::engine::{Engine, EngineError};
2671 use crate::mem::MemOrigin;
2672 use crate::ops::WarningHint;
2673 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
2674
2675 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
2676 let manifest = format!(
2677 r#"name: {manifest_name}
2678version: 1.0.0
2679description: Install-gate test schema
2680when_to_use: Tests
2681types:
2682 - sample
2683relationships:
2684 mode: strict
2685 definitions:
2686 - name: PART_OF
2687 description: hier
2688 default_weight: 3.0
2689 - name: _default
2690 description: fallback
2691 default_weight: 1.0
2692community:
2693 resolution: 1.0
2694 seed: 42
2695"#
2696 );
2697 let type_yaml = format!(
2698 r#"name: sample
2699description: t
2700when_to_use: tests
2701sections:
2702 - key: body
2703 heading: {heading}
2704 required: true
2705 search_weight: 10.0
2706 catch_all: true
2707 write_rules: []
2708metadata_fields: []
2709title_weight: 100.0
2710text_fields:
2711 - body
2712hierarchy_relationship: PART_OF
2713no_self_loop_relationships: []
2714updatable_fields:
2715 - title
2716 - body
2717health_required_fields:
2718 - body
2719staleness_threshold_days: 90
2720write_rules: []
2721"#
2722 );
2723 vec![
2724 ("schema.yaml".to_string(), manifest.into_bytes()),
2725 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2726 ]
2727 }
2728
2729 #[test]
2733 fn install_gate_refuses_non_roundtrip_heading() {
2734 let ok =
2735 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
2736 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
2737
2738 let err = Engine::validate_schema_package(
2739 "gate",
2740 "1.0.0",
2741 &schema_package_files("Body Text", "gate"),
2742 )
2743 .expect_err("non-deriving heading must refuse install");
2744 match &err {
2745 EngineError::SchemaPackageInvalid { name, message, .. } => {
2746 assert_eq!(name, "gate");
2747 assert!(
2748 message.contains("'body'") && message.contains("'Body Text'"),
2749 "message names the offending tuple: {message}"
2750 );
2751 }
2752 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2753 }
2754 }
2755
2756 fn exemplar_package_files(
2763 section_key: &str,
2764 status_value: &str,
2765 rel_type: &str,
2766 ) -> Vec<(String, Vec<u8>)> {
2767 let manifest = r#"name: gate
2768version: 1.0.0
2769description: exemplar gate fixture
2770when_to_use: tests
2771types:
2772 - sample
2773 - other
2774relationships:
2775 mode: strict
2776 definitions:
2777 - name: PART_OF
2778 description: hier
2779 default_weight: 3.0
2780 - name: REFINES
2781 description: pinned
2782 default_weight: 1.0
2783 source_types: [other]
2784 - name: _default
2785 description: fallback
2786 default_weight: 1.0
2787community:
2788 resolution: 1.0
2789 seed: 42
2790"#
2791 .to_string();
2792 let type_yaml = format!(
2793 r#"name: sample
2794description: t
2795when_to_use: tests
2796sections:
2797 - key: body
2798 heading: Body
2799 required: true
2800 search_weight: 10.0
2801 catch_all: true
2802 write_rules: []
2803metadata_fields:
2804 - key: status
2805 description: workflow state
2806 field_type: string
2807 enum_values: [draft, final]
2808title_weight: 100.0
2809text_fields:
2810 - body
2811hierarchy_relationship: PART_OF
2812no_self_loop_relationships: []
2813updatable_fields:
2814 - title
2815 - body
2816health_required_fields:
2817 - body
2818staleness_threshold_days: 90
2819write_rules: []
2820exemplar:
2821 title: A Conforming Sample
2822 metadata:
2823 status: "{status_value}"
2824 sections:
2825 {section_key}: "One canonical body paragraph."
2826 relations:
2827 - to: parent-placeholder
2828 type: {rel_type}
2829"#
2830 );
2831 let other_yaml = r#"name: other
2832description: shape-pin partner
2833when_to_use: tests
2834sections:
2835 - key: body
2836 heading: Body
2837 required: true
2838 search_weight: 10.0
2839 catch_all: true
2840 write_rules: []
2841metadata_fields: []
2842title_weight: 100.0
2843text_fields:
2844 - body
2845hierarchy_relationship: PART_OF
2846no_self_loop_relationships: []
2847updatable_fields:
2848 - title
2849 - body
2850health_required_fields:
2851 - body
2852staleness_threshold_days: 90
2853write_rules: []
2854"#
2855 .to_string();
2856 vec![
2857 ("schema.yaml".to_string(), manifest.into_bytes()),
2858 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2859 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
2860 ]
2861 }
2862
2863 #[test]
2870 fn install_gate_validates_exemplars_through_the_real_create_path() {
2871 let ok = Engine::validate_schema_package(
2873 "gate",
2874 "1.0.0",
2875 &exemplar_package_files("body", "draft", "PART_OF"),
2876 );
2877 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
2878
2879 let err = Engine::validate_schema_package(
2881 "gate",
2882 "1.0.0",
2883 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
2884 )
2885 .expect_err("wrong section key must refuse");
2886 match &err {
2887 EngineError::SchemaPackageInvalid { message, .. } => {
2888 assert!(
2889 message.contains("'sample'") && message.contains("exemplar"),
2890 "names type and calls out the exemplar: {message}"
2891 );
2892 assert!(
2893 message.contains("UNKNOWN_SECTION")
2894 || message.contains("MISSING_REQUIRED_SECTION"),
2895 "carries the typed defect code: {message}"
2896 );
2897 }
2898 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2899 }
2900
2901 let err = Engine::validate_schema_package(
2903 "gate",
2904 "1.0.0",
2905 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
2906 )
2907 .expect_err("illegal enum value must refuse");
2908 assert!(
2909 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2910 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
2911 "got {err:?}"
2912 );
2913
2914 let err = Engine::validate_schema_package(
2917 "gate",
2918 "1.0.0",
2919 &exemplar_package_files("body", "draft", "REFINES"),
2920 )
2921 .expect_err("relationship shape violation must refuse");
2922 assert!(
2923 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2924 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
2925 "got {err:?}"
2926 );
2927 }
2928
2929 #[test]
2934 fn worked_example_package_exemplars_validate() {
2935 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2936 .join("../memstead-schema/examples/minimal");
2937 let schema = std::sync::Arc::new(
2938 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
2939 );
2940 assert!(
2941 schema.types.values().all(|td| td.exemplar.is_some()),
2942 "every worked-example type models the exemplar practice"
2943 );
2944 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
2945 }
2946
2947 #[test]
2954 fn builtin_exemplars_validate_through_the_install_gate() {
2955 let schemas = memstead_schema::builtins::load_builtin_schemas()
2956 .expect("built-in schemas always load");
2957 for schema in &schemas {
2959 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
2960 let (name, version) = schema.id();
2961 panic!("built-in {name}@{version}: {defect}");
2962 }
2963 }
2964 let mut newest: std::collections::HashMap<
2966 String,
2967 &std::sync::Arc<memstead_schema::Schema>,
2968 > = std::collections::HashMap::new();
2969 for schema in &schemas {
2970 let name = schema.manifest.name.clone();
2971 match newest.get(&name) {
2972 Some(cur) if cur.version >= schema.version => {}
2973 _ => {
2974 newest.insert(name, schema);
2975 }
2976 }
2977 }
2978 for (name, schema) in &newest {
2979 for (type_name, td) in &schema.types {
2980 assert!(
2981 td.exemplar.is_some(),
2982 "built-in {name}@{} type '{type_name}' has no exemplar — the \
2983 reference schemas model the practice completely",
2984 schema.version
2985 );
2986 }
2987 }
2988 }
2989
2990 #[test]
2995 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
2996 let mut files = exemplar_package_files("body", "draft", "PART_OF");
2997 let patched = String::from_utf8(files[1].1.clone())
2998 .unwrap()
2999 .replace("to: parent-placeholder", "to: other--real-entity");
3000 files[1].1 = patched.into_bytes();
3001 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3002 .expect_err("mem-prefixed exemplar target must refuse");
3003 assert!(
3004 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3005 if message.contains("bare") && message.contains("'sample'")),
3006 "got {err:?}"
3007 );
3008 }
3009
3010 #[test]
3014 fn install_gate_refuses_manifest_identity_mismatch() {
3015 let err = Engine::validate_schema_package(
3016 "gate",
3017 "1.0.0",
3018 &schema_package_files("Body", "other"),
3019 )
3020 .expect_err("identity mismatch must refuse install");
3021 assert!(
3022 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3023 if message.contains("other@1.0.0")),
3024 "got {err:?}"
3025 );
3026 }
3027
3028 #[test]
3029 fn reload_each_writable_mem_repopulates_load_warnings() {
3030 let tmp = TempDir::new().unwrap();
3034 let mem_dir = tmp.path().to_path_buf();
3035 let writer = FilesystemMemWriter::new(mem_dir.clone());
3036 let mut mount = folder_mount("specs", mem_dir.clone());
3039 mount.schema = Some("default@1.3.0".parse().unwrap());
3040 let mut engine =
3041 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3042 assert!(
3043 engine.load_warnings().is_empty(),
3044 "clean boot has no warnings"
3045 );
3046
3047 let body =
3049 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3050 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3051
3052 engine.reload_each_writable_mem().unwrap();
3053 let warnings = engine.load_warnings();
3054 assert!(
3055 warnings
3056 .iter()
3057 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3058 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3059 );
3060 }
3061
3062 #[test]
3069 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3070 let tmp = TempDir::new().unwrap();
3071 let mem_dir = tmp.path().to_path_buf();
3072 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3074 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3075 let writer = FilesystemMemWriter::new(mem_dir.clone());
3076 let mut engine = Engine::from_mounts(vec![(
3077 folder_mount("specs", mem_dir.clone()),
3078 Box::new(writer) as Box<dyn MemBackend>,
3079 )])
3080 .unwrap();
3081 assert!(
3083 !engine
3084 .load_warnings()
3085 .iter()
3086 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3087 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3088 engine.load_warnings()
3089 );
3090
3091 let drift_body = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
3093 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3094
3095 engine.reload_each_writable_mem().unwrap();
3096
3097 let invalid: Vec<_> = engine
3098 .load_warnings()
3099 .iter()
3100 .filter_map(|w| match w {
3101 WarningHint::ParsedRelationInvalid {
3102 rel_type,
3103 reason,
3104 origin,
3105 ..
3106 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3107 _ => None,
3108 })
3109 .collect();
3110 assert_eq!(
3111 invalid.len(),
3112 1,
3113 "reload must surface the parse-time drift, got: {invalid:?}",
3114 );
3115 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3116 assert_eq!(invalid[0].1, "unknown_rel_type");
3117 assert_eq!(invalid[0].2, "writable");
3118 }
3119
3120 #[test]
3121 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3122 let tmp = TempDir::new().unwrap();
3129 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3130 let a_dir = tmp.path().join("a");
3131 std::fs::create_dir_all(&a_dir).unwrap();
3132 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3133 let b_dir = tmp.path().join("b");
3134 std::fs::create_dir_all(&b_dir).unwrap();
3135 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3136 let mut engine = Engine::from_mounts(vec![
3137 (
3138 folder_mount("alpha", a_dir.clone()),
3139 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3140 ),
3141 (
3142 folder_mount("beta", b_dir.clone()),
3143 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3144 ),
3145 ])
3146 .unwrap();
3147 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3148 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3149 assert!(
3150 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3151 "boot must populate one warning per mem: {pre:?}"
3152 );
3153
3154 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3156 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3157 engine.reload_one_mem("alpha").unwrap();
3158
3159 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3160 assert!(
3161 !post.contains(&"alpha".to_string()),
3162 "reload must drop the healed mem's stale warning: {post:?}"
3163 );
3164 assert!(
3165 post.contains(&"beta".to_string()),
3166 "reload of alpha must not clear beta's slice: {post:?}"
3167 );
3168 }
3169
3170 #[test]
3171 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3172 let tmp = TempDir::new().unwrap();
3177 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3178 let a_dir = tmp.path().join("a");
3179 std::fs::create_dir_all(&a_dir).unwrap();
3180 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3181 let b_dir = tmp.path().join("b");
3182 std::fs::create_dir_all(&b_dir).unwrap();
3183 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3184 let mut engine = Engine::from_mounts(vec![
3185 (
3186 folder_mount("alpha", a_dir.clone()),
3187 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3188 ),
3189 (
3190 folder_mount("beta", b_dir.clone()),
3191 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3192 ),
3193 ])
3194 .unwrap();
3195 assert!(
3196 engine
3197 .load_warnings()
3198 .iter()
3199 .any(|w| w.source_mem() == Some("alpha")),
3200 "boot must carry alpha-sourced warnings"
3201 );
3202
3203 engine.unregister_writable_mem("alpha").unwrap();
3204
3205 let post = engine.load_warnings();
3206 assert!(
3207 !post.iter().any(|w| w.source_mem() == Some("alpha")),
3208 "delete must purge the removed mem's warnings: {post:?}"
3209 );
3210 assert!(
3211 post.iter().any(|w| w.source_mem() == Some("beta")),
3212 "delete of alpha must keep beta's warnings: {post:?}"
3213 );
3214 }
3215
3216 #[test]
3217 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3218 let tmp = TempDir::new().unwrap();
3224 let a_dir = tmp.path().join("a");
3225 std::fs::create_dir_all(&a_dir).unwrap();
3226 let source_body = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[beta--b1]]\n";
3227 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3228 let b_dir = tmp.path().join("b");
3229 std::fs::create_dir_all(&b_dir).unwrap();
3230 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3231 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3232 let mut engine = Engine::from_mounts(vec![
3233 (
3234 folder_mount("alpha", a_dir.clone()),
3235 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3236 ),
3237 (
3238 folder_mount("beta", b_dir.clone()),
3239 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3240 ),
3241 ])
3242 .unwrap();
3243 let alpha_sourced = |engine: &Engine| {
3244 engine
3245 .load_warnings()
3246 .iter()
3247 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3248 };
3249 assert!(
3250 alpha_sourced(&engine),
3251 "boot must flag alpha's invalid row: {:?}",
3252 engine.load_warnings()
3253 );
3254
3255 engine.unregister_writable_mem("beta").unwrap();
3256
3257 assert!(
3258 alpha_sourced(&engine),
3259 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3260 engine.load_warnings()
3261 );
3262 }
3263
3264 #[test]
3272 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3273 let tmp = TempDir::new().unwrap();
3274 let mem_dir = tmp.path().to_path_buf();
3275 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3276 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3277 let writer = FilesystemMemWriter::new(mem_dir.clone());
3278 let mut engine = Engine::from_mounts(vec![(
3279 folder_mount("specs", mem_dir.clone()),
3280 Box::new(writer) as Box<dyn MemBackend>,
3281 )])
3282 .unwrap();
3283 assert!(
3284 !engine.load_warnings().is_empty(),
3285 "boot must populate load_warnings"
3286 );
3287
3288 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3291 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3292 engine.reload_each_writable_mem_reports().unwrap();
3293 assert!(
3294 engine.load_warnings().is_empty(),
3295 "reports sweep must drop healed warnings: {:?}",
3296 engine.load_warnings()
3297 );
3298
3299 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3301 engine.reload_each_writable_mem_reports().unwrap();
3302 assert!(
3303 engine
3304 .load_warnings()
3305 .iter()
3306 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3307 "reports sweep must surface fresh drift: {:?}",
3308 engine.load_warnings()
3309 );
3310 }
3311
3312 #[test]
3320 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3321 let tmp = TempDir::new().unwrap();
3322 let a_dir = tmp.path().join("a");
3323 let b_dir = tmp.path().join("b");
3324 std::fs::create_dir_all(&a_dir).unwrap();
3325 std::fs::create_dir_all(&b_dir).unwrap();
3326 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3327 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3328 let mut engine = Engine::from_mounts(vec![
3329 (
3330 folder_mount("specs", a_dir),
3331 Box::new(a_writer) as Box<dyn MemBackend>,
3332 ),
3333 (
3334 folder_mount("memos", b_dir),
3335 Box::new(b_writer) as Box<dyn MemBackend>,
3336 ),
3337 ])
3338 .unwrap();
3339
3340 let mut settings = crate::workspace::WorkspaceSettings::default();
3342 settings.cross_mem_links.insert(
3343 "specs".to_string(),
3344 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3345 );
3346 engine.set_settings(settings);
3347
3348 let (actor, client) = cli_actor();
3349 let source = engine
3350 .create_entity(
3351 empty_create_args("specs", "Source"),
3352 actor,
3353 Some(&client),
3354 None,
3355 )
3356 .unwrap();
3357 let target = engine
3358 .create_entity(
3359 empty_create_args("memos", "Target"),
3360 actor,
3361 Some(&client),
3362 None,
3363 )
3364 .unwrap();
3365 engine
3366 .relate_entity(
3367 crate::engine::RelateEntityArgs {
3368 source: source.id.clone(),
3369 expected_hash: Some(source.content_hash.clone()),
3370 rel_type: "USES".to_string(),
3371 target: target.id.clone(),
3372 remove: false,
3373 description: None,
3374 dry_run: false,
3375 },
3376 actor,
3377 Some(&client),
3378 None,
3379 )
3380 .unwrap();
3381
3382 let has_edge = |e: &Engine| {
3384 let out = e
3385 .store()
3386 .outgoing(&source.id)
3387 .iter()
3388 .any(|edge| edge.target == target.id);
3389 let inc = e
3390 .store()
3391 .incoming(&target.id)
3392 .iter()
3393 .any(|edge| edge.from == source.id);
3394 (out, inc)
3395 };
3396
3397 assert_eq!(
3398 has_edge(&engine),
3399 (true, true),
3400 "edge must be indexed in both directions after relate",
3401 );
3402
3403 engine.reload_one_mem("memos").unwrap();
3405 assert_eq!(
3406 has_edge(&engine),
3407 (true, true),
3408 "cross-mem edge into B must survive a per-mem reload of B",
3409 );
3410
3411 engine.reload_each_writable_mem().unwrap();
3414 assert_eq!(
3415 has_edge(&engine),
3416 (true, true),
3417 "per-mem and workspace reload converge on the same edge",
3418 );
3419
3420 assert!(
3423 engine
3424 .store()
3425 .get(&source.id)
3426 .unwrap()
3427 .relationships
3428 .iter()
3429 .any(|r| r.target == target.id),
3430 "source record must retain the relationship throughout",
3431 );
3432 }
3433
3434 #[test]
3440 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3441 let tmp = TempDir::new().unwrap();
3442 let a_dir = tmp.path().join("a");
3443 let b_dir = tmp.path().join("b");
3444 std::fs::create_dir_all(&a_dir).unwrap();
3445 std::fs::create_dir_all(&b_dir).unwrap();
3446 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3447 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3448 let mut engine = Engine::from_mounts(vec![
3449 (
3450 folder_mount("specs", a_dir),
3451 Box::new(a_writer) as Box<dyn MemBackend>,
3452 ),
3453 (
3454 folder_mount("memos", b_dir),
3455 Box::new(b_writer) as Box<dyn MemBackend>,
3456 ),
3457 ])
3458 .unwrap();
3459 let mut settings = crate::workspace::WorkspaceSettings::default();
3460 settings.cross_mem_links.insert(
3461 "specs".to_string(),
3462 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3463 );
3464 engine.set_settings(settings);
3465
3466 let (actor, client) = cli_actor();
3467 let source = engine
3468 .create_entity(
3469 empty_create_args("specs", "Source"),
3470 actor,
3471 Some(&client),
3472 None,
3473 )
3474 .unwrap();
3475 let target = engine
3476 .create_entity(
3477 empty_create_args("memos", "Target"),
3478 actor,
3479 Some(&client),
3480 None,
3481 )
3482 .unwrap();
3483 engine
3484 .relate_entity(
3485 crate::engine::RelateEntityArgs {
3486 source: source.id.clone(),
3487 expected_hash: Some(source.content_hash.clone()),
3488 rel_type: "USES".to_string(),
3489 target: target.id.clone(),
3490 remove: false,
3491 description: None,
3492 dry_run: false,
3493 },
3494 actor,
3495 Some(&client),
3496 None,
3497 )
3498 .unwrap();
3499
3500 engine.reload_one_mem("specs").unwrap();
3501
3502 let out = engine
3503 .store()
3504 .outgoing(&source.id)
3505 .iter()
3506 .any(|edge| edge.target == target.id);
3507 let inc = engine
3508 .store()
3509 .incoming(&target.id)
3510 .iter()
3511 .any(|edge| edge.from == source.id);
3512 assert!(
3513 out && inc,
3514 "outgoing cross-mem edge must survive a source-mem reload"
3515 );
3516 }
3517
3518 #[test]
3519 fn workspace_root_setter_round_trips() {
3520 let tmp = TempDir::new().unwrap();
3521 let mem_dir = tmp.path().to_path_buf();
3522 let writer = FilesystemMemWriter::new(mem_dir.clone());
3523 let mut engine = Engine::from_mounts(vec![(
3524 folder_mount("specs", mem_dir),
3525 Box::new(writer) as Box<dyn MemBackend>,
3526 )])
3527 .unwrap();
3528 let root = tmp.path().to_path_buf();
3529 engine.set_workspace_root(root.clone());
3530 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3531 }
3532
3533 #[test]
3534 fn export_mem_folder_backend_produces_archive() {
3535 let tmp = TempDir::new().unwrap();
3539 let mem_dir = tmp.path().join("specs");
3540 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3541 let config_body = r#"{
3542 "format": 1,
3543 "schema": "default@1.0.0",
3544 "version": "1.0.0"
3545 }"#;
3546 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3547
3548 let writer = FilesystemMemWriter::new(mem_dir.clone());
3549 let engine = Engine::from_mounts(vec![(
3550 folder_mount("specs", mem_dir.clone()),
3551 Box::new(writer) as Box<dyn MemBackend>,
3552 )])
3553 .unwrap();
3554
3555 let archive_path = tmp.path().join("specs.mem");
3556 let result = engine.export_mem("specs", &archive_path).unwrap();
3557 assert!(archive_path.exists(), "archive must exist on disk");
3558 assert!(result.size_bytes > 0);
3559 assert_eq!(result.entity_count, 0);
3562 }
3563
3564 #[test]
3565 fn export_mem_unknown_mem_returns_unknown_mem() {
3566 let tmp = TempDir::new().unwrap();
3567 let mem_dir = tmp.path().to_path_buf();
3568 let writer = FilesystemMemWriter::new(mem_dir.clone());
3569 let engine = Engine::from_mounts(vec![(
3570 folder_mount("specs", mem_dir),
3571 Box::new(writer) as Box<dyn MemBackend>,
3572 )])
3573 .unwrap();
3574 let output = tmp.path().join("out.mem");
3575 let err = engine.export_mem("missing", &output).unwrap_err();
3576 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3577 }
3578
3579 #[test]
3580 fn export_mem_missing_config_returns_invalid_input() {
3581 let tmp = TempDir::new().unwrap();
3585 let mem_dir = tmp.path().to_path_buf();
3586 let writer = FilesystemMemWriter::new(mem_dir.clone());
3587 let engine = Engine::from_mounts(vec![(
3588 folder_mount("specs", mem_dir),
3589 Box::new(writer) as Box<dyn MemBackend>,
3590 )])
3591 .unwrap();
3592 let output = tmp.path().join("out.mem");
3593 let err = engine.export_mem("specs", &output).unwrap_err();
3594 assert!(matches!(err, EngineError::InvalidInput(_)));
3595 }
3596
3597 #[test]
3598 fn export_mem_archive_backend_returns_sealed() {
3599 let tmp = TempDir::new().unwrap();
3602 let archive_path = build_archive(
3603 tmp.path(),
3604 "ext",
3605 &[(
3606 ".memstead/config.json",
3607 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3608 )],
3609 );
3610 let engine = Engine::from_mounts(vec![(
3611 archive_mount("ext", archive_path.clone()),
3612 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3613 )])
3614 .unwrap();
3615 let output = tmp.path().join("out.mem");
3616 let err = engine.export_mem("ext", &output).unwrap_err();
3617 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3618 }
3619
3620 #[test]
3621 fn export_markdown_writes_unchanged_files_zero_writes() {
3622 let tmp = TempDir::new().unwrap();
3627 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3628 let result = engine.export_markdown(None, None).unwrap();
3629 assert_eq!(
3630 result.written, 0,
3631 "freshly-created entity's file already matches generated markdown"
3632 );
3633 assert_eq!(
3634 result.unchanged, 1,
3635 "the one seeded entity counts as unchanged"
3636 );
3637 assert!(
3638 result.skipped_mounts.is_empty(),
3639 "folder-only workspace has no skipped mounts"
3640 );
3641 }
3642
3643 #[test]
3644 fn export_markdown_skips_non_folder_mounts() {
3645 let tmp = TempDir::new().unwrap();
3649 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3650 let engine = Engine::from_mounts(vec![(
3651 archive_mount("ext", archive_path.clone()),
3652 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3653 )])
3654 .unwrap();
3655 let result = engine.export_markdown(None, None).unwrap();
3656 assert_eq!(result.written, 0);
3657 assert_eq!(result.unchanged, 0);
3658 assert_eq!(
3659 result.skipped_mounts.len(),
3660 1,
3661 "archive mount is in the skipped list"
3662 );
3663 let entry = &result.skipped_mounts[0];
3664 assert_eq!(entry.mem, "ext");
3665 assert_eq!(entry.active_backend, "archive");
3666 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
3667 }
3668
3669 #[test]
3670 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
3671 let tmp = TempDir::new().unwrap();
3675 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3676 let engine = Engine::from_mounts(vec![(
3677 archive_mount("ext", archive_path.clone()),
3678 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3679 )])
3680 .unwrap();
3681 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
3682 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
3683 let details = err.details();
3684 assert_eq!(details["mem"], "ext");
3685 assert_eq!(details["active_backend"], "archive");
3686 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
3687 }
3688
3689 #[test]
3690 fn register_writable_mem_adds_mount_and_router_entry() {
3691 let tmp = TempDir::new().unwrap();
3694 let mem_a = tmp.path().join("a");
3695 std::fs::create_dir_all(&mem_a).unwrap();
3696 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3697
3698 let mut engine = Engine::from_mounts(vec![(
3699 folder_mount("alpha", mem_a),
3700 Box::new(writer_a) as Box<dyn MemBackend>,
3701 )])
3702 .unwrap();
3703 assert!(engine.mem_router().is_writable("alpha"));
3704
3705 let mem_b = tmp.path().join("b");
3706 std::fs::create_dir_all(&mem_b).unwrap();
3707 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3708
3709 engine
3710 .register_writable_mem(
3711 folder_mount("beta", mem_b.clone()),
3712 Box::new(writer_b) as Box<dyn MemBackend>,
3713 MemOrigin::ExplicitToml,
3714 )
3715 .unwrap();
3716
3717 assert!(engine.mem_router().is_writable("alpha"));
3719 assert!(engine.mem_router().is_writable("beta"));
3720 assert!(engine.mem_router().is_visible("beta"));
3721
3722 assert!(engine.mount("beta").is_some());
3724 assert!(engine.schemas().contains_key("beta"));
3725
3726 assert_eq!(
3728 engine.mem_router().dir_for_mem("beta"),
3729 Some(mem_b.as_path()),
3730 );
3731 }
3732
3733 #[test]
3739 fn register_writable_mem_resolves_schema_from_mem_config() {
3740 let tmp = TempDir::new().unwrap();
3741 let mem_a = tmp.path().join("a");
3742 std::fs::create_dir_all(&mem_a).unwrap();
3743 let mut engine = Engine::from_mounts(vec![(
3744 folder_mount("alpha", mem_a.clone()),
3745 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
3746 )])
3747 .unwrap();
3748
3749 let mem_b = tmp.path().join("b");
3750 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
3751 std::fs::write(
3752 mem_b.join(".memstead").join("config.json"),
3753 r#"{"schema":"software@0.1.0"}"#,
3754 )
3755 .unwrap();
3756 let mount_b = crate::workspace::Mount {
3757 mem: "beta".to_string(),
3758 schema: Some(memstead_schema::SchemaRef::new(
3759 "totally-not-a-schema",
3760 semver::Version::new(9, 9, 9),
3761 )),
3762 storage: crate::workspace::MountStorage::Folder {
3763 path: mem_b.clone(),
3764 },
3765 capability: crate::workspace::MountCapability::Write,
3766 lifecycle: crate::workspace::MountLifecycle::Eager,
3767 cross_linkable: true,
3768 migration_target: None,
3769 };
3770 engine
3771 .register_writable_mem(
3772 mount_b,
3773 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
3774 MemOrigin::ExplicitToml,
3775 )
3776 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
3777
3778 assert!(engine.schemas().contains_key("beta"));
3779 let surfaced = engine.load_warnings().iter().any(|w| {
3780 matches!(
3781 w,
3782 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
3783 if mem == "beta"
3784 && config_pin == "software@0.1.0"
3785 && mount_pin == "totally-not-a-schema@9.9.9"
3786 )
3787 });
3788 assert!(
3789 surfaced,
3790 "SchemaPinMismatch must surface for beta: {:?}",
3791 engine.load_warnings(),
3792 );
3793 }
3794
3795 #[test]
3796 fn register_writable_mem_rejects_existing_name() {
3797 let tmp = TempDir::new().unwrap();
3800 let mem_a = tmp.path().join("a");
3801 std::fs::create_dir_all(&mem_a).unwrap();
3802 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3803
3804 let mut engine = Engine::from_mounts(vec![(
3805 folder_mount("alpha", mem_a),
3806 Box::new(writer_a) as Box<dyn MemBackend>,
3807 )])
3808 .unwrap();
3809 let mount_count_pre = engine.mounts().len();
3810
3811 let mem_collide = tmp.path().join("alpha-2");
3812 std::fs::create_dir_all(&mem_collide).unwrap();
3813 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
3814
3815 let err = engine
3816 .register_writable_mem(
3817 folder_mount("alpha", mem_collide),
3818 Box::new(writer_collide) as Box<dyn MemBackend>,
3819 MemOrigin::ExplicitToml,
3820 )
3821 .unwrap_err();
3822 match err {
3823 EngineError::MemNameCollision {
3824 name,
3825 source_origin,
3826 } => {
3827 assert_eq!(name, "alpha");
3828 assert!(
3833 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
3834 );
3835 }
3836 other => panic!("expected MemNameCollision, got {other:?}"),
3837 }
3838
3839 assert_eq!(engine.mounts().len(), mount_count_pre);
3841 }
3842
3843 #[test]
3844 fn register_writable_mem_loads_entities_into_store() {
3845 let tmp = TempDir::new().unwrap();
3848 let mem_a = tmp.path().join("a");
3849 std::fs::create_dir_all(&mem_a).unwrap();
3850 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3851
3852 let mut engine = Engine::from_mounts(vec![(
3853 folder_mount("alpha", mem_a),
3854 Box::new(writer_a) as Box<dyn MemBackend>,
3855 )])
3856 .unwrap();
3857 let pre_count = engine.store().all_entities().count();
3858
3859 let mem_b = tmp.path().join("b");
3861 std::fs::create_dir_all(&mem_b).unwrap();
3862 std::fs::write(
3863 mem_b.join("b1.md"),
3864 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
3865 )
3866 .unwrap();
3867 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3868
3869 engine
3870 .register_writable_mem(
3871 folder_mount("beta", mem_b),
3872 Box::new(writer_b) as Box<dyn MemBackend>,
3873 MemOrigin::ExplicitToml,
3874 )
3875 .unwrap();
3876
3877 let post_count = engine.store().all_entities().count();
3878 assert!(post_count > pre_count, "register must load entities");
3879 let beta_count = engine
3880 .store()
3881 .all_entities()
3882 .filter(|e| e.mem == "beta")
3883 .count();
3884 assert_eq!(beta_count, 1);
3885 }
3886
3887 #[test]
3888 fn register_then_unregister_round_trips() {
3889 let tmp = TempDir::new().unwrap();
3893 let mem_a = tmp.path().join("a");
3894 std::fs::create_dir_all(&mem_a).unwrap();
3895 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3896
3897 let mut engine = Engine::from_mounts(vec![(
3898 folder_mount("alpha", mem_a),
3899 Box::new(writer_a) as Box<dyn MemBackend>,
3900 )])
3901 .unwrap();
3902 let pre_mounts = engine.mounts().len();
3903
3904 let mem_b = tmp.path().join("b");
3905 std::fs::create_dir_all(&mem_b).unwrap();
3906 let writer_b = FilesystemMemWriter::new(mem_b);
3907
3908 engine
3909 .register_writable_mem(
3910 folder_mount("beta", tmp.path().join("b")),
3911 Box::new(writer_b) as Box<dyn MemBackend>,
3912 MemOrigin::ExplicitToml,
3913 )
3914 .unwrap();
3915 assert_eq!(engine.mounts().len(), pre_mounts + 1);
3916
3917 let removed = engine.unregister_writable_mem("beta").unwrap();
3918 assert!(removed.is_some());
3919 assert_eq!(engine.mounts().len(), pre_mounts);
3920 assert!(!engine.mem_router().is_writable("beta"));
3921 }
3922
3923 #[test]
3924 fn unregister_writable_mem_returns_false_for_unknown_name() {
3925 let tmp = TempDir::new().unwrap();
3929 let mem_dir = tmp.path().to_path_buf();
3930 let writer = FilesystemMemWriter::new(mem_dir.clone());
3931 let mut engine = Engine::from_mounts(vec![(
3932 folder_mount("specs", mem_dir),
3933 Box::new(writer) as Box<dyn MemBackend>,
3934 )])
3935 .unwrap();
3936 let removed = engine.unregister_writable_mem("missing").unwrap();
3937 assert!(removed.is_none(), "unknown mem returns Ok(None)");
3938 assert!(engine.mem_router().is_writable("specs"));
3940 }
3941
3942 #[test]
3943 fn unregister_writable_mem_drops_mount_and_router_entry() {
3944 let tmp = TempDir::new().unwrap();
3949 let mem_a = tmp.path().join("a");
3950 std::fs::create_dir_all(&mem_a).unwrap();
3951 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3952 let mem_b = tmp.path().join("b");
3953 std::fs::create_dir_all(&mem_b).unwrap();
3954 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3955
3956 let mut engine = Engine::from_mounts(vec![
3957 (
3958 folder_mount("alpha", mem_a),
3959 Box::new(writer_a) as Box<dyn MemBackend>,
3960 ),
3961 (
3962 folder_mount("beta", mem_b),
3963 Box::new(writer_b) as Box<dyn MemBackend>,
3964 ),
3965 ])
3966 .unwrap();
3967
3968 let removed = engine.unregister_writable_mem("alpha").unwrap();
3969 assert!(removed.is_some());
3970
3971 assert!(!engine.mem_router().is_writable("alpha"));
3973 assert!(!engine.mem_router().is_visible("alpha"));
3974 assert!(engine.mount("alpha").is_none());
3975
3976 assert!(engine.mem_router().is_writable("beta"));
3978 assert!(engine.mount("beta").is_some());
3979 }
3980
3981 #[test]
3982 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
3983 let tmp = TempDir::new().unwrap();
3987 let mem_a = tmp.path().join("a");
3988 std::fs::create_dir_all(&mem_a).unwrap();
3989 std::fs::write(
3990 mem_a.join("a1.md"),
3991 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
3992 )
3993 .unwrap();
3994 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3995
3996 let mem_b = tmp.path().join("b");
3997 std::fs::create_dir_all(&mem_b).unwrap();
3998 std::fs::write(
3999 mem_b.join("b1.md"),
4000 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4001 )
4002 .unwrap();
4003 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4004
4005 let mut engine = Engine::from_mounts(vec![
4006 (
4007 folder_mount("alpha", mem_a),
4008 Box::new(writer_a) as Box<dyn MemBackend>,
4009 ),
4010 (
4011 folder_mount("beta", mem_b),
4012 Box::new(writer_b) as Box<dyn MemBackend>,
4013 ),
4014 ])
4015 .unwrap();
4016
4017 let pre_total = engine.store().all_entities().count();
4018 assert!(pre_total >= 2, "both mems must load entities");
4019
4020 engine.unregister_writable_mem("alpha").unwrap();
4021
4022 let alpha_remaining = engine
4024 .store()
4025 .all_entities()
4026 .filter(|e| e.mem == "alpha")
4027 .count();
4028 assert_eq!(alpha_remaining, 0);
4029
4030 let beta_remaining = engine
4032 .store()
4033 .all_entities()
4034 .filter(|e| e.mem == "beta")
4035 .count();
4036 assert!(beta_remaining > 0, "beta entities must survive");
4037 }
4038 #[test]
4039 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4040 let tmp = TempDir::new().unwrap();
4041 let mut engine = build_demo_engine(&tmp);
4042 let result = engine
4043 .reload_one_mem("specs")
4044 .expect("reload on stable disk must succeed");
4045 assert!(result.added.is_empty(), "added: {:?}", result.added);
4046 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4047 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4048 }
4049
4050 #[test]
4051 fn reload_one_mem_picks_up_external_addition() {
4052 let tmp = TempDir::new().unwrap();
4053 let mut engine = build_demo_engine(&tmp);
4054 std::fs::write(
4057 tmp.path().join("external.md"),
4058 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4059 )
4060 .unwrap();
4061 let result = engine.reload_one_mem("specs").unwrap();
4062 assert_eq!(
4063 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4064 vec!["specs--external"]
4065 );
4066 assert!(result.changed.is_empty());
4067 assert!(result.removed.is_empty());
4068 assert!(
4070 engine
4071 .get_entity(&crate::EntityId::new("specs", "external"))
4072 .is_some()
4073 );
4074 }
4075
4076 #[test]
4077 fn reload_one_mem_picks_up_external_removal() {
4078 let tmp = TempDir::new().unwrap();
4079 let mut engine = build_demo_engine(&tmp);
4080 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4083 let result = engine.reload_one_mem("specs").unwrap();
4084 assert!(result.added.is_empty());
4085 assert!(result.changed.is_empty());
4086 assert_eq!(
4087 result
4088 .removed
4089 .iter()
4090 .map(|i| i.as_ref())
4091 .collect::<Vec<_>>(),
4092 vec!["specs--lonely-three"]
4093 );
4094 }
4095
4096 #[test]
4097 fn reload_one_mem_picks_up_external_change() {
4098 let tmp = TempDir::new().unwrap();
4099 let mut engine = build_demo_engine(&tmp);
4100 std::fs::write(
4103 tmp.path().join("source-one.md"),
4104 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4105 )
4106 .unwrap();
4107 let result = engine.reload_one_mem("specs").unwrap();
4108 assert!(result.added.is_empty());
4109 assert_eq!(
4110 result
4111 .changed
4112 .iter()
4113 .map(|i| i.as_ref())
4114 .collect::<Vec<_>>(),
4115 vec!["specs--source-one"]
4116 );
4117 assert!(result.removed.is_empty());
4118 }
4119
4120 #[test]
4121 fn reload_one_mem_rejects_unknown_mem() {
4122 let tmp = TempDir::new().unwrap();
4123 let mut engine = build_demo_engine(&tmp);
4124 let err = engine.reload_one_mem("nope").unwrap_err();
4125 match err {
4126 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4127 other => panic!("expected UnknownMem, got {other:?}"),
4128 }
4129 }
4130
4131 #[test]
4132 fn reload_each_writable_mem_returns_one_entry_per_mount() {
4133 let tmp = TempDir::new().unwrap();
4134 let mut engine = build_demo_engine(&tmp);
4135 let reports = engine
4136 .reload_each_writable_mem()
4137 .expect("batch reload on stable disk must succeed");
4138 assert_eq!(reports.len(), 1);
4139 assert_eq!(reports[0].0, "specs");
4140 assert!(reports[0].1.added.is_empty());
4141 assert!(reports[0].1.changed.is_empty());
4142 assert!(reports[0].1.removed.is_empty());
4143 }
4144
4145 #[test]
4148 fn settings_default_to_empty_on_fresh_engine() {
4149 let tmp = TempDir::new().unwrap();
4150 let engine = build_demo_engine(&tmp);
4151 let s = engine.settings();
4152 assert!(s.mem_create_rules.is_empty());
4153 assert!(s.mem_delete_rules.is_empty());
4154 assert!(s.cross_mem_links.is_empty());
4155 }
4156
4157 #[test]
4158 fn set_settings_replaces_workspace_policy() {
4159 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4160 let tmp = TempDir::new().unwrap();
4161 let mut engine = build_demo_engine(&tmp);
4162 let mut settings = WorkspaceSettings::default();
4163 settings.mem_create_rules.push(CreateRuleSetting {
4164 pattern: "exec-*".to_string(),
4165 schemas: vec!["default@1.0.0".to_string()],
4166 default_cross_links: None,
4167 });
4168 settings.mem_delete_rules.push(DeleteRuleSetting {
4169 pattern: "exec-*".to_string(),
4170 });
4171 engine.set_settings(settings);
4172 assert_eq!(engine.settings().mem_create_rules.len(), 1);
4173 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4174 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4175 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4176 }
4177
4178 #[test]
4181 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4182 let tmp = TempDir::new().unwrap();
4183 let mut engine = build_demo_engine(&tmp);
4184 std::fs::write(
4186 tmp.path().join("new-via-disk.md"),
4187 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4188 )
4189 .unwrap();
4190 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4191 std::fs::write(
4192 tmp.path().join("source-one.md"),
4193 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4194 )
4195 .unwrap();
4196
4197 let reports = engine.reload_each_writable_mem().unwrap();
4198 assert_eq!(reports.len(), 1);
4199 let (mem, result) = &reports[0];
4200 assert_eq!(mem, "specs");
4201 assert_eq!(
4202 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4203 vec!["specs--new-via-disk"]
4204 );
4205 assert_eq!(
4206 result
4207 .removed
4208 .iter()
4209 .map(|i| i.as_ref())
4210 .collect::<Vec<_>>(),
4211 vec!["specs--lonely-three"]
4212 );
4213 assert_eq!(
4214 result
4215 .changed
4216 .iter()
4217 .map(|i| i.as_ref())
4218 .collect::<Vec<_>>(),
4219 vec!["specs--source-one"]
4220 );
4221 }
4222
4223 #[test]
4226 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4227 let tmp = TempDir::new().unwrap();
4235 let mut engine = build_demo_engine(&tmp);
4236 let report = engine.reload_one_mem_report("specs").unwrap();
4237 assert_eq!(report.mem, "specs");
4238 assert_eq!(
4239 report.head_before, report.head_after,
4240 "unchanged disk → stable cursor"
4241 );
4242 assert!(
4243 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4244 "folder heads carry the changelog-ts cursor, got {}",
4245 report.head_after
4246 );
4247 assert_eq!(report.entities_loaded, 3);
4250 assert!(report.changed_entity_ids.is_empty());
4252 }
4253
4254 #[test]
4255 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4256 let tmp = TempDir::new().unwrap();
4261 let mut engine = build_demo_engine(&tmp);
4262 std::fs::write(
4263 tmp.path().join("new-via-disk.md"),
4264 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4265 )
4266 .unwrap();
4267 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4268 std::fs::write(
4269 tmp.path().join("source-one.md"),
4270 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4271 )
4272 .unwrap();
4273
4274 let report = engine.reload_one_mem_report("specs").unwrap();
4275 assert_eq!(report.mem, "specs");
4276 let ids: Vec<&str> = report
4277 .changed_entity_ids
4278 .iter()
4279 .map(|id| id.as_ref())
4280 .collect();
4281 assert_eq!(
4283 ids,
4284 vec![
4285 "specs--lonely-three",
4286 "specs--new-via-disk",
4287 "specs--source-one",
4288 ]
4289 );
4290 }
4291
4292 #[test]
4293 fn reload_one_mem_report_rejects_unknown_mem() {
4294 let tmp = TempDir::new().unwrap();
4295 let mut engine = build_demo_engine(&tmp);
4296 let err = engine.reload_one_mem_report("missing").unwrap_err();
4297 assert!(matches!(err, EngineError::UnknownMem(_)));
4298 }
4299
4300 #[test]
4301 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4302 let tmp = TempDir::new().unwrap();
4303 let mut engine = build_demo_engine(&tmp);
4304 let reports = engine.reload_each_writable_mem_reports().unwrap();
4305 assert_eq!(reports.len(), 1);
4306 assert_eq!(reports[0].mem, "specs");
4307 assert_eq!(reports[0].entities_loaded, 3);
4308 }
4309
4310 #[test]
4317 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4318 let tmp = TempDir::new().unwrap();
4319
4320 let memstead_dir = tmp.path().join(".memstead");
4323 std::fs::create_dir_all(&memstead_dir).unwrap();
4324 let workspace_toml = memstead_dir.join("workspace.toml");
4325 std::fs::write(
4326 &workspace_toml,
4327 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4328 )
4329 .unwrap();
4330 let mounts_json = memstead_dir.join("state").join("mounts.json");
4331 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4332 let mem_dir = tmp.path().join("specs");
4333 std::fs::create_dir_all(&mem_dir).unwrap();
4334 let mounts_body = format!(
4335 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4336 mem_dir.display(),
4337 );
4338 std::fs::write(&mounts_json, mounts_body).unwrap();
4339
4340 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4341 assert!(
4342 engine.settings().mem_create_rules.is_empty(),
4343 "boot-time settings carry no create rules"
4344 );
4345
4346 std::fs::write(
4348 &workspace_toml,
4349 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n\n[[mem_management.create]]\npattern = \"exec-*\"\nschemas = [\"default@1.0.0\"]\n",
4350 )
4351 .unwrap();
4352
4353 engine.reload_each_writable_mem_reports().unwrap();
4354
4355 let rules = &engine.settings().mem_create_rules;
4356 assert_eq!(
4357 rules.len(),
4358 1,
4359 "workspace-wide reload must refresh the policy"
4360 );
4361 assert_eq!(rules[0].pattern, "exec-*");
4362 }
4363
4364 const MIG_TYPE_TAIL: &str = r#"sections:
4369 - key: body
4370 heading: Body
4371 required: true
4372 search_weight: 10.0
4373 catch_all: true
4374 write_rules: []
4375title_weight: 100.0
4376text_fields:
4377 - body
4378hierarchy_relationship: _default
4379no_self_loop_relationships: []
4380updatable_fields: []
4381health_required_fields: []
4382staleness_threshold_days: 90
4383write_rules: []
4384"#;
4385
4386 fn mig_manifest(name: &str, version: &str) -> String {
4391 format!(
4392 r#"name: {name}
4393version: {version}
4394description: migration test schema
4395when_to_use: tests
4396types:
4397 - doc
4398relationships:
4399 mode: strict
4400 definitions:
4401 - name: USES
4402 description: link
4403 default_weight: 1.0
4404 - name: _default
4405 description: fallback
4406 default_weight: 1.0
4407community:
4408 resolution: 1.0
4409 seed: 42
4410"#
4411 )
4412 }
4413
4414 fn mig_type_yaml(with_status: bool) -> String {
4415 let metadata = if with_status {
4416 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4417 } else {
4418 "metadata_fields: []\n"
4419 };
4420 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4421 }
4422
4423 fn write_mig_schema(
4424 root: &std::path::Path,
4425 dir: &str,
4426 name: &str,
4427 version: &str,
4428 with_status: bool,
4429 ) {
4430 let d = root.join(dir);
4431 std::fs::create_dir_all(d.join("types")).unwrap();
4432 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4433 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4434 }
4435
4436 #[test]
4453 fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4454 for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4455 run_mutation_sequence(seed);
4456 }
4457 }
4458
4459 struct Xorshift(u64);
4460 impl Xorshift {
4461 fn next(&mut self) -> u64 {
4462 let mut x = self.0;
4463 x ^= x << 13;
4464 x ^= x >> 7;
4465 x ^= x << 17;
4466 self.0 = x;
4467 x
4468 }
4469 fn pick(&mut self, n: usize) -> usize {
4470 (self.next() % n as u64) as usize
4471 }
4472 }
4473
4474 fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4475 let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4478 let live = engine.search_indexes();
4479 let mut live_mems: Vec<&String> = live.keys().collect();
4480 let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4481 live_mems.sort();
4482 fresh_mems.sort();
4483 assert_eq!(
4484 live_mems, fresh_mems,
4485 "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4486 );
4487 for (mem, idx) in live {
4488 let mut got = idx.stored_ids().unwrap();
4489 let mut want = fresh[mem].stored_ids().unwrap();
4490 got.sort();
4491 want.sort();
4492 assert_eq!(
4493 got, want,
4494 "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4495 );
4496 }
4497 let schema = engine
4500 .schemas
4501 .iter()
4502 .min_by(|a, b| a.0.cmp(b.0))
4503 .map(|(_, s)| s.clone())
4504 .expect("schema present");
4505 let weights_schema = schema.clone();
4506 let fresh_partition = crate::graph::community::detect_communities(
4507 engine.store(),
4508 schema.manifest.community.resolution,
4509 schema.manifest.community.seed,
4510 move |rel_type| {
4511 weights_schema
4512 .manifest
4513 .relationships
4514 .definitions
4515 .iter()
4516 .find(|d| d.name == rel_type)
4517 .map(|d| d.default_weight as f64)
4518 .unwrap_or(1.0)
4519 },
4520 );
4521 assert_eq!(
4522 engine.communities().entity_cluster_map,
4523 fresh_partition.entity_cluster_map,
4524 "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4525 );
4526 }
4527
4528 fn run_mutation_sequence(seed: u64) {
4529 use indexmap::IndexMap;
4530
4531 let (_tmp, mut engine) = migration_engine();
4532 let mut rng = Xorshift(seed);
4533 let mut live: Vec<crate::EntityId> = vec![
4534 crate::EntityId::new("specs", "one"),
4535 crate::EntityId::new("specs", "two"),
4536 ];
4537 let mut counter = 0usize;
4538 let mut kinds_hit: std::collections::HashSet<&'static str> =
4539 std::collections::HashSet::new();
4540
4541 const KINDS: [&str; 7] = [
4542 "create",
4543 "update",
4544 "relate",
4545 "delete",
4546 "rename",
4547 "batch_applied",
4548 "batch_refused",
4549 ];
4550
4551 let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4552 anchors: Vec::new(),
4553 anchors_unset: Vec::new(),
4554 id,
4555 expected_hash: None,
4556 sections: IndexMap::new(),
4557 append_sections: IndexMap::new(),
4558 patch_sections: IndexMap::new(),
4559 metadata: IndexMap::new(),
4560 metadata_unset: Vec::new(),
4561 declare_relations: Vec::new(),
4562 dry_run: false,
4563 relations_unset: Vec::new(),
4564 };
4565
4566 for op_i in 0..30usize {
4567 let kind = *KINDS
4570 .get(op_i)
4571 .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4572 match kind {
4573 "create" => {
4574 counter += 1;
4575 let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4576 args.entity_type = "doc".to_string();
4577 args.sections = IndexMap::from_iter([(
4578 "body".to_string(),
4579 format!("generated body {counter}"),
4580 )]);
4581 let out = engine
4582 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4583 .expect("generated create is conformant");
4584 live.push(out.id);
4585 kinds_hit.insert("create");
4586 }
4587 "update" => {
4588 let id = live[rng.pick(live.len())].clone();
4589 let mut args = bare_update(id);
4590 args.append_sections
4591 .insert("body".to_string(), format!("appended at op {op_i}"));
4592 engine
4593 .update_entity(args, crate::vcs::Actor::Cli, None, None)
4594 .expect("append update is conformant");
4595 kinds_hit.insert("update");
4596 }
4597 "relate" => {
4598 if live.len() >= 2 {
4599 let a = rng.pick(live.len());
4600 let mut b = rng.pick(live.len());
4601 if a == b {
4602 b = (b + 1) % live.len();
4603 }
4604 engine
4605 .relate_entity(
4606 crate::engine::RelateEntityArgs {
4607 source: live[a].clone(),
4608 expected_hash: None,
4609 rel_type: "USES".to_string(),
4610 target: live[b].clone(),
4611 remove: false,
4612 description: None,
4613 dry_run: false,
4614 },
4615 crate::vcs::Actor::Cli,
4616 None,
4617 None,
4618 )
4619 .expect("USES relate is legal under mig-a");
4620 kinds_hit.insert("relate");
4621 }
4622 }
4623 "delete" => {
4624 if live.len() > 2
4627 && let Some(pos) = (0..live.len()).find(|&i| {
4628 engine.store().incoming(&live[i]).is_empty()
4629 && engine
4630 .store()
4631 .get(&live[i])
4632 .is_some_and(|e| e.relationships.is_empty())
4633 })
4634 {
4635 let id = live.remove(pos);
4636 engine
4637 .delete_entity(
4638 crate::engine::DeleteEntityArgs {
4639 id: id.clone(),
4640 expected_hash: None,
4641 },
4642 crate::vcs::Actor::Cli,
4643 None,
4644 None,
4645 )
4646 .expect("reference-free delete lands");
4647 kinds_hit.insert("delete");
4648 }
4649 }
4650 "rename" => {
4651 counter += 1;
4652 let pos = rng.pick(live.len());
4653 let old = live[pos].clone();
4654 let out = engine
4655 .rename_entity(
4656 crate::engine::RenameEntityArgs {
4657 id: old,
4658 new_title: format!("Renamed {counter}"),
4659 expected_hash: None,
4660 },
4661 crate::vcs::Actor::Cli,
4662 None,
4663 None,
4664 )
4665 .expect("fresh-slug rename lands");
4666 live[pos] = out.new_id;
4667 kinds_hit.insert("rename");
4668 }
4669 "batch_applied" => {
4670 let id_a = live[rng.pick(live.len())].clone();
4671 let mut a = bare_update(id_a);
4672 a.append_sections
4673 .insert("body".to_string(), format!("batch line {op_i}"));
4674 let result = engine
4675 .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
4676 .expect("batch envelope");
4677 assert!(result.applied, "single-entry append batch applies");
4678 kinds_hit.insert("batch_applied");
4679 }
4680 "batch_refused" => {
4681 let id_a = live[rng.pick(live.len())].clone();
4682 let mut a = bare_update(id_a);
4683 a.append_sections
4684 .insert("body".to_string(), "doomed".to_string());
4685 let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
4686 let result = engine
4687 .batch_update(
4688 vec![(a, None), (missing, None)],
4689 crate::vcs::Actor::Cli,
4690 None,
4691 false,
4692 )
4693 .expect("refused batch returns a report-all envelope");
4694 assert!(!result.applied, "the missing target refuses the batch");
4695 kinds_hit.insert("batch_refused");
4696 }
4697 _ => unreachable!(),
4698 }
4699
4700 if op_i == 14 {
4701 engine
4705 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4706 .expect("integral switch");
4707 kinds_hit.insert("schema_switch");
4708 }
4709 if op_i == 19 {
4710 engine.reload_one_mem("specs").expect("reload lands");
4711 kinds_hit.insert("reload");
4712 }
4713
4714 if op_i % 10 == 9 {
4715 assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
4716 }
4717 }
4718
4719 assert_derived_oracles(&engine, seed, "sequence end");
4720
4721 for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
4722 assert!(
4723 kinds_hit.contains(kind),
4724 "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
4725 );
4726 }
4727 }
4728
4729 fn migration_engine() -> (tempfile::TempDir, Engine) {
4730 let tmp = tempfile::TempDir::new().unwrap();
4731 let schemas_dir = tmp.path().join("schemas");
4732 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4733 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4734 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
4735 let mem_dir = tmp.path().join("mem");
4736 std::fs::create_dir_all(&mem_dir).unwrap();
4737 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4738 let mut mount = folder_mount("specs", mem_dir);
4739 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4740 let mut engine = Engine::from_mounts_with_schemas_dir(
4741 vec![(
4742 mount,
4743 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4744 )],
4745 Some(&schemas_dir),
4746 )
4747 .unwrap();
4748 for title in ["One", "Two"] {
4749 let mut args = empty_create_args("specs", title);
4750 args.entity_type = "doc".to_string();
4751 args.sections =
4752 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
4753 engine
4754 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4755 .expect("conformant create under mig-a");
4756 }
4757 (tmp, engine)
4758 }
4759
4760 fn sref(s: &str) -> memstead_schema::SchemaRef {
4761 s.parse().unwrap()
4762 }
4763
4764 #[test]
4765 fn set_schema_noop_on_current_pin() {
4766 let (_tmp, mut engine) = migration_engine();
4767 let out = engine
4768 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
4769 .unwrap();
4770 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
4771 assert_eq!(out.schema_pin, "mig-a@0.1.0");
4772 assert_eq!(out.migration_target, None);
4773 assert!(out.findings.is_empty());
4774 }
4775
4776 #[test]
4785 fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
4786 let (_tmp, mut engine) = migration_engine();
4787
4788 let _ = engine.communities();
4789 let _ = engine.search_indexes();
4790 assert!(engine.community_memo.get().is_some());
4791 assert!(engine.search_indexes_memo.get().is_some());
4792 let store_gen_before = engine.store().generation();
4793
4794 engine
4795 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4796 .unwrap();
4797
4798 assert_eq!(
4799 engine.store().generation(),
4800 store_gen_before,
4801 "a schema switch mutates no store content"
4802 );
4803 assert!(
4804 engine.community_memo.get().is_none(),
4805 "the community memo must clear on a schema switch (weights derive from the schema)"
4806 );
4807 assert!(
4808 engine.search_indexes_memo.get().is_none(),
4809 "the search memo must clear on a schema switch (the field set derives from the schema)"
4810 );
4811 }
4812
4813 #[test]
4814 fn set_schema_switches_immediately_when_integral() {
4815 let (_tmp, mut engine) = migration_engine();
4818 let out = engine
4819 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4820 .unwrap();
4821 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4822 assert_eq!(out.schema_pin, "mig-a@0.2.0");
4823 assert_eq!(out.migration_target, None);
4824 assert!(out.findings.is_empty());
4825 assert_eq!(
4826 engine.schema_pin("specs").unwrap().as_display(),
4827 "mig-a@0.2.0"
4828 );
4829 assert!(engine.migration_target("specs").is_none());
4830 }
4831
4832 #[test]
4838 fn set_schema_switch_persists_pin_into_backend_config() {
4839 let tmp = tempfile::TempDir::new().unwrap();
4840 let schemas_dir = tmp.path().join("schemas");
4841 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4842 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4843 let mem_dir = tmp.path().join("mem");
4844 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4845 std::fs::write(
4847 mem_dir.join(".memstead").join("config.json"),
4848 br#"{"schema":"mig-a@0.1.0"}"#,
4849 )
4850 .unwrap();
4851 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4852 let mut mount = folder_mount("specs", mem_dir.clone());
4853 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4854 let mut engine = Engine::from_mounts_with_schemas_dir(
4855 vec![(
4856 mount,
4857 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4858 )],
4859 Some(&schemas_dir),
4860 )
4861 .unwrap();
4862
4863 let out = engine
4864 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4865 .unwrap();
4866 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4867
4868 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
4871 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
4872 assert_eq!(
4873 cfg["schema"], "mig-a@0.2.0",
4874 "atomic switch must update the authoritative backend config"
4875 );
4876 }
4877
4878 #[test]
4879 fn set_schema_unknown_target_refuses_schema_not_found() {
4880 let (_tmp, mut engine) = migration_engine();
4881 let err = engine
4882 .set_mem_schema("specs", &sref("nope@9.9.9"))
4883 .unwrap_err();
4884 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
4885 assert!(engine.migration_target("specs").is_none());
4887 }
4888
4889 #[test]
4890 fn set_schema_migration_lifecycle_end_to_end() {
4891 let (_tmp, mut engine) = migration_engine();
4892 let target = sref("mig-b@0.1.0");
4893
4894 let out = engine.set_mem_schema("specs", &target).unwrap();
4896 assert_eq!(
4897 out.outcome,
4898 crate::engine::SetSchemaResult::MigrationStarted
4899 );
4900 assert_eq!(out.schema_pin, "mig-a@0.1.0");
4901 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
4902 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
4903 assert!(
4904 out.findings
4905 .iter()
4906 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
4907 );
4908
4909 let one = crate::entity::EntityId::new("specs", "one");
4911 assert!(engine.store().get(&one).is_some());
4912
4913 let out = engine.set_mem_schema("specs", &target).unwrap();
4915 assert_eq!(
4916 out.outcome,
4917 crate::engine::SetSchemaResult::MigrationPending
4918 );
4919 assert_eq!(out.findings.len(), 2);
4920
4921 let mut bad = crate::engine::UpdateEntityArgs {
4925 anchors: Vec::new(),
4926 id: one.clone(),
4927 expected_hash: None,
4928 sections: indexmap::IndexMap::new(),
4929 append_sections: indexmap::IndexMap::new(),
4930 patch_sections: indexmap::IndexMap::new(),
4931 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
4932 metadata_unset: Vec::new(),
4933 declare_relations: Vec::new(),
4934 dry_run: false,
4935 relations_unset: Vec::new(),
4936 anchors_unset: Vec::new(),
4937 };
4938 let err = engine
4939 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
4940 .unwrap_err();
4941 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
4942 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
4943 engine
4944 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
4945 .expect("repair write validated against the migration target");
4946
4947 let out = engine.set_mem_schema("specs", &target).unwrap();
4949 assert_eq!(
4950 out.outcome,
4951 crate::engine::SetSchemaResult::MigrationPending
4952 );
4953 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
4954
4955 let two = crate::entity::EntityId::new("specs", "two");
4957 let repair = crate::engine::UpdateEntityArgs {
4958 anchors: Vec::new(),
4959 id: two.clone(),
4960 expected_hash: None,
4961 sections: indexmap::IndexMap::new(),
4962 append_sections: indexmap::IndexMap::new(),
4963 patch_sections: indexmap::IndexMap::new(),
4964 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
4965 metadata_unset: Vec::new(),
4966 declare_relations: Vec::new(),
4967 dry_run: false,
4968 relations_unset: Vec::new(),
4969 anchors_unset: Vec::new(),
4970 };
4971 engine
4972 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
4973 .unwrap();
4974 let out = engine.set_mem_schema("specs", &target).unwrap();
4975 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4976 assert_eq!(out.schema_pin, "mig-b@0.1.0");
4977 assert_eq!(out.migration_target, None);
4978 assert!(out.findings.is_empty());
4979 assert_eq!(
4980 engine.schema_pin("specs").unwrap().as_display(),
4981 "mig-b@0.1.0"
4982 );
4983 assert!(engine.migration_target("specs").is_none());
4984 }
4985
4986 #[test]
4991 fn relations_unset_works_during_migration_without_mode_flag() {
4992 let (_tmp, mut engine) = migration_engine();
4993 let one = crate::entity::EntityId::new("specs", "one");
4994 let two = crate::entity::EntityId::new("specs", "two");
4995 engine
4996 .relate_entity(
4997 crate::engine::RelateEntityArgs {
4998 source: one.clone(),
4999 expected_hash: None,
5000 rel_type: "USES".to_string(),
5001 target: two.clone(),
5002 remove: false,
5003 description: None,
5004 dry_run: false,
5005 },
5006 crate::vcs::Actor::Cli,
5007 None,
5008 None,
5009 )
5010 .unwrap();
5011 let shut = engine
5013 .update_entity(
5014 crate::engine::UpdateEntityArgs {
5015 anchors: Vec::new(),
5016 id: one.clone(),
5017 expected_hash: None,
5018 sections: indexmap::IndexMap::new(),
5019 append_sections: indexmap::IndexMap::new(),
5020 patch_sections: indexmap::IndexMap::new(),
5021 metadata: indexmap::IndexMap::new(),
5022 metadata_unset: Vec::new(),
5023 declare_relations: Vec::new(),
5024 dry_run: false,
5025 relations_unset: vec![crate::ops::RelationUnsetArg {
5026 rel_type: "USES".to_string(),
5027 target: two.clone(),
5028 }],
5029 anchors_unset: Vec::new(),
5030 },
5031 crate::vcs::Actor::Cli,
5032 None,
5033 None,
5034 )
5035 .unwrap_err();
5036 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5037
5038 engine
5042 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5043 .unwrap();
5044 engine
5045 .update_entity(
5046 crate::engine::UpdateEntityArgs {
5047 anchors: Vec::new(),
5048 id: one.clone(),
5049 expected_hash: None,
5050 sections: indexmap::IndexMap::new(),
5051 append_sections: indexmap::IndexMap::new(),
5052 patch_sections: indexmap::IndexMap::new(),
5053 metadata: indexmap::IndexMap::from_iter([(
5054 "status".to_string(),
5055 "open".to_string(),
5056 )]),
5057 metadata_unset: Vec::new(),
5058 declare_relations: Vec::new(),
5059 dry_run: false,
5060 relations_unset: vec![crate::ops::RelationUnsetArg {
5061 rel_type: "USES".to_string(),
5062 target: two.clone(),
5063 }],
5064 anchors_unset: Vec::new(),
5065 },
5066 crate::vcs::Actor::Cli,
5067 None,
5068 None,
5069 )
5070 .expect("repair-shaped update lands during migration without a flag");
5071 let entity = engine.store().get(&one).unwrap();
5072 assert!(entity.relationships.is_empty());
5073 }
5074
5075 #[test]
5080 fn boot_resumes_dual_pin_validation_against_target() {
5081 let (tmp, engine) = migration_engine();
5082 drop(engine);
5083 let schemas_dir = tmp.path().join("schemas");
5084 let mem_dir = tmp.path().join("mem");
5085 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5086 let mut mount = folder_mount("specs", mem_dir);
5087 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5088 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5089 let engine = Engine::from_mounts_with_schemas_dir(
5090 vec![(
5091 mount,
5092 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5093 )],
5094 Some(&schemas_dir),
5095 )
5096 .unwrap();
5097 let (name, version) = {
5099 let s = engine.schema_for("specs").unwrap();
5100 let (n, v) = s.id();
5101 (n.to_string(), v.to_string())
5102 };
5103 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5104 assert_eq!(
5107 engine.schema_pin("specs").unwrap().as_display(),
5108 "mig-a@0.1.0"
5109 );
5110 assert_eq!(
5111 engine.migration_target("specs").unwrap().as_display(),
5112 "mig-b@0.1.0"
5113 );
5114 }
5115
5116 #[test]
5126 fn every_lifecycle_setter_refuses_on_read_only_mount() {
5127 let tmp = TempDir::new().unwrap();
5128 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5129 let mut engine = Engine::from_mounts(vec![(
5130 archive_mount("ext", archive_path.clone()),
5131 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5132 )])
5133 .unwrap();
5134
5135 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5136 let attempts: Vec<(&str, EngineError)> = vec![
5137 (
5138 "set_mem_schema",
5139 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5140 ),
5141 (
5142 "set_mem_version",
5143 engine
5144 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5145 .unwrap_err(),
5146 ),
5147 (
5148 "set_mem_description",
5149 engine
5150 .set_mem_description("ext", Some("x".into()), None)
5151 .unwrap_err(),
5152 ),
5153 (
5154 "set_mem_title",
5155 engine
5156 .set_mem_title("ext", Some("x".into()), None)
5157 .unwrap_err(),
5158 ),
5159 (
5160 "set_mem_subject",
5161 engine.set_mem_subject("ext", None, None).unwrap_err(),
5162 ),
5163 (
5164 "set_mem_internal",
5165 engine.set_mem_internal("ext", true, None).unwrap_err(),
5166 ),
5167 (
5168 "set_mem_sync_state",
5169 engine
5170 .set_mem_sync_state("ext", "k", "t", None)
5171 .unwrap_err(),
5172 ),
5173 ];
5174 for (setter, err) in attempts {
5175 match err {
5176 EngineError::ReadOnlyMount(v) => {
5177 assert_eq!(v, "ext", "{setter} must name the refused mem")
5178 }
5179 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5180 }
5181 }
5182 }
5183}