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 if let Some(w) =
753 super::boot::unbacked_mount_warning(&mount, backend.as_ref(), Some(entries.len()))
754 {
755 self.load_warnings.push(w);
756 }
757 let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
758
759 let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
760 mem_names.push(mount.mem.clone());
761 let known_suffixes: Vec<String> = mem_names
762 .iter()
763 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
764 .collect();
765 let fallback = engine_fallback_type();
766 push_entities_into_store(
767 &mut self.store,
768 load_result.entities,
769 fallback.as_ref(),
770 Some(crate::entity::store_builder::LoadCollector {
771 warnings: &mut self.load_warnings,
772 known_suffixes: &known_suffixes,
773 mem_names: &mem_names,
774 }),
775 );
776 self.load_errors.extend(load_result.errors);
777
778 self.schemas_insert(mount.mem.clone(), schema);
780
781 if run_global_passes {
792 let mut mount_caps: std::collections::HashMap<
793 String,
794 crate::workspace::MountCapability,
795 > = self
796 .mounts
797 .iter()
798 .map(|m| (m.mount.mem.clone(), m.mount.capability))
799 .collect();
800 mount_caps.insert(mount.mem.clone(), mount.capability);
801 crate::entity::store_builder::validate_loaded_relations(
802 &mut self.store,
803 &self.schemas,
804 &mount_caps,
805 &mut self.load_warnings,
806 );
807 crate::entity::store_builder::remap_alias_target_edge_sources(
808 &mut self.store,
809 &self.schemas,
810 );
811 }
812
813 let last_known_head = backend.current_head().ok().flatten();
815 let mem_name_for_router = mount.mem.clone();
816 let storage_for_router = mount.storage.clone();
817 let mount_capability_for_router = mount.capability;
818 self.mounts.push(MountedBackend {
819 mount,
820 backend,
821 last_known_head,
822 mem_config,
823 archive_provenance: None,
826 deferred: false,
828 });
829
830 match (&mount_capability_for_router, &storage_for_router) {
838 (crate::workspace::MountCapability::ReadOnly, MountStorage::Archive { path }) => {
839 Arc::make_mut(&mut self.mem_router)
840 .add_read_only(mem_name_for_router, path.clone());
841 }
842 _ => {
843 let dir: Option<PathBuf> = match &storage_for_router {
844 MountStorage::Folder { path } => Some(path.clone()),
845 MountStorage::GitBranch { .. }
846 | MountStorage::Archive { .. }
847 | MountStorage::InMemory => None,
848 };
849 Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
850 }
851 }
852
853 if run_global_passes {
855 self.invalidate_communities();
856 self.invalidate_search_indexes();
857 }
858
859 Ok(())
860 }
861
862 fn finish_batched_registrations(&mut self) {
867 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
868 .mounts
869 .iter()
870 .map(|m| (m.mount.mem.clone(), m.mount.capability))
871 .collect();
872 crate::entity::store_builder::validate_loaded_relations(
873 &mut self.store,
874 &self.schemas,
875 &mount_caps,
876 &mut self.load_warnings,
877 );
878 crate::entity::store_builder::remap_alias_target_edge_sources(
879 &mut self.store,
880 &self.schemas,
881 );
882 self.invalidate_communities();
883 self.invalidate_search_indexes();
884 }
885
886 pub fn full_refresh(&mut self) -> crate::ops::FullRefreshReport {
911 let started = std::time::Instant::now();
912 let mut report = crate::ops::FullRefreshReport::default();
913
914 let Some(root) = self.workspace_root.clone() else {
915 report.failures.push(crate::ops::RefreshFailure {
916 item: "workspace".to_string(),
917 error: "engine has no workspace root (ad-hoc mount-list construction) — \
918 nothing to re-scan"
919 .to_string(),
920 });
921 report.elapsed_ms = started.elapsed().as_millis() as u64;
922 return report;
923 };
924
925 self.refresh_workspace_settings_if_possible();
928
929 use crate::schema_source::SchemaSource as _;
931 let mut fresh: Vec<std::sync::Arc<memstead_schema::Schema>> = Vec::new();
932 let mut sources_complete = true;
933 match crate::schema_source::FolderSchemaSource::for_workspace(&root).read_schemas() {
934 Ok(mut s) => fresh.append(&mut s),
935 Err(e) => {
936 sources_complete = false;
937 report.failures.push(crate::ops::RefreshFailure {
938 item: "schema-source:folder".to_string(),
939 error: e.to_string(),
940 });
941 }
942 }
943 if let Some(ops) = self.git_branch_ops() {
944 match (ops.read_ref_schemas)(&root) {
945 Ok(mut s) => fresh.append(&mut s),
946 Err(e) => {
947 sources_complete = false;
948 report.failures.push(crate::ops::RefreshFailure {
949 item: "schema-source:memstead-ref".to_string(),
950 error: e.to_string(),
951 });
952 }
953 }
954 }
955 let key = |s: &memstead_schema::Schema| {
956 let (name, version) = s.id();
957 format!("{name}@{version}")
958 };
959 let existing: std::collections::HashSet<String> =
960 self.workspace_schemas.iter().map(|s| key(s)).collect();
961 let fresh_keys: std::collections::HashSet<String> = fresh.iter().map(|s| key(s)).collect();
962 for schema in fresh {
963 let k = key(&schema);
964 if !existing.contains(&k) && !report.schemas_added.contains(&k) {
965 report.schemas_added.push(k);
966 self.workspace_schemas.push(schema);
967 }
968 }
969 report.schemas_added.sort();
970 if sources_complete {
974 report.schema_removals_skipped = existing
975 .difference(&fresh_keys)
976 .cloned()
977 .collect::<Vec<_>>();
978 report.schema_removals_skipped.sort();
979 }
980
981 let store = crate::workspace_store::FileWorkspaceStore::new();
983 match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
984 Err(e) => {
985 report.failures.push(crate::ops::RefreshFailure {
986 item: "mount-manifest".to_string(),
987 error: e.to_string(),
988 });
989 }
990 Ok(workspace) => {
991 let manifest_names: std::collections::HashSet<String> =
992 workspace.mounts.iter().map(|m| m.mem.clone()).collect();
993 let mut any_mounted = false;
994 for mount in workspace.mounts {
995 if self.mounts.iter().any(|m| m.mount.mem == mount.mem) {
996 continue;
997 }
998 let name = mount.mem.clone();
999 if mount.capability != crate::workspace::MountCapability::Write {
1000 report.failures.push(crate::ops::RefreshFailure {
1001 item: format!("mount:{name}"),
1002 error: "only writable mounts attach on a warm refresh — \
1003 restart the process to attach this mount"
1004 .to_string(),
1005 });
1006 continue;
1007 }
1008 let backend = match (self.backend_factory)(&mount) {
1009 Ok(b) => b,
1010 Err(e) => {
1011 report.failures.push(crate::ops::RefreshFailure {
1012 item: format!("mount:{name}"),
1013 error: e.to_string(),
1014 });
1015 continue;
1016 }
1017 };
1018 match self.register_writable_mem_inner(
1019 mount,
1020 backend,
1021 crate::mem::MemOrigin::ExplicitToml,
1022 false,
1023 ) {
1024 Ok(()) => {
1025 any_mounted = true;
1026 report.mems_mounted.push(name);
1027 }
1028 Err(e) => report.failures.push(crate::ops::RefreshFailure {
1029 item: format!("mount:{name}"),
1030 error: e.to_string(),
1031 }),
1032 }
1033 }
1034 report.mem_removals_skipped = self
1039 .mounts
1040 .iter()
1041 .filter(|m| {
1042 m.mount.capability == crate::workspace::MountCapability::Write
1043 && !manifest_names.contains(&m.mount.mem)
1044 })
1045 .map(|m| m.mount.mem.clone())
1046 .collect();
1047 report.mem_removals_skipped.sort();
1048 if any_mounted {
1049 self.finish_batched_registrations();
1051 }
1052 }
1053 }
1054
1055 report.elapsed_ms = started.elapsed().as_millis() as u64;
1056 report
1057 }
1058
1059 pub fn set_workspace_root(&mut self, root: PathBuf) {
1065 self.workspace_root = Some(root);
1066 }
1067
1068 pub fn persist_state(&self) -> Result<(), EngineError> {
1091 let Some(root) = self.workspace_root.as_ref() else {
1092 return Ok(());
1093 };
1094 let workspace = crate::workspace::Workspace {
1095 mounts: self.mounts.iter().map(|m| m.mount.clone()).collect(),
1096 settings: self.settings.clone(),
1097 };
1098 let store = crate::FileWorkspaceStore::new();
1099 crate::workspace_store::WorkspaceStoreAdapter::save_state(&store, root, &workspace)
1100 .map_err(|e| EngineError::Mem(format!("persist workspace state: {e}")))
1101 }
1102 pub fn set_mem_schema(
1122 &mut self,
1123 mem: &str,
1124 target: &memstead_schema::SchemaRef,
1125 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1126 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1127 if self.quarantine_reason(mem).is_some() {
1133 return self.set_schema_on_quarantined(mem, target);
1134 }
1135 let mount_idx = self
1136 .mounts
1137 .iter()
1138 .position(|m| m.mount.mem == mem)
1139 .ok_or_else(|| self.unknown_mem_error(mem))?;
1140 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1146 return Err(EngineError::ReadOnlyMount(mem.to_string()));
1147 }
1148
1149 let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
1152 let consulted: Vec<_> = self
1155 .workspace_schemas
1156 .iter()
1157 .chain(self.builtin_schemas.iter())
1158 .cloned()
1159 .collect();
1160 EngineError::SchemaNotFound {
1161 mem: mem.to_string(),
1162 pin: target.as_display(),
1163 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1164 &target.name,
1165 &target.version,
1166 &consulted,
1167 ),
1168 install_hint: None,
1169 }
1170 .with_schema_install_probe(self.workspace_root())
1171 })?;
1172
1173 let current_pin = self.mounts[mount_idx].mount.schema.clone();
1178 let current_pin_display = current_pin
1179 .as_ref()
1180 .map(|p| p.as_display())
1181 .unwrap_or_else(|| "<unset>".to_string());
1182 let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1183
1184 let served_pin: Option<memstead_schema::SchemaRef> = self.schemas.get(mem).map(|s| {
1195 let (name, version) = s.id();
1196 memstead_schema::SchemaRef::new(name, version)
1197 });
1198 if served_pin.as_ref() == Some(target) && in_flight.is_none() {
1204 if current_pin.as_ref() == Some(target) {
1205 return Ok(SetSchemaOutcome {
1206 mem: mem.to_string(),
1207 schema_pin: current_pin_display,
1208 migration_target: in_flight.map(|t| t.as_display()),
1209 outcome: SetSchemaResult::Noop,
1210 findings: Vec::new(),
1211 });
1212 }
1213 self.mounts[mount_idx].mount.schema = Some(target.clone());
1214 self.mounts[mount_idx].mount.migration_target = None;
1215 self.persist_state()?;
1216 return Ok(SetSchemaOutcome {
1217 mem: mem.to_string(),
1218 schema_pin: target.as_display(),
1219 migration_target: None,
1220 outcome: SetSchemaResult::Switched,
1221 findings: Vec::new(),
1222 });
1223 }
1224
1225 let findings = crate::ops::integrity::conformance_findings(
1231 &self.store,
1232 mem,
1233 target_schema.as_ref(),
1234 &self.schemas,
1235 );
1236
1237 if findings.is_empty() {
1238 self.persist_mem_schema_pin(mount_idx, target)?;
1246 self.mounts[mount_idx].mount.schema = Some(target.clone());
1247 self.mounts[mount_idx].mount.migration_target = None;
1248 self.schemas_insert(mem.to_string(), target_schema);
1249 self.invalidate_communities();
1250 self.invalidate_search_indexes();
1255 self.persist_state()?;
1256 return Ok(SetSchemaOutcome {
1257 mem: mem.to_string(),
1258 schema_pin: target.as_display(),
1259 migration_target: None,
1260 outcome: SetSchemaResult::Switched,
1261 findings: Vec::new(),
1262 });
1263 }
1264
1265 let outcome = if in_flight.as_ref() == Some(target) {
1266 SetSchemaResult::MigrationPending
1267 } else {
1268 SetSchemaResult::MigrationStarted
1269 };
1270 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1271 self.schemas_insert(mem.to_string(), target_schema);
1274 self.invalidate_communities();
1275 self.invalidate_search_indexes();
1277 self.persist_state()?;
1278 Ok(SetSchemaOutcome {
1279 mem: mem.to_string(),
1280 schema_pin: current_pin_display,
1281 migration_target: Some(target.as_display()),
1282 outcome,
1283 findings,
1284 })
1285 }
1286
1287 fn persist_mem_schema_pin(
1304 &mut self,
1305 mount_idx: usize,
1306 target: &memstead_schema::SchemaRef,
1307 ) -> Result<(), EngineError> {
1308 let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1309 if let Some(value) = value
1312 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1313 {
1314 self.mounts[mount_idx].mem_config = Some(cfg);
1315 }
1316 Ok(())
1317 }
1318
1319 pub fn export_markdown(
1348 &self,
1349 mem_filter: Option<&str>,
1350 schema_filter: Option<&str>,
1351 ) -> Result<crate::ops::ExportResult, EngineError> {
1352 use crate::workspace::MountStorage;
1353 let fallback = engine_fallback_type();
1354 let supported_backends = vec!["folder".to_string()];
1355
1356 if let Some(name) = mem_filter {
1357 let mount = self
1358 .mounts
1359 .iter()
1360 .find(|m| m.mount.mem == name)
1361 .ok_or_else(|| self.unknown_mem_error(name))?;
1362 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1363 return Err(EngineError::MarkdownExportUnsupportedBackend {
1364 mem: name.to_string(),
1365 active_backend: mount.mount.storage.backend_id().to_string(),
1366 supported_backends,
1367 });
1368 }
1369 }
1370
1371 let mut total_written = 0;
1372 let mut total_unchanged = 0;
1373 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1374
1375 for mount in &self.mounts {
1376 let mem_name = mount.mount.mem.as_str();
1377 if let Some(filter) = mem_filter
1378 && mem_name != filter
1379 {
1380 continue;
1381 }
1382 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1383 skipped_mounts.push(crate::ops::SkippedMount {
1384 mem: mem_name.to_string(),
1385 active_backend: mount.mount.storage.backend_id().to_string(),
1386 reason: "backend_does_not_support_markdown_export".to_string(),
1387 });
1388 continue;
1389 };
1390 let schema = match self.schemas.get(mem_name) {
1391 Some(s) => s,
1392 None => continue,
1393 };
1394
1395 for entity in self.store.all_entities() {
1396 if entity.stub || entity.file_path.is_empty() {
1397 continue;
1398 }
1399 if entity.id.mem() != mem_name {
1400 continue;
1401 }
1402 if let Some(filter) = schema_filter
1403 && entity.entity_type != filter
1404 {
1405 continue;
1406 }
1407 let type_def = schema
1408 .get_type(&entity.entity_type)
1409 .unwrap_or_else(|| fallback.clone());
1410 let generated = generate_markdown(entity, type_def.as_ref());
1411
1412 let full_path = mem_dir.join(&entity.file_path);
1413 let needs_write = match std::fs::read_to_string(&full_path) {
1414 Ok(existing) => existing != generated,
1415 Err(_) => true,
1416 };
1417 if needs_write {
1418 let _ = crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref());
1419 total_written += 1;
1420 } else {
1421 total_unchanged += 1;
1422 }
1423 }
1424 }
1425
1426 Ok(crate::ops::ExportResult {
1427 written: total_written,
1428 unchanged: total_unchanged,
1429 skipped_mounts,
1430 })
1431 }
1432
1433 pub fn export_mem(
1451 &self,
1452 mem_name: &str,
1453 output_path: &std::path::Path,
1454 ) -> Result<crate::ops::MemExportResult, EngineError> {
1455 let mount = self
1456 .mounts
1457 .iter()
1458 .find(|m| m.mount.mem == mem_name)
1459 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1460 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1461 EngineError::InvalidInput(format!(
1462 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1463 ))
1464 })?;
1465 if config.version.is_none() {
1473 return Err(EngineError::MemConfigIncomplete {
1474 mem: mem_name.to_string(),
1475 missing_fields: vec!["version".to_string()],
1476 });
1477 }
1478 let workspace_root = self.workspace_root.as_deref();
1479 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1483 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1484 match &mount.mount.storage {
1485 MountStorage::Folder { path } => crate::ops::export::export_mem(
1486 path,
1487 config,
1488 output_path,
1489 workspace_root,
1490 workspace_schemas_dir,
1491 )
1492 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1493 MountStorage::GitBranch { gitdir, branch } => {
1494 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1495 EngineError::Backend(BackendError::Other(
1496 "git-branch export hook not installed (full flavour not loaded)"
1497 .to_string(),
1498 ))
1499 })?;
1500 let provenance_bytes = mount
1504 .backend
1505 .read_provenance(None)
1506 .ok()
1507 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
1508 .and_then(|prov| prov.to_archive_bytes().ok());
1509 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1512 (hook.export)(
1513 gitdir,
1514 branch,
1515 mem_name,
1516 config,
1517 output_path,
1518 workspace_root,
1519 workspace_schemas_dir,
1520 provenance_bytes.as_deref(),
1521 anchors_bytes.as_deref(),
1522 )
1523 .map_err(EngineError::Backend)
1524 }
1525 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1526 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1531 "export not yet supported for in-memory backend".to_string(),
1532 ))),
1533 }
1534 }
1535
1536 pub fn record_pipeline_edit_provenance(
1560 &self,
1561 mem: &str,
1562 kind: &str,
1563 edits: &[(String, Option<Vec<u8>>)],
1564 note: Option<&str>,
1565 verb: &str,
1566 ) -> Result<(), crate::backend::BackendError> {
1567 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1568 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1569 None => Ok(()),
1570 }
1571 }
1572
1573 pub fn set_mem_version(
1574 &mut self,
1575 mem_name: &str,
1576 new_version: semver::Version,
1577 note: Option<&str>,
1578 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1579 let mount_idx = self
1582 .mounts
1583 .iter()
1584 .position(|m| m.mount.mem == mem_name)
1585 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1586 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1587 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1588 }
1589
1590 let mut warnings = self.reload_if_stale(Some(mem_name));
1598 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1603 warnings.push(w);
1604 }
1605
1606 let mounted = &mut self.mounts[mount_idx];
1607 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1608 EngineError::InvalidInput(format!(
1609 "mem '{mem_name}' has no loaded MemConfig — \
1610 cannot set version (initialize the mem via `memstead init` \
1611 or `memstead mem create` first)"
1612 ))
1613 })?;
1614 let old_version = config.version.clone();
1615 config.version = Some(new_version.clone());
1616
1617 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1618 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1619 })?;
1620 bytes.push(b'\n');
1621 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1622 mounted.mem_config = Some(config);
1623
1624 let new_head = mounted.backend.current_head().ok().flatten();
1630 if let Some(sha) = new_head {
1631 mounted.last_known_head = Some(sha);
1632 }
1633
1634 Ok(crate::ops::SetMemVersionOutcome {
1635 mem: mem_name.to_string(),
1636 old_version,
1637 new_version,
1638 warnings,
1639 })
1640 }
1641
1642 pub fn set_mem_description(
1649 &mut self,
1650 mem_name: &str,
1651 new_description: Option<String>,
1652 note: Option<&str>,
1653 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1654 let mount_idx = self
1655 .mounts
1656 .iter()
1657 .position(|m| m.mount.mem == mem_name)
1658 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1659 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1660 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1661 }
1662
1663 let mut warnings = self.reload_if_stale(Some(mem_name));
1664 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1665 warnings.push(w);
1666 }
1667
1668 let mounted = &mut self.mounts[mount_idx];
1669 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1670 EngineError::InvalidInput(format!(
1671 "mem '{mem_name}' has no loaded MemConfig — \
1672 cannot set description (initialize the mem via `memstead init` \
1673 or `memstead mem create` first)"
1674 ))
1675 })?;
1676 let old_description = config.description.clone();
1677 config.description = new_description.clone();
1678
1679 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1680 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1681 })?;
1682 bytes.push(b'\n');
1683 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1684 mounted.mem_config = Some(config);
1685
1686 let new_head = mounted.backend.current_head().ok().flatten();
1687 if let Some(sha) = new_head {
1688 mounted.last_known_head = Some(sha);
1689 }
1690
1691 Ok(crate::ops::SetMemDescriptionOutcome {
1692 mem: mem_name.to_string(),
1693 old_description,
1694 new_description,
1695 warnings,
1696 })
1697 }
1698
1699 pub fn set_mem_title(
1704 &mut self,
1705 mem_name: &str,
1706 new_title: Option<String>,
1707 note: Option<&str>,
1708 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1709 let mount_idx = self
1710 .mounts
1711 .iter()
1712 .position(|m| m.mount.mem == mem_name)
1713 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1714 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1715 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1716 }
1717
1718 let mut warnings = self.reload_if_stale(Some(mem_name));
1719 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1720 warnings.push(w);
1721 }
1722
1723 let mounted = &mut self.mounts[mount_idx];
1724 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1725 EngineError::InvalidInput(format!(
1726 "mem '{mem_name}' has no loaded MemConfig — cannot set title"
1727 ))
1728 })?;
1729 let old_title = config.title.clone();
1730 config.title = new_title.clone();
1731
1732 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1733 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1734 })?;
1735 bytes.push(b'\n');
1736 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1737 mounted.mem_config = Some(config);
1738
1739 let new_head = mounted.backend.current_head().ok().flatten();
1740 if let Some(sha) = new_head {
1741 mounted.last_known_head = Some(sha);
1742 }
1743
1744 Ok(crate::ops::SetMemTitleOutcome {
1745 mem: mem_name.to_string(),
1746 old_title,
1747 new_title,
1748 warnings,
1749 })
1750 }
1751
1752 pub fn set_mem_subject(
1756 &mut self,
1757 mem_name: &str,
1758 new_subject: Option<memstead_schema::MemSubject>,
1759 note: Option<&str>,
1760 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1761 let mount_idx = self
1762 .mounts
1763 .iter()
1764 .position(|m| m.mount.mem == mem_name)
1765 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1766 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1767 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1768 }
1769
1770 let mut warnings = self.reload_if_stale(Some(mem_name));
1771 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
1772 warnings.push(w);
1773 }
1774
1775 let mounted = &mut self.mounts[mount_idx];
1776 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1777 EngineError::InvalidInput(format!(
1778 "mem '{mem_name}' has no loaded MemConfig — cannot set subject"
1779 ))
1780 })?;
1781 let old_subject = config.subject.clone();
1782 config.subject = new_subject.clone();
1783
1784 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1785 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1786 })?;
1787 bytes.push(b'\n');
1788 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1789 mounted.mem_config = Some(config);
1790
1791 let new_head = mounted.backend.current_head().ok().flatten();
1792 if let Some(sha) = new_head {
1793 mounted.last_known_head = Some(sha);
1794 }
1795
1796 Ok(crate::ops::SetMemSubjectOutcome {
1797 mem: mem_name.to_string(),
1798 old_subject,
1799 new_subject,
1800 warnings,
1801 })
1802 }
1803
1804 pub fn set_mem_internal(
1816 &mut self,
1817 mem_name: &str,
1818 internal: bool,
1819 note: Option<&str>,
1820 ) -> Result<bool, EngineError> {
1821 let mount_idx = self
1822 .mounts
1823 .iter()
1824 .position(|m| m.mount.mem == mem_name)
1825 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1826 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1827 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1828 }
1829
1830 let _ = self.reload_if_stale(Some(mem_name));
1831
1832 let mounted = &mut self.mounts[mount_idx];
1833 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1834 EngineError::InvalidInput(format!(
1835 "mem '{mem_name}' has no loaded MemConfig — initialize the mem first"
1836 ))
1837 })?;
1838 if internal {
1839 config
1840 .extra
1841 .insert("internal".to_string(), serde_json::Value::Bool(true));
1842 } else {
1843 config.extra.remove("internal");
1844 }
1845
1846 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1847 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1848 })?;
1849 bytes.push(b'\n');
1850 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1851 mounted.mem_config = Some(config);
1852
1853 let new_head = mounted.backend.current_head().ok().flatten();
1854 if let Some(sha) = new_head {
1855 mounted.last_known_head = Some(sha);
1856 }
1857
1858 Ok(internal)
1859 }
1860
1861 pub fn set_mem_sync_state(
1886 &mut self,
1887 mem_name: &str,
1888 key: &str,
1889 token: &str,
1890 note: Option<&str>,
1891 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
1892 let mount_idx = self
1895 .mounts
1896 .iter()
1897 .position(|m| m.mount.mem == mem_name)
1898 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1899 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1900 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1901 }
1902
1903 let mut warnings = self.reload_if_stale(Some(mem_name));
1907 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
1908 warnings.push(w);
1909 }
1910
1911 let mounted = &mut self.mounts[mount_idx];
1912 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1913 EngineError::InvalidInput(format!(
1914 "mem '{mem_name}' has no loaded MemConfig — \
1915 cannot set sync state (initialize the mem via `memstead init` \
1916 or `memstead mem create` first)"
1917 ))
1918 })?;
1919
1920 let removed;
1924 let previous;
1925 if token.is_empty() {
1926 previous = config.sync_state.remove(key);
1927 removed = previous.is_some();
1928 } else {
1929 previous = config.sync_state.insert(key.to_string(), token.to_string());
1930 removed = false;
1931 }
1932
1933 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1934 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1935 })?;
1936 bytes.push(b'\n');
1937 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1938 mounted.mem_config = Some(config);
1939
1940 let new_head = mounted.backend.current_head().ok().flatten();
1943 if let Some(sha) = new_head {
1944 mounted.last_known_head = Some(sha);
1945 }
1946
1947 Ok(crate::ops::SetMemSyncStateOutcome {
1948 mem: mem_name.to_string(),
1949 key: key.to_string(),
1950 previous,
1951 removed,
1952 warnings,
1953 })
1954 }
1955
1956 pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
2003 let pending: Vec<String> = self
2004 .mounts
2005 .iter()
2006 .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
2007 .map(|m| m.mount.mem.clone())
2008 .collect();
2009 for name in pending {
2010 match self.reload_one_mem(&name) {
2011 Ok(_) => {
2012 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
2013 state.deferred = false;
2014 }
2015 let store = &self.store;
2021 self.load_warnings.retain(|w| match w {
2022 crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
2023 store.get(resolved_id).is_none_or(|e| e.stub)
2024 }
2025 _ => true,
2026 });
2027 }
2028 Err(e) => {
2029 let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
2034 continue;
2035 };
2036 let removed = self.mounts.remove(idx);
2037 self.schemas_remove(&name);
2038 self.quarantined.push(crate::engine::QuarantinedMem {
2039 mount: removed.mount,
2040 reason_code: e.code().to_string(),
2041 reason_message: e.to_string(),
2042 });
2043 self.mem_router = std::sync::Arc::new(
2044 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2045 );
2046 self.invalidate_communities();
2047 self.invalidate_search_indexes();
2053 }
2054 }
2055 }
2056 }
2057
2058 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2059 if self.quarantine_reason(mem).is_some() {
2082 return self.reattach_quarantined_mem(mem);
2083 }
2084 let mut sink: Vec<WarningHint> = Vec::new();
2085 let result = self.reload_one_mem_inner(mem, &mut sink)?;
2086 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2087 self.load_warnings
2088 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2089 Ok(result)
2090 }
2091
2092 fn set_schema_on_quarantined(
2102 &mut self,
2103 mem: &str,
2104 target: &memstead_schema::SchemaRef,
2105 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2106 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2107 if self.resolve_schema_by_ref(target).is_none() {
2109 let consulted: Vec<_> = self
2110 .workspace_schemas
2111 .iter()
2112 .chain(self.builtin_schemas.iter())
2113 .cloned()
2114 .collect();
2115 return Err(EngineError::SchemaNotFound {
2116 mem: mem.to_string(),
2117 pin: target.as_display(),
2118 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2119 &target.name,
2120 &target.version,
2121 &consulted,
2122 ),
2123 install_hint: None,
2124 }
2125 .with_schema_install_probe(self.workspace_root()));
2126 }
2127 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2128 return Err(self.unknown_mem_error(mem));
2129 };
2130 self.quarantined[q_idx].mount.schema = Some(target.clone());
2134 self.quarantined[q_idx].mount.migration_target = None;
2135 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2136 let _ = bump_backend_schema_pin(backend.as_ref(), target);
2137 }
2138 self.persist_state()?;
2139 let _ = self.reattach_quarantined_mem(mem);
2144 Ok(SetSchemaOutcome {
2145 mem: mem.to_string(),
2146 schema_pin: target.as_display(),
2147 migration_target: None,
2148 outcome: SetSchemaResult::Switched,
2149 findings: Vec::new(),
2150 })
2151 }
2152
2153 fn reattach_quarantined_mem(
2161 &mut self,
2162 mem: &str,
2163 ) -> Result<crate::ops::ReloadResult, EngineError> {
2164 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2165 return Err(self.unknown_mem_error(mem));
2166 };
2167 let mount = self.quarantined[q_idx].mount.clone();
2168
2169 let requarantine = |this: &mut Self, e: &EngineError| {
2170 this.quarantined[q_idx].reason_code = e.code().to_string();
2171 this.quarantined[q_idx].reason_message = e.to_string();
2172 };
2173
2174 let backend = match (self.backend_factory)(&mount) {
2175 Ok(b) => b,
2176 Err(e) => {
2177 let err = EngineError::Mem(e.to_string());
2178 requarantine(self, &err);
2179 return Err(self.unknown_mem_error(mem));
2180 }
2181 };
2182
2183 let last_known_head = backend.current_head().ok().flatten();
2185 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2186 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2187 memstead_schema::config::parse_mem_config(&value).ok()
2188 });
2189 let archive_provenance = backend
2190 .read_archive_provenance()
2191 .ok()
2192 .flatten()
2193 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2194 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2195 let effective_pin = mount
2196 .migration_target
2197 .clone()
2198 .or(config_pin)
2199 .or(mount.schema.clone());
2200 let Some(effective_pin) = effective_pin else {
2201 let err = EngineError::MemConfigIncomplete {
2202 mem: mem.to_string(),
2203 missing_fields: vec!["schema".to_string()],
2204 };
2205 requarantine(self, &err);
2206 return Err(self.unknown_mem_error(mem));
2207 };
2208 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2209 .workspace_schemas
2210 .iter()
2211 .chain(self.builtin_schemas.iter())
2212 .cloned()
2213 .collect();
2214 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2215 Ok(s) => s,
2216 Err(sources) => {
2217 let err = EngineError::SchemaNotFound {
2218 mem: mem.to_string(),
2219 pin: effective_pin.as_display(),
2220 sources,
2221 install_hint: None,
2222 }
2223 .with_schema_install_probe(self.workspace_root());
2224 requarantine(self, &err);
2225 return Err(self.unknown_mem_error(mem));
2226 }
2227 };
2228
2229 self.quarantined.remove(q_idx);
2233 self.schemas_insert(mem.to_string(), schema);
2234 self.mounts.push(crate::engine::MountedBackend {
2235 mount,
2236 backend,
2237 last_known_head,
2238 mem_config,
2239 archive_provenance,
2240 deferred: false,
2243 });
2244 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2245 &self.mounts,
2246 ));
2247 let mut sink: Vec<WarningHint> = Vec::new();
2248 match self.reload_one_mem_inner(mem, &mut sink) {
2249 Ok(result) => {
2250 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2251 self.load_warnings
2252 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2253 self.invalidate_communities();
2254 Ok(result)
2255 }
2256 Err(e) => {
2257 let mount_idx = self.mounts.len() - 1;
2258 let mounted = self.mounts.remove(mount_idx);
2259 self.schemas_remove(mem);
2260 self.mem_router = std::sync::Arc::new(
2261 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2262 );
2263 self.quarantined.push(crate::engine::QuarantinedMem {
2264 mount: mounted.mount,
2265 reason_code: e.code().to_string(),
2266 reason_message: e.to_string(),
2267 });
2268 self.invalidate_communities();
2272 self.invalidate_search_indexes();
2273 Err(e)
2274 }
2275 }
2276 }
2277
2278 fn reload_one_mem_inner(
2284 &mut self,
2285 mem: &str,
2286 warnings_sink: &mut Vec<WarningHint>,
2287 ) -> Result<crate::ops::ReloadResult, EngineError> {
2288 let mount_idx = self
2291 .mounts
2292 .iter()
2293 .position(|m| m.mount.mem == mem)
2294 .ok_or_else(|| self.unknown_mem_error(mem))?;
2295 let schema = self
2296 .schemas
2297 .get(mem)
2298 .cloned()
2299 .ok_or_else(|| self.unknown_mem_error(mem))?;
2300
2301 let pre: HashMap<EntityId, String> = self
2303 .store
2304 .all_entities()
2305 .filter(|e| !e.stub && e.mem == mem)
2306 .map(|e| (e.id.clone(), e.content_hash.clone()))
2307 .collect();
2308 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2309
2310 let backend = self.mounts[mount_idx].backend.as_ref();
2313 let (entries, read_errors) = collect_source_entries(backend)?;
2314 let unbacked = super::boot::unbacked_mount_warning(
2319 &self.mounts[mount_idx].mount,
2320 backend,
2321 Some(entries.len()),
2322 );
2323 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2324
2325 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2329 let known_suffixes: Vec<String> = mem_names
2330 .iter()
2331 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2332 .collect();
2333
2334 self.store.remove_entities_by_mem(mem);
2336 if let Some(w) = unbacked {
2337 warnings_sink.push(w);
2338 }
2339 let fallback = engine_fallback_type();
2340 push_entities_into_store(
2341 &mut self.store,
2342 load_result.entities,
2343 fallback.as_ref(),
2344 Some(crate::entity::store_builder::LoadCollector {
2345 warnings: warnings_sink,
2346 known_suffixes: &known_suffixes,
2347 mem_names: &mem_names,
2348 }),
2349 );
2350 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2357 .mounts
2358 .iter()
2359 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2360 .collect();
2361 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2369 crate::entity::store_builder::validate_loaded_relations(
2370 &mut self.store,
2371 &self.schemas,
2372 &mount_caps,
2373 warnings_sink,
2374 );
2375 crate::entity::store_builder::remap_alias_target_edge_sources(
2376 &mut self.store,
2377 &self.schemas,
2378 );
2379 if let crate::workspace::MountStorage::Folder { path } =
2389 &self.mounts[mount_idx].mount.storage
2390 {
2391 let root = path.clone();
2392 self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2393 self.load_errors
2397 .extend(load_result.errors.into_iter().map(|(p, m)| {
2398 let abs = if p.is_relative() { root.join(&p) } else { p };
2399 (abs, m)
2400 }));
2401 } else {
2402 self.load_errors.extend(load_result.errors);
2403 }
2404
2405 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2413 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2414 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2415 {
2416 self.mounts[mount_idx].mem_config = Some(cfg);
2417 }
2418
2419 let mut added: Vec<EntityId> = Vec::new();
2421 let mut changed: Vec<EntityId> = Vec::new();
2422 for entity in self.store.all_entities() {
2423 if entity.stub || entity.mem != mem {
2424 continue;
2425 }
2426 match pre.get(&entity.id) {
2427 None => added.push(entity.id.clone()),
2428 Some(prev_hash) if prev_hash != &entity.content_hash => {
2429 changed.push(entity.id.clone());
2430 }
2431 Some(_) => {}
2432 }
2433 }
2434 let post_ids: std::collections::HashSet<EntityId> = self
2435 .store
2436 .all_entities()
2437 .filter(|e| !e.stub && e.mem == mem)
2438 .map(|e| e.id.clone())
2439 .collect();
2440 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2441 added.sort_by(|a, b| a.0.cmp(&b.0));
2442 changed.sort_by(|a, b| a.0.cmp(&b.0));
2443 removed.sort_by(|a, b| a.0.cmp(&b.0));
2444
2445 self.invalidate_communities();
2446 self.invalidate_search_indexes();
2447
2448 Ok(crate::ops::ReloadResult {
2449 added,
2450 changed,
2451 removed,
2452 })
2453 }
2454
2455 pub fn reload_one_mem_report(
2483 &mut self,
2484 mem: &str,
2485 ) -> Result<crate::ops::ReloadReport, EngineError> {
2486 let tracks_head = self
2496 .mounts
2497 .iter()
2498 .find(|m| m.mount.mem == mem)
2499 .and_then(|m| m.backend.current_head().ok().flatten())
2500 .is_some();
2501 let head_before = if tracks_head {
2502 self.mounts
2503 .iter()
2504 .find(|m| m.mount.mem == mem)
2505 .and_then(|m| m.last_known_head.clone())
2506 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2507 } else {
2508 crate::ops::EMPTY_TREE_SHA.to_string()
2509 };
2510
2511 let result = self.reload_one_mem(mem)?;
2512
2513 let head_after_raw = self
2520 .mounts
2521 .iter()
2522 .find(|m| m.mount.mem == mem)
2523 .and_then(|m| m.backend.current_head().ok().flatten());
2524 if let Some(new_head) = head_after_raw.clone()
2525 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2526 {
2527 m.last_known_head = Some(new_head);
2528 }
2529 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2530
2531 let entities_loaded = self
2532 .store
2533 .all_entities()
2534 .filter(|e| !e.stub && e.mem == mem)
2535 .count();
2536
2537 let mut changed_entity_ids: Vec<EntityId> = result
2542 .added
2543 .into_iter()
2544 .chain(result.changed)
2545 .chain(result.removed)
2546 .collect();
2547 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2548
2549 Ok(crate::ops::ReloadReport {
2550 mem: mem.to_string(),
2551 head_before,
2552 head_after,
2553 entities_loaded,
2554 changed_entity_ids,
2555 })
2556 }
2557
2558 pub fn reload_each_writable_mem_reports(
2598 &mut self,
2599 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2600 self.refresh_workspace_settings_if_possible();
2601 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2602 let mut out = Vec::with_capacity(names.len());
2603 for name in names {
2604 let report = self.reload_one_mem_report(&name)?;
2605 out.push(report);
2606 }
2607 Ok(out)
2608 }
2609
2610 fn refresh_workspace_settings_if_possible(&mut self) {
2622 let Some(root) = self.workspace_root.clone() else {
2623 return;
2624 };
2625 let store = crate::workspace_store::FileWorkspaceStore::new();
2626 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2627 Ok(w) => w,
2628 Err(_) => return,
2629 };
2630 self.set_settings(workspace.settings);
2631 }
2632
2633 pub fn reload_each_writable_mem(
2649 &mut self,
2650 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2651 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2652 let mut sink = std::mem::take(&mut self.load_warnings);
2658 sink.clear();
2659 let mut out = Vec::with_capacity(names.len());
2660 let mut loop_err = None;
2661 for name in names {
2662 match self.reload_one_mem_inner(&name, &mut sink) {
2663 Ok(result) => out.push((name, result)),
2664 Err(e) => {
2665 loop_err = Some(e);
2666 break;
2667 }
2668 }
2669 }
2670 self.load_warnings = sink;
2671 if let Some(e) = loop_err {
2672 return Err(e);
2673 }
2674 Ok(out)
2675 }
2676}
2677
2678pub fn bump_backend_schema_pin(
2691 backend: &dyn crate::backend::MemBackend,
2692 target: &memstead_schema::SchemaRef,
2693) -> Result<Option<serde_json::Value>, EngineError> {
2694 let Some(bytes) = backend
2695 .read_mem_config()
2696 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2697 else {
2698 return Ok(None);
2699 };
2700 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2701 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2702 value["schema"] = serde_json::Value::String(target.as_display());
2703 let new_bytes = serde_json::to_vec_pretty(&value)
2704 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2705 backend
2706 .write_mem_config(&new_bytes)
2707 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2708 Ok(Some(value))
2709}
2710
2711#[cfg(test)]
2712mod tests {
2713
2714 use tempfile::TempDir;
2715
2716 use crate::backend::{BackendError, MemBackend};
2717 use crate::engine::test_helpers::*;
2718 use crate::engine::{Engine, EngineError};
2719 use crate::mem::MemOrigin;
2720 use crate::ops::WarningHint;
2721 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
2722
2723 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
2724 let manifest = format!(
2725 r#"name: {manifest_name}
2726version: 1.0.0
2727description: Install-gate test schema
2728when_to_use: Tests
2729types:
2730 - sample
2731relationships:
2732 mode: strict
2733 definitions:
2734 - name: PART_OF
2735 description: hier
2736 default_weight: 3.0
2737 - name: _default
2738 description: fallback
2739 default_weight: 1.0
2740community:
2741 resolution: 1.0
2742 seed: 42
2743"#
2744 );
2745 let type_yaml = format!(
2746 r#"name: sample
2747description: t
2748when_to_use: tests
2749sections:
2750 - key: body
2751 heading: {heading}
2752 required: true
2753 search_weight: 10.0
2754 catch_all: true
2755 write_rules: []
2756metadata_fields: []
2757title_weight: 100.0
2758text_fields:
2759 - body
2760hierarchy_relationship: PART_OF
2761no_self_loop_relationships: []
2762updatable_fields:
2763 - title
2764 - body
2765health_required_fields:
2766 - body
2767staleness_threshold_days: 90
2768write_rules: []
2769"#
2770 );
2771 vec![
2772 ("schema.yaml".to_string(), manifest.into_bytes()),
2773 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2774 ]
2775 }
2776
2777 #[test]
2781 fn install_gate_refuses_non_roundtrip_heading() {
2782 let ok =
2783 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
2784 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
2785
2786 let err = Engine::validate_schema_package(
2787 "gate",
2788 "1.0.0",
2789 &schema_package_files("Body Text", "gate"),
2790 )
2791 .expect_err("non-deriving heading must refuse install");
2792 match &err {
2793 EngineError::SchemaPackageInvalid { name, message, .. } => {
2794 assert_eq!(name, "gate");
2795 assert!(
2796 message.contains("'body'") && message.contains("'Body Text'"),
2797 "message names the offending tuple: {message}"
2798 );
2799 }
2800 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2801 }
2802 }
2803
2804 fn exemplar_package_files(
2811 section_key: &str,
2812 status_value: &str,
2813 rel_type: &str,
2814 ) -> Vec<(String, Vec<u8>)> {
2815 let manifest = r#"name: gate
2816version: 1.0.0
2817description: exemplar gate fixture
2818when_to_use: tests
2819types:
2820 - sample
2821 - other
2822relationships:
2823 mode: strict
2824 definitions:
2825 - name: PART_OF
2826 description: hier
2827 default_weight: 3.0
2828 - name: REFINES
2829 description: pinned
2830 default_weight: 1.0
2831 source_types: [other]
2832 - name: _default
2833 description: fallback
2834 default_weight: 1.0
2835community:
2836 resolution: 1.0
2837 seed: 42
2838"#
2839 .to_string();
2840 let type_yaml = format!(
2841 r#"name: sample
2842description: t
2843when_to_use: tests
2844sections:
2845 - key: body
2846 heading: Body
2847 required: true
2848 search_weight: 10.0
2849 catch_all: true
2850 write_rules: []
2851metadata_fields:
2852 - key: status
2853 description: workflow state
2854 field_type: string
2855 enum_values: [draft, final]
2856title_weight: 100.0
2857text_fields:
2858 - body
2859hierarchy_relationship: PART_OF
2860no_self_loop_relationships: []
2861updatable_fields:
2862 - title
2863 - body
2864health_required_fields:
2865 - body
2866staleness_threshold_days: 90
2867write_rules: []
2868exemplar:
2869 title: A Conforming Sample
2870 metadata:
2871 status: "{status_value}"
2872 sections:
2873 {section_key}: "One canonical body paragraph."
2874 relations:
2875 - to: parent-placeholder
2876 type: {rel_type}
2877"#
2878 );
2879 let other_yaml = r#"name: other
2880description: shape-pin partner
2881when_to_use: tests
2882sections:
2883 - key: body
2884 heading: Body
2885 required: true
2886 search_weight: 10.0
2887 catch_all: true
2888 write_rules: []
2889metadata_fields: []
2890title_weight: 100.0
2891text_fields:
2892 - body
2893hierarchy_relationship: PART_OF
2894no_self_loop_relationships: []
2895updatable_fields:
2896 - title
2897 - body
2898health_required_fields:
2899 - body
2900staleness_threshold_days: 90
2901write_rules: []
2902"#
2903 .to_string();
2904 vec![
2905 ("schema.yaml".to_string(), manifest.into_bytes()),
2906 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2907 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
2908 ]
2909 }
2910
2911 #[test]
2918 fn install_gate_validates_exemplars_through_the_real_create_path() {
2919 let ok = Engine::validate_schema_package(
2921 "gate",
2922 "1.0.0",
2923 &exemplar_package_files("body", "draft", "PART_OF"),
2924 );
2925 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
2926
2927 let err = Engine::validate_schema_package(
2929 "gate",
2930 "1.0.0",
2931 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
2932 )
2933 .expect_err("wrong section key must refuse");
2934 match &err {
2935 EngineError::SchemaPackageInvalid { message, .. } => {
2936 assert!(
2937 message.contains("'sample'") && message.contains("exemplar"),
2938 "names type and calls out the exemplar: {message}"
2939 );
2940 assert!(
2941 message.contains("UNKNOWN_SECTION")
2942 || message.contains("MISSING_REQUIRED_SECTION"),
2943 "carries the typed defect code: {message}"
2944 );
2945 }
2946 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2947 }
2948
2949 let err = Engine::validate_schema_package(
2951 "gate",
2952 "1.0.0",
2953 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
2954 )
2955 .expect_err("illegal enum value must refuse");
2956 assert!(
2957 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2958 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
2959 "got {err:?}"
2960 );
2961
2962 let err = Engine::validate_schema_package(
2965 "gate",
2966 "1.0.0",
2967 &exemplar_package_files("body", "draft", "REFINES"),
2968 )
2969 .expect_err("relationship shape violation must refuse");
2970 assert!(
2971 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2972 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
2973 "got {err:?}"
2974 );
2975 }
2976
2977 #[test]
2982 fn worked_example_package_exemplars_validate() {
2983 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2984 .join("../memstead-schema/examples/minimal");
2985 let schema = std::sync::Arc::new(
2986 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
2987 );
2988 assert!(
2989 schema.types.values().all(|td| td.exemplar.is_some()),
2990 "every worked-example type models the exemplar practice"
2991 );
2992 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
2993 }
2994
2995 #[test]
3002 fn builtin_exemplars_validate_through_the_install_gate() {
3003 let schemas = memstead_schema::builtins::load_builtin_schemas()
3004 .expect("built-in schemas always load");
3005 for schema in &schemas {
3007 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
3008 let (name, version) = schema.id();
3009 panic!("built-in {name}@{version}: {defect}");
3010 }
3011 }
3012 let mut newest: std::collections::HashMap<
3014 String,
3015 &std::sync::Arc<memstead_schema::Schema>,
3016 > = std::collections::HashMap::new();
3017 for schema in &schemas {
3018 let name = schema.manifest.name.clone();
3019 match newest.get(&name) {
3020 Some(cur) if cur.version >= schema.version => {}
3021 _ => {
3022 newest.insert(name, schema);
3023 }
3024 }
3025 }
3026 for (name, schema) in &newest {
3027 for (type_name, td) in &schema.types {
3028 assert!(
3029 td.exemplar.is_some(),
3030 "built-in {name}@{} type '{type_name}' has no exemplar — the \
3031 reference schemas model the practice completely",
3032 schema.version
3033 );
3034 }
3035 }
3036 }
3037
3038 #[test]
3043 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
3044 let mut files = exemplar_package_files("body", "draft", "PART_OF");
3045 let patched = String::from_utf8(files[1].1.clone())
3046 .unwrap()
3047 .replace("to: parent-placeholder", "to: other--real-entity");
3048 files[1].1 = patched.into_bytes();
3049 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3050 .expect_err("mem-prefixed exemplar target must refuse");
3051 assert!(
3052 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3053 if message.contains("bare") && message.contains("'sample'")),
3054 "got {err:?}"
3055 );
3056 }
3057
3058 #[test]
3062 fn install_gate_refuses_manifest_identity_mismatch() {
3063 let err = Engine::validate_schema_package(
3064 "gate",
3065 "1.0.0",
3066 &schema_package_files("Body", "other"),
3067 )
3068 .expect_err("identity mismatch must refuse install");
3069 assert!(
3070 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3071 if message.contains("other@1.0.0")),
3072 "got {err:?}"
3073 );
3074 }
3075
3076 #[test]
3077 fn reload_each_writable_mem_repopulates_load_warnings() {
3078 let tmp = TempDir::new().unwrap();
3082 let mem_dir = tmp.path().to_path_buf();
3083 let writer = FilesystemMemWriter::new(mem_dir.clone());
3084 let mut mount = folder_mount("specs", mem_dir.clone());
3087 mount.schema = Some("default@1.3.0".parse().unwrap());
3088 let mut engine =
3089 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3090 assert!(
3093 engine
3094 .load_warnings()
3095 .iter()
3096 .all(|w| w.code() == "MOUNT_UNBACKED"),
3097 "clean boot has no warnings beyond the empty-mount one: {:?}",
3098 engine.load_warnings()
3099 );
3100
3101 let body =
3103 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3104 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3105
3106 engine.reload_each_writable_mem().unwrap();
3107 let warnings = engine.load_warnings();
3108 assert!(
3109 warnings
3110 .iter()
3111 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3112 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3113 );
3114 }
3115
3116 #[test]
3123 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3124 let tmp = TempDir::new().unwrap();
3125 let mem_dir = tmp.path().to_path_buf();
3126 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3128 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3129 let writer = FilesystemMemWriter::new(mem_dir.clone());
3130 let mut engine = Engine::from_mounts(vec![(
3131 folder_mount("specs", mem_dir.clone()),
3132 Box::new(writer) as Box<dyn MemBackend>,
3133 )])
3134 .unwrap();
3135 assert!(
3137 !engine
3138 .load_warnings()
3139 .iter()
3140 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3141 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3142 engine.load_warnings()
3143 );
3144
3145 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";
3147 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3148
3149 engine.reload_each_writable_mem().unwrap();
3150
3151 let invalid: Vec<_> = engine
3152 .load_warnings()
3153 .iter()
3154 .filter_map(|w| match w {
3155 WarningHint::ParsedRelationInvalid {
3156 rel_type,
3157 reason,
3158 origin,
3159 ..
3160 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3161 _ => None,
3162 })
3163 .collect();
3164 assert_eq!(
3165 invalid.len(),
3166 1,
3167 "reload must surface the parse-time drift, got: {invalid:?}",
3168 );
3169 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3170 assert_eq!(invalid[0].1, "unknown_rel_type");
3171 assert_eq!(invalid[0].2, "writable");
3172 }
3173
3174 #[test]
3175 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3176 let tmp = TempDir::new().unwrap();
3183 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3184 let a_dir = tmp.path().join("a");
3185 std::fs::create_dir_all(&a_dir).unwrap();
3186 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3187 let b_dir = tmp.path().join("b");
3188 std::fs::create_dir_all(&b_dir).unwrap();
3189 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3190 let mut engine = Engine::from_mounts(vec![
3191 (
3192 folder_mount("alpha", a_dir.clone()),
3193 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3194 ),
3195 (
3196 folder_mount("beta", b_dir.clone()),
3197 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3198 ),
3199 ])
3200 .unwrap();
3201 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3202 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3203 assert!(
3204 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3205 "boot must populate one warning per mem: {pre:?}"
3206 );
3207
3208 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3210 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3211 engine.reload_one_mem("alpha").unwrap();
3212
3213 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3214 assert!(
3215 !post.contains(&"alpha".to_string()),
3216 "reload must drop the healed mem's stale warning: {post:?}"
3217 );
3218 assert!(
3219 post.contains(&"beta".to_string()),
3220 "reload of alpha must not clear beta's slice: {post:?}"
3221 );
3222 }
3223
3224 #[test]
3225 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3226 let tmp = TempDir::new().unwrap();
3231 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3232 let a_dir = tmp.path().join("a");
3233 std::fs::create_dir_all(&a_dir).unwrap();
3234 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3235 let b_dir = tmp.path().join("b");
3236 std::fs::create_dir_all(&b_dir).unwrap();
3237 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3238 let mut engine = Engine::from_mounts(vec![
3239 (
3240 folder_mount("alpha", a_dir.clone()),
3241 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3242 ),
3243 (
3244 folder_mount("beta", b_dir.clone()),
3245 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3246 ),
3247 ])
3248 .unwrap();
3249 assert!(
3250 engine
3251 .load_warnings()
3252 .iter()
3253 .any(|w| w.source_mem() == Some("alpha")),
3254 "boot must carry alpha-sourced warnings"
3255 );
3256
3257 engine.unregister_writable_mem("alpha").unwrap();
3258
3259 let post = engine.load_warnings();
3260 assert!(
3261 !post.iter().any(|w| w.source_mem() == Some("alpha")),
3262 "delete must purge the removed mem's warnings: {post:?}"
3263 );
3264 assert!(
3265 post.iter().any(|w| w.source_mem() == Some("beta")),
3266 "delete of alpha must keep beta's warnings: {post:?}"
3267 );
3268 }
3269
3270 #[test]
3271 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3272 let tmp = TempDir::new().unwrap();
3278 let a_dir = tmp.path().join("a");
3279 std::fs::create_dir_all(&a_dir).unwrap();
3280 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";
3281 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3282 let b_dir = tmp.path().join("b");
3283 std::fs::create_dir_all(&b_dir).unwrap();
3284 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3285 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3286 let mut engine = Engine::from_mounts(vec![
3287 (
3288 folder_mount("alpha", a_dir.clone()),
3289 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3290 ),
3291 (
3292 folder_mount("beta", b_dir.clone()),
3293 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3294 ),
3295 ])
3296 .unwrap();
3297 let alpha_sourced = |engine: &Engine| {
3298 engine
3299 .load_warnings()
3300 .iter()
3301 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3302 };
3303 assert!(
3304 alpha_sourced(&engine),
3305 "boot must flag alpha's invalid row: {:?}",
3306 engine.load_warnings()
3307 );
3308
3309 engine.unregister_writable_mem("beta").unwrap();
3310
3311 assert!(
3312 alpha_sourced(&engine),
3313 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3314 engine.load_warnings()
3315 );
3316 }
3317
3318 #[test]
3326 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3327 let tmp = TempDir::new().unwrap();
3328 let mem_dir = tmp.path().to_path_buf();
3329 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3330 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3331 let writer = FilesystemMemWriter::new(mem_dir.clone());
3332 let mut engine = Engine::from_mounts(vec![(
3333 folder_mount("specs", mem_dir.clone()),
3334 Box::new(writer) as Box<dyn MemBackend>,
3335 )])
3336 .unwrap();
3337 assert!(
3338 !engine.load_warnings().is_empty(),
3339 "boot must populate load_warnings"
3340 );
3341
3342 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3345 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3346 engine.reload_each_writable_mem_reports().unwrap();
3347 assert!(
3348 engine.load_warnings().is_empty(),
3349 "reports sweep must drop healed warnings: {:?}",
3350 engine.load_warnings()
3351 );
3352
3353 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3355 engine.reload_each_writable_mem_reports().unwrap();
3356 assert!(
3357 engine
3358 .load_warnings()
3359 .iter()
3360 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3361 "reports sweep must surface fresh drift: {:?}",
3362 engine.load_warnings()
3363 );
3364 }
3365
3366 #[test]
3374 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3375 let tmp = TempDir::new().unwrap();
3376 let a_dir = tmp.path().join("a");
3377 let b_dir = tmp.path().join("b");
3378 std::fs::create_dir_all(&a_dir).unwrap();
3379 std::fs::create_dir_all(&b_dir).unwrap();
3380 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3381 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3382 let mut engine = Engine::from_mounts(vec![
3383 (
3384 folder_mount("specs", a_dir),
3385 Box::new(a_writer) as Box<dyn MemBackend>,
3386 ),
3387 (
3388 folder_mount("memos", b_dir),
3389 Box::new(b_writer) as Box<dyn MemBackend>,
3390 ),
3391 ])
3392 .unwrap();
3393
3394 let mut settings = crate::workspace::WorkspaceSettings::default();
3396 settings.cross_mem_links.insert(
3397 "specs".to_string(),
3398 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3399 );
3400 engine.set_settings(settings);
3401
3402 let (actor, client) = cli_actor();
3403 let source = engine
3404 .create_entity(
3405 empty_create_args("specs", "Source"),
3406 actor,
3407 Some(&client),
3408 None,
3409 )
3410 .unwrap();
3411 let target = engine
3412 .create_entity(
3413 empty_create_args("memos", "Target"),
3414 actor,
3415 Some(&client),
3416 None,
3417 )
3418 .unwrap();
3419 engine
3420 .relate_entity(
3421 crate::engine::RelateEntityArgs {
3422 source: source.id.clone(),
3423 expected_hash: Some(source.content_hash.clone()),
3424 rel_type: "USES".to_string(),
3425 target: target.id.clone(),
3426 remove: false,
3427 description: None,
3428 dry_run: false,
3429 },
3430 actor,
3431 Some(&client),
3432 None,
3433 )
3434 .unwrap();
3435
3436 let has_edge = |e: &Engine| {
3438 let out = e
3439 .store()
3440 .outgoing(&source.id)
3441 .iter()
3442 .any(|edge| edge.target == target.id);
3443 let inc = e
3444 .store()
3445 .incoming(&target.id)
3446 .iter()
3447 .any(|edge| edge.from == source.id);
3448 (out, inc)
3449 };
3450
3451 assert_eq!(
3452 has_edge(&engine),
3453 (true, true),
3454 "edge must be indexed in both directions after relate",
3455 );
3456
3457 engine.reload_one_mem("memos").unwrap();
3459 assert_eq!(
3460 has_edge(&engine),
3461 (true, true),
3462 "cross-mem edge into B must survive a per-mem reload of B",
3463 );
3464
3465 engine.reload_each_writable_mem().unwrap();
3468 assert_eq!(
3469 has_edge(&engine),
3470 (true, true),
3471 "per-mem and workspace reload converge on the same edge",
3472 );
3473
3474 assert!(
3477 engine
3478 .store()
3479 .get(&source.id)
3480 .unwrap()
3481 .relationships
3482 .iter()
3483 .any(|r| r.target == target.id),
3484 "source record must retain the relationship throughout",
3485 );
3486 }
3487
3488 #[test]
3494 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3495 let tmp = TempDir::new().unwrap();
3496 let a_dir = tmp.path().join("a");
3497 let b_dir = tmp.path().join("b");
3498 std::fs::create_dir_all(&a_dir).unwrap();
3499 std::fs::create_dir_all(&b_dir).unwrap();
3500 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3501 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3502 let mut engine = Engine::from_mounts(vec![
3503 (
3504 folder_mount("specs", a_dir),
3505 Box::new(a_writer) as Box<dyn MemBackend>,
3506 ),
3507 (
3508 folder_mount("memos", b_dir),
3509 Box::new(b_writer) as Box<dyn MemBackend>,
3510 ),
3511 ])
3512 .unwrap();
3513 let mut settings = crate::workspace::WorkspaceSettings::default();
3514 settings.cross_mem_links.insert(
3515 "specs".to_string(),
3516 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3517 );
3518 engine.set_settings(settings);
3519
3520 let (actor, client) = cli_actor();
3521 let source = engine
3522 .create_entity(
3523 empty_create_args("specs", "Source"),
3524 actor,
3525 Some(&client),
3526 None,
3527 )
3528 .unwrap();
3529 let target = engine
3530 .create_entity(
3531 empty_create_args("memos", "Target"),
3532 actor,
3533 Some(&client),
3534 None,
3535 )
3536 .unwrap();
3537 engine
3538 .relate_entity(
3539 crate::engine::RelateEntityArgs {
3540 source: source.id.clone(),
3541 expected_hash: Some(source.content_hash.clone()),
3542 rel_type: "USES".to_string(),
3543 target: target.id.clone(),
3544 remove: false,
3545 description: None,
3546 dry_run: false,
3547 },
3548 actor,
3549 Some(&client),
3550 None,
3551 )
3552 .unwrap();
3553
3554 engine.reload_one_mem("specs").unwrap();
3555
3556 let out = engine
3557 .store()
3558 .outgoing(&source.id)
3559 .iter()
3560 .any(|edge| edge.target == target.id);
3561 let inc = engine
3562 .store()
3563 .incoming(&target.id)
3564 .iter()
3565 .any(|edge| edge.from == source.id);
3566 assert!(
3567 out && inc,
3568 "outgoing cross-mem edge must survive a source-mem reload"
3569 );
3570 }
3571
3572 #[test]
3573 fn workspace_root_setter_round_trips() {
3574 let tmp = TempDir::new().unwrap();
3575 let mem_dir = tmp.path().to_path_buf();
3576 let writer = FilesystemMemWriter::new(mem_dir.clone());
3577 let mut engine = Engine::from_mounts(vec![(
3578 folder_mount("specs", mem_dir),
3579 Box::new(writer) as Box<dyn MemBackend>,
3580 )])
3581 .unwrap();
3582 let root = tmp.path().to_path_buf();
3583 engine.set_workspace_root(root.clone());
3584 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3585 }
3586
3587 #[test]
3588 fn export_mem_folder_backend_produces_archive() {
3589 let tmp = TempDir::new().unwrap();
3593 let mem_dir = tmp.path().join("specs");
3594 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3595 let config_body = r#"{
3596 "format": 1,
3597 "schema": "default@1.0.0",
3598 "version": "1.0.0"
3599 }"#;
3600 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3601
3602 let writer = FilesystemMemWriter::new(mem_dir.clone());
3603 let engine = Engine::from_mounts(vec![(
3604 folder_mount("specs", mem_dir.clone()),
3605 Box::new(writer) as Box<dyn MemBackend>,
3606 )])
3607 .unwrap();
3608
3609 let archive_path = tmp.path().join("specs.mem");
3610 let result = engine.export_mem("specs", &archive_path).unwrap();
3611 assert!(archive_path.exists(), "archive must exist on disk");
3612 assert!(result.size_bytes > 0);
3613 assert_eq!(result.entity_count, 0);
3616 }
3617
3618 #[test]
3619 fn export_mem_unknown_mem_returns_unknown_mem() {
3620 let tmp = TempDir::new().unwrap();
3621 let mem_dir = tmp.path().to_path_buf();
3622 let writer = FilesystemMemWriter::new(mem_dir.clone());
3623 let engine = Engine::from_mounts(vec![(
3624 folder_mount("specs", mem_dir),
3625 Box::new(writer) as Box<dyn MemBackend>,
3626 )])
3627 .unwrap();
3628 let output = tmp.path().join("out.mem");
3629 let err = engine.export_mem("missing", &output).unwrap_err();
3630 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3631 }
3632
3633 #[test]
3634 fn export_mem_missing_config_returns_invalid_input() {
3635 let tmp = TempDir::new().unwrap();
3639 let mem_dir = tmp.path().to_path_buf();
3640 let writer = FilesystemMemWriter::new(mem_dir.clone());
3641 let engine = Engine::from_mounts(vec![(
3642 folder_mount("specs", mem_dir),
3643 Box::new(writer) as Box<dyn MemBackend>,
3644 )])
3645 .unwrap();
3646 let output = tmp.path().join("out.mem");
3647 let err = engine.export_mem("specs", &output).unwrap_err();
3648 assert!(matches!(err, EngineError::InvalidInput(_)));
3649 }
3650
3651 #[test]
3652 fn export_mem_archive_backend_returns_sealed() {
3653 let tmp = TempDir::new().unwrap();
3656 let archive_path = build_archive(
3657 tmp.path(),
3658 "ext",
3659 &[(
3660 ".memstead/config.json",
3661 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3662 )],
3663 );
3664 let engine = Engine::from_mounts(vec![(
3665 archive_mount("ext", archive_path.clone()),
3666 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3667 )])
3668 .unwrap();
3669 let output = tmp.path().join("out.mem");
3670 let err = engine.export_mem("ext", &output).unwrap_err();
3671 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3672 }
3673
3674 #[test]
3675 fn export_markdown_writes_unchanged_files_zero_writes() {
3676 let tmp = TempDir::new().unwrap();
3681 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3682 let result = engine.export_markdown(None, None).unwrap();
3683 assert_eq!(
3684 result.written, 0,
3685 "freshly-created entity's file already matches generated markdown"
3686 );
3687 assert_eq!(
3688 result.unchanged, 1,
3689 "the one seeded entity counts as unchanged"
3690 );
3691 assert!(
3692 result.skipped_mounts.is_empty(),
3693 "folder-only workspace has no skipped mounts"
3694 );
3695 }
3696
3697 #[test]
3698 fn export_markdown_skips_non_folder_mounts() {
3699 let tmp = TempDir::new().unwrap();
3703 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3704 let engine = Engine::from_mounts(vec![(
3705 archive_mount("ext", archive_path.clone()),
3706 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3707 )])
3708 .unwrap();
3709 let result = engine.export_markdown(None, None).unwrap();
3710 assert_eq!(result.written, 0);
3711 assert_eq!(result.unchanged, 0);
3712 assert_eq!(
3713 result.skipped_mounts.len(),
3714 1,
3715 "archive mount is in the skipped list"
3716 );
3717 let entry = &result.skipped_mounts[0];
3718 assert_eq!(entry.mem, "ext");
3719 assert_eq!(entry.active_backend, "archive");
3720 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
3721 }
3722
3723 #[test]
3724 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
3725 let tmp = TempDir::new().unwrap();
3729 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3730 let engine = Engine::from_mounts(vec![(
3731 archive_mount("ext", archive_path.clone()),
3732 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3733 )])
3734 .unwrap();
3735 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
3736 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
3737 let details = err.details();
3738 assert_eq!(details["mem"], "ext");
3739 assert_eq!(details["active_backend"], "archive");
3740 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
3741 }
3742
3743 #[test]
3744 fn register_writable_mem_adds_mount_and_router_entry() {
3745 let tmp = TempDir::new().unwrap();
3748 let mem_a = tmp.path().join("a");
3749 std::fs::create_dir_all(&mem_a).unwrap();
3750 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3751
3752 let mut engine = Engine::from_mounts(vec![(
3753 folder_mount("alpha", mem_a),
3754 Box::new(writer_a) as Box<dyn MemBackend>,
3755 )])
3756 .unwrap();
3757 assert!(engine.mem_router().is_writable("alpha"));
3758
3759 let mem_b = tmp.path().join("b");
3760 std::fs::create_dir_all(&mem_b).unwrap();
3761 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3762
3763 engine
3764 .register_writable_mem(
3765 folder_mount("beta", mem_b.clone()),
3766 Box::new(writer_b) as Box<dyn MemBackend>,
3767 MemOrigin::ExplicitToml,
3768 )
3769 .unwrap();
3770
3771 assert!(engine.mem_router().is_writable("alpha"));
3773 assert!(engine.mem_router().is_writable("beta"));
3774 assert!(engine.mem_router().is_visible("beta"));
3775
3776 assert!(engine.mount("beta").is_some());
3778 assert!(engine.schemas().contains_key("beta"));
3779
3780 assert_eq!(
3782 engine.mem_router().dir_for_mem("beta"),
3783 Some(mem_b.as_path()),
3784 );
3785 }
3786
3787 #[test]
3793 fn register_writable_mem_resolves_schema_from_mem_config() {
3794 let tmp = TempDir::new().unwrap();
3795 let mem_a = tmp.path().join("a");
3796 std::fs::create_dir_all(&mem_a).unwrap();
3797 let mut engine = Engine::from_mounts(vec![(
3798 folder_mount("alpha", mem_a.clone()),
3799 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
3800 )])
3801 .unwrap();
3802
3803 let mem_b = tmp.path().join("b");
3804 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
3805 std::fs::write(
3806 mem_b.join(".memstead").join("config.json"),
3807 r#"{"schema":"software@0.1.0"}"#,
3808 )
3809 .unwrap();
3810 let mount_b = crate::workspace::Mount {
3811 mem: "beta".to_string(),
3812 schema: Some(memstead_schema::SchemaRef::new(
3813 "totally-not-a-schema",
3814 semver::Version::new(9, 9, 9),
3815 )),
3816 storage: crate::workspace::MountStorage::Folder {
3817 path: mem_b.clone(),
3818 },
3819 capability: crate::workspace::MountCapability::Write,
3820 lifecycle: crate::workspace::MountLifecycle::Eager,
3821 cross_linkable: true,
3822 migration_target: None,
3823 };
3824 engine
3825 .register_writable_mem(
3826 mount_b,
3827 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
3828 MemOrigin::ExplicitToml,
3829 )
3830 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
3831
3832 assert!(engine.schemas().contains_key("beta"));
3833 let surfaced = engine.load_warnings().iter().any(|w| {
3834 matches!(
3835 w,
3836 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
3837 if mem == "beta"
3838 && config_pin == "software@0.1.0"
3839 && mount_pin == "totally-not-a-schema@9.9.9"
3840 )
3841 });
3842 assert!(
3843 surfaced,
3844 "SchemaPinMismatch must surface for beta: {:?}",
3845 engine.load_warnings(),
3846 );
3847 }
3848
3849 #[test]
3850 fn register_writable_mem_rejects_existing_name() {
3851 let tmp = TempDir::new().unwrap();
3854 let mem_a = tmp.path().join("a");
3855 std::fs::create_dir_all(&mem_a).unwrap();
3856 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3857
3858 let mut engine = Engine::from_mounts(vec![(
3859 folder_mount("alpha", mem_a),
3860 Box::new(writer_a) as Box<dyn MemBackend>,
3861 )])
3862 .unwrap();
3863 let mount_count_pre = engine.mounts().len();
3864
3865 let mem_collide = tmp.path().join("alpha-2");
3866 std::fs::create_dir_all(&mem_collide).unwrap();
3867 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
3868
3869 let err = engine
3870 .register_writable_mem(
3871 folder_mount("alpha", mem_collide),
3872 Box::new(writer_collide) as Box<dyn MemBackend>,
3873 MemOrigin::ExplicitToml,
3874 )
3875 .unwrap_err();
3876 match err {
3877 EngineError::MemNameCollision {
3878 name,
3879 source_origin,
3880 } => {
3881 assert_eq!(name, "alpha");
3882 assert!(
3887 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
3888 );
3889 }
3890 other => panic!("expected MemNameCollision, got {other:?}"),
3891 }
3892
3893 assert_eq!(engine.mounts().len(), mount_count_pre);
3895 }
3896
3897 #[test]
3898 fn register_writable_mem_loads_entities_into_store() {
3899 let tmp = TempDir::new().unwrap();
3902 let mem_a = tmp.path().join("a");
3903 std::fs::create_dir_all(&mem_a).unwrap();
3904 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3905
3906 let mut engine = Engine::from_mounts(vec![(
3907 folder_mount("alpha", mem_a),
3908 Box::new(writer_a) as Box<dyn MemBackend>,
3909 )])
3910 .unwrap();
3911 let pre_count = engine.store().all_entities().count();
3912
3913 let mem_b = tmp.path().join("b");
3915 std::fs::create_dir_all(&mem_b).unwrap();
3916 std::fs::write(
3917 mem_b.join("b1.md"),
3918 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
3919 )
3920 .unwrap();
3921 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3922
3923 engine
3924 .register_writable_mem(
3925 folder_mount("beta", mem_b),
3926 Box::new(writer_b) as Box<dyn MemBackend>,
3927 MemOrigin::ExplicitToml,
3928 )
3929 .unwrap();
3930
3931 let post_count = engine.store().all_entities().count();
3932 assert!(post_count > pre_count, "register must load entities");
3933 let beta_count = engine
3934 .store()
3935 .all_entities()
3936 .filter(|e| e.mem == "beta")
3937 .count();
3938 assert_eq!(beta_count, 1);
3939 }
3940
3941 #[test]
3942 fn register_then_unregister_round_trips() {
3943 let tmp = TempDir::new().unwrap();
3947 let mem_a = tmp.path().join("a");
3948 std::fs::create_dir_all(&mem_a).unwrap();
3949 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3950
3951 let mut engine = Engine::from_mounts(vec![(
3952 folder_mount("alpha", mem_a),
3953 Box::new(writer_a) as Box<dyn MemBackend>,
3954 )])
3955 .unwrap();
3956 let pre_mounts = engine.mounts().len();
3957
3958 let mem_b = tmp.path().join("b");
3959 std::fs::create_dir_all(&mem_b).unwrap();
3960 let writer_b = FilesystemMemWriter::new(mem_b);
3961
3962 engine
3963 .register_writable_mem(
3964 folder_mount("beta", tmp.path().join("b")),
3965 Box::new(writer_b) as Box<dyn MemBackend>,
3966 MemOrigin::ExplicitToml,
3967 )
3968 .unwrap();
3969 assert_eq!(engine.mounts().len(), pre_mounts + 1);
3970
3971 let removed = engine.unregister_writable_mem("beta").unwrap();
3972 assert!(removed.is_some());
3973 assert_eq!(engine.mounts().len(), pre_mounts);
3974 assert!(!engine.mem_router().is_writable("beta"));
3975 }
3976
3977 #[test]
3978 fn unregister_writable_mem_returns_false_for_unknown_name() {
3979 let tmp = TempDir::new().unwrap();
3983 let mem_dir = tmp.path().to_path_buf();
3984 let writer = FilesystemMemWriter::new(mem_dir.clone());
3985 let mut engine = Engine::from_mounts(vec![(
3986 folder_mount("specs", mem_dir),
3987 Box::new(writer) as Box<dyn MemBackend>,
3988 )])
3989 .unwrap();
3990 let removed = engine.unregister_writable_mem("missing").unwrap();
3991 assert!(removed.is_none(), "unknown mem returns Ok(None)");
3992 assert!(engine.mem_router().is_writable("specs"));
3994 }
3995
3996 #[test]
3997 fn unregister_writable_mem_drops_mount_and_router_entry() {
3998 let tmp = TempDir::new().unwrap();
4003 let mem_a = tmp.path().join("a");
4004 std::fs::create_dir_all(&mem_a).unwrap();
4005 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4006 let mem_b = tmp.path().join("b");
4007 std::fs::create_dir_all(&mem_b).unwrap();
4008 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4009
4010 let mut engine = Engine::from_mounts(vec![
4011 (
4012 folder_mount("alpha", mem_a),
4013 Box::new(writer_a) as Box<dyn MemBackend>,
4014 ),
4015 (
4016 folder_mount("beta", mem_b),
4017 Box::new(writer_b) as Box<dyn MemBackend>,
4018 ),
4019 ])
4020 .unwrap();
4021
4022 let removed = engine.unregister_writable_mem("alpha").unwrap();
4023 assert!(removed.is_some());
4024
4025 assert!(!engine.mem_router().is_writable("alpha"));
4027 assert!(!engine.mem_router().is_visible("alpha"));
4028 assert!(engine.mount("alpha").is_none());
4029
4030 assert!(engine.mem_router().is_writable("beta"));
4032 assert!(engine.mount("beta").is_some());
4033 }
4034
4035 #[test]
4036 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
4037 let tmp = TempDir::new().unwrap();
4041 let mem_a = tmp.path().join("a");
4042 std::fs::create_dir_all(&mem_a).unwrap();
4043 std::fs::write(
4044 mem_a.join("a1.md"),
4045 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
4046 )
4047 .unwrap();
4048 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4049
4050 let mem_b = tmp.path().join("b");
4051 std::fs::create_dir_all(&mem_b).unwrap();
4052 std::fs::write(
4053 mem_b.join("b1.md"),
4054 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4055 )
4056 .unwrap();
4057 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4058
4059 let mut engine = Engine::from_mounts(vec![
4060 (
4061 folder_mount("alpha", mem_a),
4062 Box::new(writer_a) as Box<dyn MemBackend>,
4063 ),
4064 (
4065 folder_mount("beta", mem_b),
4066 Box::new(writer_b) as Box<dyn MemBackend>,
4067 ),
4068 ])
4069 .unwrap();
4070
4071 let pre_total = engine.store().all_entities().count();
4072 assert!(pre_total >= 2, "both mems must load entities");
4073
4074 engine.unregister_writable_mem("alpha").unwrap();
4075
4076 let alpha_remaining = engine
4078 .store()
4079 .all_entities()
4080 .filter(|e| e.mem == "alpha")
4081 .count();
4082 assert_eq!(alpha_remaining, 0);
4083
4084 let beta_remaining = engine
4086 .store()
4087 .all_entities()
4088 .filter(|e| e.mem == "beta")
4089 .count();
4090 assert!(beta_remaining > 0, "beta entities must survive");
4091 }
4092 #[test]
4093 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4094 let tmp = TempDir::new().unwrap();
4095 let mut engine = build_demo_engine(&tmp);
4096 let result = engine
4097 .reload_one_mem("specs")
4098 .expect("reload on stable disk must succeed");
4099 assert!(result.added.is_empty(), "added: {:?}", result.added);
4100 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4101 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4102 }
4103
4104 #[test]
4105 fn reload_one_mem_picks_up_external_addition() {
4106 let tmp = TempDir::new().unwrap();
4107 let mut engine = build_demo_engine(&tmp);
4108 std::fs::write(
4111 tmp.path().join("external.md"),
4112 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4113 )
4114 .unwrap();
4115 let result = engine.reload_one_mem("specs").unwrap();
4116 assert_eq!(
4117 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4118 vec!["specs--external"]
4119 );
4120 assert!(result.changed.is_empty());
4121 assert!(result.removed.is_empty());
4122 assert!(
4124 engine
4125 .get_entity(&crate::EntityId::new("specs", "external"))
4126 .is_some()
4127 );
4128 }
4129
4130 #[test]
4131 fn reload_one_mem_picks_up_external_removal() {
4132 let tmp = TempDir::new().unwrap();
4133 let mut engine = build_demo_engine(&tmp);
4134 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4137 let result = engine.reload_one_mem("specs").unwrap();
4138 assert!(result.added.is_empty());
4139 assert!(result.changed.is_empty());
4140 assert_eq!(
4141 result
4142 .removed
4143 .iter()
4144 .map(|i| i.as_ref())
4145 .collect::<Vec<_>>(),
4146 vec!["specs--lonely-three"]
4147 );
4148 }
4149
4150 #[test]
4151 fn reload_one_mem_picks_up_external_change() {
4152 let tmp = TempDir::new().unwrap();
4153 let mut engine = build_demo_engine(&tmp);
4154 std::fs::write(
4157 tmp.path().join("source-one.md"),
4158 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4159 )
4160 .unwrap();
4161 let result = engine.reload_one_mem("specs").unwrap();
4162 assert!(result.added.is_empty());
4163 assert_eq!(
4164 result
4165 .changed
4166 .iter()
4167 .map(|i| i.as_ref())
4168 .collect::<Vec<_>>(),
4169 vec!["specs--source-one"]
4170 );
4171 assert!(result.removed.is_empty());
4172 }
4173
4174 #[test]
4175 fn reload_one_mem_rejects_unknown_mem() {
4176 let tmp = TempDir::new().unwrap();
4177 let mut engine = build_demo_engine(&tmp);
4178 let err = engine.reload_one_mem("nope").unwrap_err();
4179 match err {
4180 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4181 other => panic!("expected UnknownMem, got {other:?}"),
4182 }
4183 }
4184
4185 #[test]
4186 fn reload_each_writable_mem_returns_one_entry_per_mount() {
4187 let tmp = TempDir::new().unwrap();
4188 let mut engine = build_demo_engine(&tmp);
4189 let reports = engine
4190 .reload_each_writable_mem()
4191 .expect("batch reload on stable disk must succeed");
4192 assert_eq!(reports.len(), 1);
4193 assert_eq!(reports[0].0, "specs");
4194 assert!(reports[0].1.added.is_empty());
4195 assert!(reports[0].1.changed.is_empty());
4196 assert!(reports[0].1.removed.is_empty());
4197 }
4198
4199 #[test]
4202 fn settings_default_to_empty_on_fresh_engine() {
4203 let tmp = TempDir::new().unwrap();
4204 let engine = build_demo_engine(&tmp);
4205 let s = engine.settings();
4206 assert!(s.mem_create_rules.is_empty());
4207 assert!(s.mem_delete_rules.is_empty());
4208 assert!(s.cross_mem_links.is_empty());
4209 }
4210
4211 #[test]
4212 fn set_settings_replaces_workspace_policy() {
4213 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4214 let tmp = TempDir::new().unwrap();
4215 let mut engine = build_demo_engine(&tmp);
4216 let mut settings = WorkspaceSettings::default();
4217 settings.mem_create_rules.push(CreateRuleSetting {
4218 pattern: "exec-*".to_string(),
4219 schemas: vec!["default@1.0.0".to_string()],
4220 default_cross_links: None,
4221 });
4222 settings.mem_delete_rules.push(DeleteRuleSetting {
4223 pattern: "exec-*".to_string(),
4224 });
4225 engine.set_settings(settings);
4226 assert_eq!(engine.settings().mem_create_rules.len(), 1);
4227 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4228 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4229 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4230 }
4231
4232 #[test]
4235 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4236 let tmp = TempDir::new().unwrap();
4237 let mut engine = build_demo_engine(&tmp);
4238 std::fs::write(
4240 tmp.path().join("new-via-disk.md"),
4241 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4242 )
4243 .unwrap();
4244 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4245 std::fs::write(
4246 tmp.path().join("source-one.md"),
4247 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4248 )
4249 .unwrap();
4250
4251 let reports = engine.reload_each_writable_mem().unwrap();
4252 assert_eq!(reports.len(), 1);
4253 let (mem, result) = &reports[0];
4254 assert_eq!(mem, "specs");
4255 assert_eq!(
4256 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4257 vec!["specs--new-via-disk"]
4258 );
4259 assert_eq!(
4260 result
4261 .removed
4262 .iter()
4263 .map(|i| i.as_ref())
4264 .collect::<Vec<_>>(),
4265 vec!["specs--lonely-three"]
4266 );
4267 assert_eq!(
4268 result
4269 .changed
4270 .iter()
4271 .map(|i| i.as_ref())
4272 .collect::<Vec<_>>(),
4273 vec!["specs--source-one"]
4274 );
4275 }
4276
4277 #[test]
4280 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4281 let tmp = TempDir::new().unwrap();
4289 let mut engine = build_demo_engine(&tmp);
4290 let report = engine.reload_one_mem_report("specs").unwrap();
4291 assert_eq!(report.mem, "specs");
4292 assert_eq!(
4293 report.head_before, report.head_after,
4294 "unchanged disk → stable cursor"
4295 );
4296 assert!(
4297 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4298 "folder heads carry the changelog-ts cursor, got {}",
4299 report.head_after
4300 );
4301 assert_eq!(report.entities_loaded, 3);
4304 assert!(report.changed_entity_ids.is_empty());
4306 }
4307
4308 #[test]
4309 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4310 let tmp = TempDir::new().unwrap();
4315 let mut engine = build_demo_engine(&tmp);
4316 std::fs::write(
4317 tmp.path().join("new-via-disk.md"),
4318 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4319 )
4320 .unwrap();
4321 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4322 std::fs::write(
4323 tmp.path().join("source-one.md"),
4324 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4325 )
4326 .unwrap();
4327
4328 let report = engine.reload_one_mem_report("specs").unwrap();
4329 assert_eq!(report.mem, "specs");
4330 let ids: Vec<&str> = report
4331 .changed_entity_ids
4332 .iter()
4333 .map(|id| id.as_ref())
4334 .collect();
4335 assert_eq!(
4337 ids,
4338 vec![
4339 "specs--lonely-three",
4340 "specs--new-via-disk",
4341 "specs--source-one",
4342 ]
4343 );
4344 }
4345
4346 #[test]
4347 fn reload_one_mem_report_rejects_unknown_mem() {
4348 let tmp = TempDir::new().unwrap();
4349 let mut engine = build_demo_engine(&tmp);
4350 let err = engine.reload_one_mem_report("missing").unwrap_err();
4351 assert!(matches!(err, EngineError::UnknownMem(_)));
4352 }
4353
4354 #[test]
4355 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4356 let tmp = TempDir::new().unwrap();
4357 let mut engine = build_demo_engine(&tmp);
4358 let reports = engine.reload_each_writable_mem_reports().unwrap();
4359 assert_eq!(reports.len(), 1);
4360 assert_eq!(reports[0].mem, "specs");
4361 assert_eq!(reports[0].entities_loaded, 3);
4362 }
4363
4364 #[test]
4371 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4372 let tmp = TempDir::new().unwrap();
4373
4374 let memstead_dir = tmp.path().join(".memstead");
4377 std::fs::create_dir_all(&memstead_dir).unwrap();
4378 let workspace_toml = memstead_dir.join("workspace.toml");
4379 std::fs::write(
4380 &workspace_toml,
4381 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4382 )
4383 .unwrap();
4384 let mounts_json = memstead_dir.join("state").join("mounts.json");
4385 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4386 let mem_dir = tmp.path().join("specs");
4387 std::fs::create_dir_all(&mem_dir).unwrap();
4388 let mounts_body = format!(
4389 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4390 mem_dir.display(),
4391 );
4392 std::fs::write(&mounts_json, mounts_body).unwrap();
4393
4394 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4395 assert!(
4396 engine.settings().mem_create_rules.is_empty(),
4397 "boot-time settings carry no create rules"
4398 );
4399
4400 std::fs::write(
4402 &workspace_toml,
4403 "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",
4404 )
4405 .unwrap();
4406
4407 engine.reload_each_writable_mem_reports().unwrap();
4408
4409 let rules = &engine.settings().mem_create_rules;
4410 assert_eq!(
4411 rules.len(),
4412 1,
4413 "workspace-wide reload must refresh the policy"
4414 );
4415 assert_eq!(rules[0].pattern, "exec-*");
4416 }
4417
4418 const MIG_TYPE_TAIL: &str = r#"sections:
4423 - key: body
4424 heading: Body
4425 required: true
4426 search_weight: 10.0
4427 catch_all: true
4428 write_rules: []
4429title_weight: 100.0
4430text_fields:
4431 - body
4432hierarchy_relationship: _default
4433no_self_loop_relationships: []
4434updatable_fields: []
4435health_required_fields: []
4436staleness_threshold_days: 90
4437write_rules: []
4438"#;
4439
4440 fn mig_manifest(name: &str, version: &str) -> String {
4445 format!(
4446 r#"name: {name}
4447version: {version}
4448description: migration test schema
4449when_to_use: tests
4450types:
4451 - doc
4452relationships:
4453 mode: strict
4454 definitions:
4455 - name: USES
4456 description: link
4457 default_weight: 1.0
4458 - name: _default
4459 description: fallback
4460 default_weight: 1.0
4461community:
4462 resolution: 1.0
4463 seed: 42
4464"#
4465 )
4466 }
4467
4468 fn mig_type_yaml(with_status: bool) -> String {
4469 let metadata = if with_status {
4470 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4471 } else {
4472 "metadata_fields: []\n"
4473 };
4474 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4475 }
4476
4477 fn write_mig_schema(
4478 root: &std::path::Path,
4479 dir: &str,
4480 name: &str,
4481 version: &str,
4482 with_status: bool,
4483 ) {
4484 let d = root.join(dir);
4485 std::fs::create_dir_all(d.join("types")).unwrap();
4486 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4487 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4488 }
4489
4490 #[test]
4507 fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4508 for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4509 run_mutation_sequence(seed);
4510 }
4511 }
4512
4513 struct Xorshift(u64);
4514 impl Xorshift {
4515 fn next(&mut self) -> u64 {
4516 let mut x = self.0;
4517 x ^= x << 13;
4518 x ^= x >> 7;
4519 x ^= x << 17;
4520 self.0 = x;
4521 x
4522 }
4523 fn pick(&mut self, n: usize) -> usize {
4524 (self.next() % n as u64) as usize
4525 }
4526 }
4527
4528 fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4529 let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4532 let live = engine.search_indexes();
4533 let mut live_mems: Vec<&String> = live.keys().collect();
4534 let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4535 live_mems.sort();
4536 fresh_mems.sort();
4537 assert_eq!(
4538 live_mems, fresh_mems,
4539 "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4540 );
4541 for (mem, idx) in live {
4542 let mut got = idx.stored_ids().unwrap();
4543 let mut want = fresh[mem].stored_ids().unwrap();
4544 got.sort();
4545 want.sort();
4546 assert_eq!(
4547 got, want,
4548 "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4549 );
4550 }
4551 let schema = engine
4554 .schemas
4555 .iter()
4556 .min_by(|a, b| a.0.cmp(b.0))
4557 .map(|(_, s)| s.clone())
4558 .expect("schema present");
4559 let weights_schema = schema.clone();
4560 let fresh_partition = crate::graph::community::detect_communities(
4561 engine.store(),
4562 schema.manifest.community.resolution,
4563 schema.manifest.community.seed,
4564 move |rel_type| {
4565 weights_schema
4566 .manifest
4567 .relationships
4568 .definitions
4569 .iter()
4570 .find(|d| d.name == rel_type)
4571 .map(|d| d.default_weight as f64)
4572 .unwrap_or(1.0)
4573 },
4574 );
4575 assert_eq!(
4576 engine.communities().entity_cluster_map,
4577 fresh_partition.entity_cluster_map,
4578 "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4579 );
4580 }
4581
4582 fn run_mutation_sequence(seed: u64) {
4583 use indexmap::IndexMap;
4584
4585 let (_tmp, mut engine) = migration_engine();
4586 let mut rng = Xorshift(seed);
4587 let mut live: Vec<crate::EntityId> = vec![
4588 crate::EntityId::new("specs", "one"),
4589 crate::EntityId::new("specs", "two"),
4590 ];
4591 let mut counter = 0usize;
4592 let mut kinds_hit: std::collections::HashSet<&'static str> =
4593 std::collections::HashSet::new();
4594
4595 const KINDS: [&str; 7] = [
4596 "create",
4597 "update",
4598 "relate",
4599 "delete",
4600 "rename",
4601 "batch_applied",
4602 "batch_refused",
4603 ];
4604
4605 let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4606 anchors: Vec::new(),
4607 anchors_unset: Vec::new(),
4608 id,
4609 expected_hash: None,
4610 sections: IndexMap::new(),
4611 append_sections: IndexMap::new(),
4612 patch_sections: IndexMap::new(),
4613 metadata: IndexMap::new(),
4614 metadata_unset: Vec::new(),
4615 declare_relations: Vec::new(),
4616 dry_run: false,
4617 relations_unset: Vec::new(),
4618 };
4619
4620 for op_i in 0..30usize {
4621 let kind = *KINDS
4624 .get(op_i)
4625 .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4626 match kind {
4627 "create" => {
4628 counter += 1;
4629 let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4630 args.entity_type = "doc".to_string();
4631 args.sections = IndexMap::from_iter([(
4632 "body".to_string(),
4633 format!("generated body {counter}"),
4634 )]);
4635 let out = engine
4636 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4637 .expect("generated create is conformant");
4638 live.push(out.id);
4639 kinds_hit.insert("create");
4640 }
4641 "update" => {
4642 let id = live[rng.pick(live.len())].clone();
4643 let mut args = bare_update(id);
4644 args.append_sections
4645 .insert("body".to_string(), format!("appended at op {op_i}"));
4646 engine
4647 .update_entity(args, crate::vcs::Actor::Cli, None, None)
4648 .expect("append update is conformant");
4649 kinds_hit.insert("update");
4650 }
4651 "relate" => {
4652 if live.len() >= 2 {
4653 let a = rng.pick(live.len());
4654 let mut b = rng.pick(live.len());
4655 if a == b {
4656 b = (b + 1) % live.len();
4657 }
4658 engine
4659 .relate_entity(
4660 crate::engine::RelateEntityArgs {
4661 source: live[a].clone(),
4662 expected_hash: None,
4663 rel_type: "USES".to_string(),
4664 target: live[b].clone(),
4665 remove: false,
4666 description: None,
4667 dry_run: false,
4668 },
4669 crate::vcs::Actor::Cli,
4670 None,
4671 None,
4672 )
4673 .expect("USES relate is legal under mig-a");
4674 kinds_hit.insert("relate");
4675 }
4676 }
4677 "delete" => {
4678 if live.len() > 2
4681 && let Some(pos) = (0..live.len()).find(|&i| {
4682 engine.store().incoming(&live[i]).is_empty()
4683 && engine
4684 .store()
4685 .get(&live[i])
4686 .is_some_and(|e| e.relationships.is_empty())
4687 })
4688 {
4689 let id = live.remove(pos);
4690 engine
4691 .delete_entity(
4692 crate::engine::DeleteEntityArgs {
4693 id: id.clone(),
4694 expected_hash: None,
4695 },
4696 crate::vcs::Actor::Cli,
4697 None,
4698 None,
4699 )
4700 .expect("reference-free delete lands");
4701 kinds_hit.insert("delete");
4702 }
4703 }
4704 "rename" => {
4705 counter += 1;
4706 let pos = rng.pick(live.len());
4707 let old = live[pos].clone();
4708 let out = engine
4709 .rename_entity(
4710 crate::engine::RenameEntityArgs {
4711 id: old,
4712 new_title: format!("Renamed {counter}"),
4713 expected_hash: None,
4714 },
4715 crate::vcs::Actor::Cli,
4716 None,
4717 None,
4718 )
4719 .expect("fresh-slug rename lands");
4720 live[pos] = out.new_id;
4721 kinds_hit.insert("rename");
4722 }
4723 "batch_applied" => {
4724 let id_a = live[rng.pick(live.len())].clone();
4725 let mut a = bare_update(id_a);
4726 a.append_sections
4727 .insert("body".to_string(), format!("batch line {op_i}"));
4728 let result = engine
4729 .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
4730 .expect("batch envelope");
4731 assert!(result.applied, "single-entry append batch applies");
4732 kinds_hit.insert("batch_applied");
4733 }
4734 "batch_refused" => {
4735 let id_a = live[rng.pick(live.len())].clone();
4736 let mut a = bare_update(id_a);
4737 a.append_sections
4738 .insert("body".to_string(), "doomed".to_string());
4739 let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
4740 let result = engine
4741 .batch_update(
4742 vec![(a, None), (missing, None)],
4743 crate::vcs::Actor::Cli,
4744 None,
4745 false,
4746 )
4747 .expect("refused batch returns a report-all envelope");
4748 assert!(!result.applied, "the missing target refuses the batch");
4749 kinds_hit.insert("batch_refused");
4750 }
4751 _ => unreachable!(),
4752 }
4753
4754 if op_i == 14 {
4755 engine
4759 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4760 .expect("integral switch");
4761 kinds_hit.insert("schema_switch");
4762 }
4763 if op_i == 19 {
4764 engine.reload_one_mem("specs").expect("reload lands");
4765 kinds_hit.insert("reload");
4766 }
4767
4768 if op_i % 10 == 9 {
4769 assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
4770 }
4771 }
4772
4773 assert_derived_oracles(&engine, seed, "sequence end");
4774
4775 for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
4776 assert!(
4777 kinds_hit.contains(kind),
4778 "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
4779 );
4780 }
4781 }
4782
4783 fn migration_engine() -> (tempfile::TempDir, Engine) {
4784 let tmp = tempfile::TempDir::new().unwrap();
4785 let schemas_dir = tmp.path().join("schemas");
4786 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4787 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4788 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
4789 let mem_dir = tmp.path().join("mem");
4790 std::fs::create_dir_all(&mem_dir).unwrap();
4791 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4792 let mut mount = folder_mount("specs", mem_dir);
4793 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4794 let mut engine = Engine::from_mounts_with_schemas_dir(
4795 vec![(
4796 mount,
4797 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4798 )],
4799 Some(&schemas_dir),
4800 )
4801 .unwrap();
4802 for title in ["One", "Two"] {
4803 let mut args = empty_create_args("specs", title);
4804 args.entity_type = "doc".to_string();
4805 args.sections =
4806 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
4807 engine
4808 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4809 .expect("conformant create under mig-a");
4810 }
4811 (tmp, engine)
4812 }
4813
4814 fn sref(s: &str) -> memstead_schema::SchemaRef {
4815 s.parse().unwrap()
4816 }
4817
4818 #[test]
4824 fn set_schema_repairs_a_mount_expectation_ahead_of_the_served_pin() {
4825 let (_tmp, mut engine) = migration_engine();
4826 let idx = engine
4829 .mounts
4830 .iter()
4831 .position(|m| m.mount.mem == "specs")
4832 .unwrap();
4833 engine.mounts[idx].mount.schema = Some(sref("mig-b@0.1.0"));
4834 assert_eq!(engine.schemas.get("specs").unwrap().id().0, "mig-a");
4835
4836 let out = engine
4837 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
4838 .unwrap();
4839 assert_eq!(
4843 out.outcome,
4844 crate::engine::SetSchemaResult::MigrationStarted,
4845 "a served pin behind the target enters the switch path, never a noop: {out:?}"
4846 );
4847 assert!(!out.findings.is_empty());
4848 assert_eq!(
4849 engine.schemas.get("specs").unwrap().id().0,
4850 "mig-b",
4851 "writes now validate against the target"
4852 );
4853
4854 let (_tmp2, mut clean) = migration_engine();
4857 let again = clean.set_mem_schema("specs", &sref("mig-a@0.1.0")).unwrap();
4858 assert_eq!(again.outcome, crate::engine::SetSchemaResult::Noop);
4859 }
4860
4861 #[test]
4862 fn set_schema_noop_on_current_pin() {
4863 let (_tmp, mut engine) = migration_engine();
4864 let out = engine
4865 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
4866 .unwrap();
4867 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
4868 assert_eq!(out.schema_pin, "mig-a@0.1.0");
4869 assert_eq!(out.migration_target, None);
4870 assert!(out.findings.is_empty());
4871 }
4872
4873 #[test]
4882 fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
4883 let (_tmp, mut engine) = migration_engine();
4884
4885 let _ = engine.communities();
4886 let _ = engine.search_indexes();
4887 assert!(engine.community_memo.get().is_some());
4888 assert!(engine.search_indexes_memo.get().is_some());
4889 let store_gen_before = engine.store().generation();
4890
4891 engine
4892 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4893 .unwrap();
4894
4895 assert_eq!(
4896 engine.store().generation(),
4897 store_gen_before,
4898 "a schema switch mutates no store content"
4899 );
4900 assert!(
4901 engine.community_memo.get().is_none(),
4902 "the community memo must clear on a schema switch (weights derive from the schema)"
4903 );
4904 assert!(
4905 engine.search_indexes_memo.get().is_none(),
4906 "the search memo must clear on a schema switch (the field set derives from the schema)"
4907 );
4908 }
4909
4910 #[test]
4911 fn set_schema_switches_immediately_when_integral() {
4912 let (_tmp, mut engine) = migration_engine();
4915 let out = engine
4916 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4917 .unwrap();
4918 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4919 assert_eq!(out.schema_pin, "mig-a@0.2.0");
4920 assert_eq!(out.migration_target, None);
4921 assert!(out.findings.is_empty());
4922 assert_eq!(
4923 engine.schema_pin("specs").unwrap().as_display(),
4924 "mig-a@0.2.0"
4925 );
4926 assert!(engine.migration_target("specs").is_none());
4927 }
4928
4929 #[test]
4935 fn set_schema_switch_persists_pin_into_backend_config() {
4936 let tmp = tempfile::TempDir::new().unwrap();
4937 let schemas_dir = tmp.path().join("schemas");
4938 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4939 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4940 let mem_dir = tmp.path().join("mem");
4941 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4942 std::fs::write(
4944 mem_dir.join(".memstead").join("config.json"),
4945 br#"{"schema":"mig-a@0.1.0"}"#,
4946 )
4947 .unwrap();
4948 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4949 let mut mount = folder_mount("specs", mem_dir.clone());
4950 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4951 let mut engine = Engine::from_mounts_with_schemas_dir(
4952 vec![(
4953 mount,
4954 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4955 )],
4956 Some(&schemas_dir),
4957 )
4958 .unwrap();
4959
4960 let out = engine
4961 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4962 .unwrap();
4963 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4964
4965 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
4968 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
4969 assert_eq!(
4970 cfg["schema"], "mig-a@0.2.0",
4971 "atomic switch must update the authoritative backend config"
4972 );
4973 }
4974
4975 #[test]
4976 fn set_schema_unknown_target_refuses_schema_not_found() {
4977 let (_tmp, mut engine) = migration_engine();
4978 let err = engine
4979 .set_mem_schema("specs", &sref("nope@9.9.9"))
4980 .unwrap_err();
4981 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
4982 assert!(engine.migration_target("specs").is_none());
4984 }
4985
4986 #[test]
4987 fn set_schema_migration_lifecycle_end_to_end() {
4988 let (_tmp, mut engine) = migration_engine();
4989 let target = sref("mig-b@0.1.0");
4990
4991 let out = engine.set_mem_schema("specs", &target).unwrap();
4993 assert_eq!(
4994 out.outcome,
4995 crate::engine::SetSchemaResult::MigrationStarted
4996 );
4997 assert_eq!(out.schema_pin, "mig-a@0.1.0");
4998 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
4999 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
5000 assert!(
5001 out.findings
5002 .iter()
5003 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
5004 );
5005
5006 let one = crate::entity::EntityId::new("specs", "one");
5008 assert!(engine.store().get(&one).is_some());
5009
5010 let out = engine.set_mem_schema("specs", &target).unwrap();
5012 assert_eq!(
5013 out.outcome,
5014 crate::engine::SetSchemaResult::MigrationPending
5015 );
5016 assert_eq!(out.findings.len(), 2);
5017
5018 let mut bad = crate::engine::UpdateEntityArgs {
5022 anchors: Vec::new(),
5023 id: one.clone(),
5024 expected_hash: None,
5025 sections: indexmap::IndexMap::new(),
5026 append_sections: indexmap::IndexMap::new(),
5027 patch_sections: indexmap::IndexMap::new(),
5028 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
5029 metadata_unset: Vec::new(),
5030 declare_relations: Vec::new(),
5031 dry_run: false,
5032 relations_unset: Vec::new(),
5033 anchors_unset: Vec::new(),
5034 };
5035 let err = engine
5036 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
5037 .unwrap_err();
5038 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
5039 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
5040 engine
5041 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
5042 .expect("repair write validated against the migration target");
5043
5044 let out = engine.set_mem_schema("specs", &target).unwrap();
5046 assert_eq!(
5047 out.outcome,
5048 crate::engine::SetSchemaResult::MigrationPending
5049 );
5050 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
5051
5052 let two = crate::entity::EntityId::new("specs", "two");
5054 let repair = crate::engine::UpdateEntityArgs {
5055 anchors: Vec::new(),
5056 id: two.clone(),
5057 expected_hash: None,
5058 sections: indexmap::IndexMap::new(),
5059 append_sections: indexmap::IndexMap::new(),
5060 patch_sections: indexmap::IndexMap::new(),
5061 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
5062 metadata_unset: Vec::new(),
5063 declare_relations: Vec::new(),
5064 dry_run: false,
5065 relations_unset: Vec::new(),
5066 anchors_unset: Vec::new(),
5067 };
5068 engine
5069 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
5070 .unwrap();
5071 let out = engine.set_mem_schema("specs", &target).unwrap();
5072 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5073 assert_eq!(out.schema_pin, "mig-b@0.1.0");
5074 assert_eq!(out.migration_target, None);
5075 assert!(out.findings.is_empty());
5076 assert_eq!(
5077 engine.schema_pin("specs").unwrap().as_display(),
5078 "mig-b@0.1.0"
5079 );
5080 assert!(engine.migration_target("specs").is_none());
5081 }
5082
5083 #[test]
5088 fn relations_unset_works_during_migration_without_mode_flag() {
5089 let (_tmp, mut engine) = migration_engine();
5090 let one = crate::entity::EntityId::new("specs", "one");
5091 let two = crate::entity::EntityId::new("specs", "two");
5092 engine
5093 .relate_entity(
5094 crate::engine::RelateEntityArgs {
5095 source: one.clone(),
5096 expected_hash: None,
5097 rel_type: "USES".to_string(),
5098 target: two.clone(),
5099 remove: false,
5100 description: None,
5101 dry_run: false,
5102 },
5103 crate::vcs::Actor::Cli,
5104 None,
5105 None,
5106 )
5107 .unwrap();
5108 let shut = engine
5110 .update_entity(
5111 crate::engine::UpdateEntityArgs {
5112 anchors: Vec::new(),
5113 id: one.clone(),
5114 expected_hash: None,
5115 sections: indexmap::IndexMap::new(),
5116 append_sections: indexmap::IndexMap::new(),
5117 patch_sections: indexmap::IndexMap::new(),
5118 metadata: indexmap::IndexMap::new(),
5119 metadata_unset: Vec::new(),
5120 declare_relations: Vec::new(),
5121 dry_run: false,
5122 relations_unset: vec![crate::ops::RelationUnsetArg {
5123 rel_type: "USES".to_string(),
5124 target: two.clone(),
5125 }],
5126 anchors_unset: Vec::new(),
5127 },
5128 crate::vcs::Actor::Cli,
5129 None,
5130 None,
5131 )
5132 .unwrap_err();
5133 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5134
5135 engine
5139 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5140 .unwrap();
5141 engine
5142 .update_entity(
5143 crate::engine::UpdateEntityArgs {
5144 anchors: Vec::new(),
5145 id: one.clone(),
5146 expected_hash: None,
5147 sections: indexmap::IndexMap::new(),
5148 append_sections: indexmap::IndexMap::new(),
5149 patch_sections: indexmap::IndexMap::new(),
5150 metadata: indexmap::IndexMap::from_iter([(
5151 "status".to_string(),
5152 "open".to_string(),
5153 )]),
5154 metadata_unset: Vec::new(),
5155 declare_relations: Vec::new(),
5156 dry_run: false,
5157 relations_unset: vec![crate::ops::RelationUnsetArg {
5158 rel_type: "USES".to_string(),
5159 target: two.clone(),
5160 }],
5161 anchors_unset: Vec::new(),
5162 },
5163 crate::vcs::Actor::Cli,
5164 None,
5165 None,
5166 )
5167 .expect("repair-shaped update lands during migration without a flag");
5168 let entity = engine.store().get(&one).unwrap();
5169 assert!(entity.relationships.is_empty());
5170 }
5171
5172 #[test]
5177 fn boot_resumes_dual_pin_validation_against_target() {
5178 let (tmp, engine) = migration_engine();
5179 drop(engine);
5180 let schemas_dir = tmp.path().join("schemas");
5181 let mem_dir = tmp.path().join("mem");
5182 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5183 let mut mount = folder_mount("specs", mem_dir);
5184 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5185 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5186 let engine = Engine::from_mounts_with_schemas_dir(
5187 vec![(
5188 mount,
5189 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5190 )],
5191 Some(&schemas_dir),
5192 )
5193 .unwrap();
5194 let (name, version) = {
5196 let s = engine.schema_for("specs").unwrap();
5197 let (n, v) = s.id();
5198 (n.to_string(), v.to_string())
5199 };
5200 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5201 assert_eq!(
5204 engine.schema_pin("specs").unwrap().as_display(),
5205 "mig-a@0.1.0"
5206 );
5207 assert_eq!(
5208 engine.migration_target("specs").unwrap().as_display(),
5209 "mig-b@0.1.0"
5210 );
5211 }
5212
5213 #[test]
5223 fn every_lifecycle_setter_refuses_on_read_only_mount() {
5224 let tmp = TempDir::new().unwrap();
5225 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5226 let mut engine = Engine::from_mounts(vec![(
5227 archive_mount("ext", archive_path.clone()),
5228 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5229 )])
5230 .unwrap();
5231
5232 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5233 let attempts: Vec<(&str, EngineError)> = vec![
5234 (
5235 "set_mem_schema",
5236 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5237 ),
5238 (
5239 "set_mem_version",
5240 engine
5241 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5242 .unwrap_err(),
5243 ),
5244 (
5245 "set_mem_description",
5246 engine
5247 .set_mem_description("ext", Some("x".into()), None)
5248 .unwrap_err(),
5249 ),
5250 (
5251 "set_mem_title",
5252 engine
5253 .set_mem_title("ext", Some("x".into()), None)
5254 .unwrap_err(),
5255 ),
5256 (
5257 "set_mem_subject",
5258 engine.set_mem_subject("ext", None, None).unwrap_err(),
5259 ),
5260 (
5261 "set_mem_internal",
5262 engine.set_mem_internal("ext", true, None).unwrap_err(),
5263 ),
5264 (
5265 "set_mem_sync_state",
5266 engine
5267 .set_mem_sync_state("ext", "k", "t", None)
5268 .unwrap_err(),
5269 ),
5270 ];
5271 for (setter, err) in attempts {
5272 match err {
5273 EngineError::ReadOnlyMount(v) => {
5274 assert_eq!(v, "ext", "{setter} must name the refused mem")
5275 }
5276 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5277 }
5278 }
5279 }
5280}