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 target: 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 use crate::workspace_store::WorkspaceStoreAdapter as _;
1095 let store = crate::FileWorkspaceStore::new();
1096 let map = |e: crate::workspace_store::StoreError| {
1097 EngineError::Mem(format!("persist workspace state: {e}"))
1098 };
1099
1100 let ours: Vec<crate::workspace::Mount> = self
1117 .mounts
1118 .iter()
1119 .map(|m| m.mount.clone())
1120 .chain(self.quarantined.iter().map(|q| q.mount.clone()))
1121 .collect();
1122
1123 for attempt in 0..8 {
1124 let expected = store.read_state_bytes(root).map_err(map)?;
1125 let on_disk: Vec<crate::workspace::Mount> = match expected.as_deref() {
1126 Some(bytes) => store.parse_state_bytes(root, bytes).map_err(map)?,
1127 None => Vec::new(),
1128 };
1129
1130 let merged = {
1131 let baseline = self.mounts_baseline.borrow();
1132 merge_mount_rosters(&baseline, &ours, on_disk)
1133 };
1134 let workspace = crate::workspace::Workspace {
1135 mounts: merged,
1136 settings: self.settings.clone(),
1137 };
1138
1139 if store
1140 .save_state_cas(root, &workspace, expected.as_deref())
1141 .map_err(map)?
1142 {
1143 *self.mounts_baseline.borrow_mut() = ours;
1144 return Ok(());
1145 }
1146 if attempt == 7 {
1147 return Err(EngineError::Mem(
1148 "workspace state is being written concurrently: eight compare-and-set \
1149 attempts all lost the race. Retry, or find the writer that is not \
1150 backing off."
1151 .to_string(),
1152 ));
1153 }
1154 }
1155 unreachable!("the loop returns on success and on exhaustion")
1156 }
1157 pub fn set_mem_schema(
1177 &mut self,
1178 mem: &str,
1179 target: &memstead_schema::SchemaRef,
1180 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1181 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1182 if self.quarantine_reason(mem).is_some() {
1188 return self.set_schema_on_quarantined(mem, target);
1189 }
1190 let mount_idx = self
1191 .mounts
1192 .iter()
1193 .position(|m| m.mount.mem == mem)
1194 .ok_or_else(|| self.unknown_mem_error(mem))?;
1195 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1201 return Err(EngineError::ReadOnlyMount(mem.to_string()));
1202 }
1203
1204 let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
1207 let consulted: Vec<_> = self
1210 .workspace_schemas
1211 .iter()
1212 .chain(self.builtin_schemas.iter())
1213 .cloned()
1214 .collect();
1215 EngineError::SchemaNotFound {
1216 mem: mem.to_string(),
1217 pin: target.as_display(),
1218 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1219 &target.name,
1220 &target.version,
1221 &consulted,
1222 ),
1223 install_hint: None,
1224 }
1225 .with_schema_install_probe(self.workspace_root())
1226 })?;
1227
1228 let current_pin = self.mounts[mount_idx].mount.schema.clone();
1233 let current_pin_display = current_pin
1234 .as_ref()
1235 .map(|p| p.as_display())
1236 .unwrap_or_else(|| "<unset>".to_string());
1237 let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1238
1239 let served_pin: Option<memstead_schema::SchemaRef> = self.schemas.get(mem).map(|s| {
1250 let (name, version) = s.id();
1251 memstead_schema::SchemaRef::new(name, version)
1252 });
1253 if served_pin.as_ref() == Some(target) && in_flight.is_none() {
1259 if current_pin.as_ref() == Some(target) {
1260 return Ok(SetSchemaOutcome {
1261 mem: mem.to_string(),
1262 schema_pin: current_pin_display,
1263 migration_target: in_flight.map(|t| t.as_display()),
1264 outcome: SetSchemaResult::Noop,
1265 findings: Vec::new(),
1266 });
1267 }
1268 self.mounts[mount_idx].mount.schema = Some(target.clone());
1269 self.mounts[mount_idx].mount.migration_target = None;
1270 self.persist_state()?;
1271 return Ok(SetSchemaOutcome {
1272 mem: mem.to_string(),
1273 schema_pin: target.as_display(),
1274 migration_target: None,
1275 outcome: SetSchemaResult::Switched,
1276 findings: Vec::new(),
1277 });
1278 }
1279
1280 let findings = crate::ops::integrity::conformance_findings(
1286 &self.store,
1287 mem,
1288 target_schema.as_ref(),
1289 &self.schemas,
1290 );
1291
1292 if findings.is_empty() {
1293 self.persist_mem_schema_pin(mount_idx, target)?;
1301 self.mounts[mount_idx].mount.schema = Some(target.clone());
1302 self.mounts[mount_idx].mount.migration_target = None;
1303 self.schemas_insert(mem.to_string(), target_schema);
1304 self.invalidate_communities();
1305 self.invalidate_search_indexes();
1310 self.persist_state()?;
1311 return Ok(SetSchemaOutcome {
1312 mem: mem.to_string(),
1313 schema_pin: target.as_display(),
1314 migration_target: None,
1315 outcome: SetSchemaResult::Switched,
1316 findings: Vec::new(),
1317 });
1318 }
1319
1320 let outcome = if in_flight.as_ref() == Some(target) {
1321 SetSchemaResult::MigrationPending
1322 } else {
1323 SetSchemaResult::MigrationStarted
1324 };
1325 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1326 self.schemas_insert(mem.to_string(), target_schema);
1329 self.invalidate_communities();
1330 self.invalidate_search_indexes();
1332 self.persist_state()?;
1333 Ok(SetSchemaOutcome {
1334 mem: mem.to_string(),
1335 schema_pin: current_pin_display,
1336 migration_target: Some(target.as_display()),
1337 outcome,
1338 findings,
1339 })
1340 }
1341
1342 pub(crate) fn write_mem_config_merged(
1391 &mut self,
1392 mount_idx: usize,
1393 mem_name: &str,
1394 note: Option<&str>,
1395 apply: &dyn Fn(&mut memstead_schema::config::MemConfig),
1396 ) -> Result<(memstead_schema::config::MemConfig, Vec<String>), EngineError> {
1397 let backend = self.mounts[mount_idx].backend.as_ref();
1398 let read = |b: &dyn crate::backend::MemBackend| -> Result<Vec<u8>, EngineError> {
1399 b.read_mem_config()
1400 .map_err(|e| EngineError::Mem(format!("read mem config for update: {e}")))?
1401 .ok_or_else(|| {
1402 EngineError::InvalidInput(format!(
1403 "mem '{mem_name}' has no stored MemConfig (initialize the mem via \
1404 `memstead init` or `memstead mem create` first)"
1405 ))
1406 })
1407 };
1408
1409 let stored = read(backend)?;
1410 let intervened = match self.mounts[mount_idx].mem_config.as_ref() {
1415 Some(cached) => changed_config_fields(cached, &stored),
1416 None => Vec::new(),
1417 };
1418
1419 let render =
1420 |raw: &[u8]| -> Result<(memstead_schema::config::MemConfig, Vec<u8>), EngineError> {
1421 let value: serde_json::Value = serde_json::from_slice(raw)
1422 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1423 let mut cfg = memstead_schema::config::parse_mem_config(&value)
1424 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1425 apply(&mut cfg);
1426 let mut bytes = serde_json::to_vec_pretty(&cfg)
1427 .map_err(|e| EngineError::Mem(format!("serialize mem config: {e}")))?;
1428 bytes.push(b'\n');
1429 Ok((cfg, bytes))
1430 };
1431 let (mut parsed, mut bytes) = render(&stored)?;
1432
1433 let mut expected = stored;
1442 for attempt in 0..8 {
1443 let wrote = self.mounts[mount_idx].backend.write_mem_config_cas(
1444 Some(&expected),
1445 &bytes,
1446 note,
1447 )?;
1448 if wrote {
1449 break;
1450 }
1451 if attempt == 7 {
1452 return Err(EngineError::Mem(format!(
1453 "mem '{mem_name}' config is being written concurrently: eight \
1454 compare-and-set attempts all lost the race. Retry, or find the \
1455 writer that is not backing off."
1456 )));
1457 }
1458 expected = read(self.mounts[mount_idx].backend.as_ref())?;
1459 let rendered = render(&expected)?;
1460 parsed = rendered.0;
1461 bytes = rendered.1;
1462 }
1463
1464 let mounted = &mut self.mounts[mount_idx];
1465 mounted.mem_config = Some(parsed.clone());
1466 if let Some(sha) = mounted.backend.current_head().ok().flatten() {
1469 mounted.last_known_head = Some(sha);
1470 }
1471 Ok((parsed, intervened))
1472 }
1473
1474 fn persist_mem_schema_pin(
1475 &mut self,
1476 mount_idx: usize,
1477 target: &memstead_schema::SchemaRef,
1478 ) -> Result<(), EngineError> {
1479 let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1480 if let Some(value) = value
1483 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1484 {
1485 self.mounts[mount_idx].mem_config = Some(cfg);
1486 }
1487 Ok(())
1488 }
1489
1490 pub fn export_markdown(
1519 &self,
1520 mem_filter: Option<&str>,
1521 schema_filter: Option<&str>,
1522 ) -> Result<crate::ops::ExportResult, EngineError> {
1523 use crate::workspace::MountStorage;
1524 let fallback = engine_fallback_type();
1525 let supported_backends = vec!["folder".to_string()];
1526
1527 if let Some(name) = mem_filter {
1528 let mount = self
1529 .mounts
1530 .iter()
1531 .find(|m| m.mount.mem == name)
1532 .ok_or_else(|| self.unknown_mem_error(name))?;
1533 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1534 return Err(EngineError::MarkdownExportUnsupportedBackend {
1535 mem: name.to_string(),
1536 active_backend: mount.mount.storage.backend_id().to_string(),
1537 supported_backends,
1538 });
1539 }
1540 }
1541
1542 let mut total_written = 0;
1543 let mut refused: Vec<crate::ops::RefusedEntity> = Vec::new();
1544 let mut total_unchanged = 0;
1545 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1546
1547 for mount in &self.mounts {
1548 let mem_name = mount.mount.mem.as_str();
1549 if let Some(filter) = mem_filter
1550 && mem_name != filter
1551 {
1552 continue;
1553 }
1554 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1555 skipped_mounts.push(crate::ops::SkippedMount {
1556 mem: mem_name.to_string(),
1557 active_backend: mount.mount.storage.backend_id().to_string(),
1558 reason: "backend_does_not_support_markdown_export".to_string(),
1559 });
1560 continue;
1561 };
1562 let schema = match self.schemas.get(mem_name) {
1563 Some(s) => s,
1564 None => continue,
1565 };
1566
1567 for entity in self.store.all_entities() {
1568 if entity.stub || entity.file_path.is_empty() {
1569 continue;
1570 }
1571 if entity.id.mem() != mem_name {
1572 continue;
1573 }
1574 if let Some(filter) = schema_filter
1575 && entity.entity_type != filter
1576 {
1577 continue;
1578 }
1579 let type_def = schema
1580 .get_type(&entity.entity_type)
1581 .unwrap_or_else(|| fallback.clone());
1582 let generated = generate_markdown(entity, type_def.as_ref());
1583
1584 let full_path = mem_dir.join(&entity.file_path);
1585 let needs_write = match std::fs::read_to_string(&full_path) {
1586 Ok(existing) => existing != generated,
1587 Err(_) => true,
1588 };
1589 if needs_write {
1590 match crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref()) {
1591 Ok(_) => total_written += 1,
1592 Err(e @ crate::entity::writer::WriteError::UnterminatedFence { .. }) => {
1597 refused.push(crate::ops::RefusedEntity {
1598 id: entity.id.to_string(),
1599 reason: "UNTERMINATED_FENCE_IN_STORED_BODY".to_string(),
1600 detail: e.to_string(),
1601 });
1602 }
1603 Err(_) => {}
1604 }
1605 } else {
1606 total_unchanged += 1;
1607 }
1608 }
1609 }
1610
1611 Ok(crate::ops::ExportResult {
1612 refused_entities: refused,
1613 written: total_written,
1614 unchanged: total_unchanged,
1615 skipped_mounts,
1616 })
1617 }
1618
1619 pub fn export_mem(
1637 &self,
1638 mem_name: &str,
1639 output_path: &std::path::Path,
1640 ) -> Result<crate::ops::MemExportResult, EngineError> {
1641 let mount = self
1642 .mounts
1643 .iter()
1644 .find(|m| m.mount.mem == mem_name)
1645 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1646 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1647 EngineError::InvalidInput(format!(
1648 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1649 ))
1650 })?;
1651 if config.version.is_none() {
1659 return Err(EngineError::MemConfigIncomplete {
1660 mem: mem_name.to_string(),
1661 missing_fields: vec!["version".to_string()],
1662 });
1663 }
1664 let fenced: Vec<String> = self
1670 .store
1671 .all_entities()
1672 .filter(|e| e.mem == mem_name && !e.stub)
1673 .filter(|e| {
1674 e.sections
1675 .values()
1676 .any(|v| crate::markdown::closing_fence_if_unterminated(v.trim()).is_some())
1677 })
1678 .map(|e| e.id.to_string())
1679 .collect();
1680 let workspace_root = self.workspace_root.as_deref();
1681 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1685 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1686 let exported = match &mount.mount.storage {
1687 MountStorage::Folder { path } => crate::ops::export::export_mem(
1688 path,
1689 config,
1690 output_path,
1691 workspace_root,
1692 workspace_schemas_dir,
1693 )
1694 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1695 MountStorage::GitBranch { gitdir, branch } => {
1696 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1697 EngineError::Backend(BackendError::Other(
1698 "git-branch export hook not installed (full flavour not loaded)"
1699 .to_string(),
1700 ))
1701 })?;
1702 let provenance_bytes = mount
1706 .backend
1707 .read_provenance(None)
1708 .ok()
1709 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
1710 .and_then(|prov| prov.to_archive_bytes().ok());
1711 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1714 (hook.export)(
1715 gitdir,
1716 branch,
1717 mem_name,
1718 config,
1719 output_path,
1720 workspace_root,
1721 workspace_schemas_dir,
1722 provenance_bytes.as_deref(),
1723 anchors_bytes.as_deref(),
1724 )
1725 .map_err(EngineError::Backend)
1726 }
1727 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1728 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1733 "export not yet supported for in-memory backend".to_string(),
1734 ))),
1735 };
1736 exported.map(|mut r| {
1737 r.unterminated_fence_entities = fenced;
1738 r
1739 })
1740 }
1741
1742 pub fn record_pipeline_edit_provenance(
1766 &self,
1767 mem: &str,
1768 kind: &str,
1769 edits: &[(String, Option<Vec<u8>>)],
1770 note: Option<&str>,
1771 verb: &str,
1772 ) -> Result<(), crate::backend::BackendError> {
1773 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1774 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1775 None => Ok(()),
1776 }
1777 }
1778
1779 pub fn set_mem_version(
1780 &mut self,
1781 mem_name: &str,
1782 new_version: semver::Version,
1783 note: Option<&str>,
1784 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1785 let mount_idx = self
1788 .mounts
1789 .iter()
1790 .position(|m| m.mount.mem == mem_name)
1791 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1792 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1793 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1794 }
1795
1796 let mut warnings = self.reload_if_stale(Some(mem_name));
1804 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1809 warnings.push(w);
1810 }
1811
1812 let old_version = self.mounts[mount_idx]
1816 .mem_config
1817 .as_ref()
1818 .and_then(|c| c.version.clone());
1819 let target = new_version.clone();
1820 let (_, intervened) = self.write_mem_config_merged(
1821 mount_idx,
1822 mem_name,
1823 note,
1824 &move |c: &mut memstead_schema::config::MemConfig| {
1825 c.version = Some(target.clone());
1826 },
1827 )?;
1828 if !intervened.is_empty() {
1829 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1830 mem: mem_name.to_string(),
1831 fields: intervened,
1832 });
1833 }
1834
1835 Ok(crate::ops::SetMemVersionOutcome {
1836 mem: mem_name.to_string(),
1837 old_version,
1838 new_version,
1839 warnings,
1840 })
1841 }
1842
1843 pub fn set_mem_description(
1850 &mut self,
1851 mem_name: &str,
1852 new_description: Option<String>,
1853 note: Option<&str>,
1854 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1855 let mount_idx = self
1856 .mounts
1857 .iter()
1858 .position(|m| m.mount.mem == mem_name)
1859 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1860 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1861 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1862 }
1863
1864 let mut warnings = self.reload_if_stale(Some(mem_name));
1865 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1866 warnings.push(w);
1867 }
1868
1869 let old_description = self.mounts[mount_idx]
1870 .mem_config
1871 .as_ref()
1872 .and_then(|c| c.description.clone());
1873 let target = new_description.clone();
1874 let (_, intervened) = self.write_mem_config_merged(
1875 mount_idx,
1876 mem_name,
1877 note,
1878 &move |c: &mut memstead_schema::config::MemConfig| {
1879 c.description = target.clone();
1880 },
1881 )?;
1882 if !intervened.is_empty() {
1883 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1884 mem: mem_name.to_string(),
1885 fields: intervened,
1886 });
1887 }
1888
1889 Ok(crate::ops::SetMemDescriptionOutcome {
1890 mem: mem_name.to_string(),
1891 old_description,
1892 new_description,
1893 warnings,
1894 })
1895 }
1896
1897 pub fn set_mem_title(
1902 &mut self,
1903 mem_name: &str,
1904 new_title: Option<String>,
1905 note: Option<&str>,
1906 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1907 let mount_idx = self
1908 .mounts
1909 .iter()
1910 .position(|m| m.mount.mem == mem_name)
1911 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1912 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1913 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1914 }
1915
1916 let mut warnings = self.reload_if_stale(Some(mem_name));
1917 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1918 warnings.push(w);
1919 }
1920
1921 let old_title = self.mounts[mount_idx]
1922 .mem_config
1923 .as_ref()
1924 .and_then(|c| c.title.clone());
1925 let target = new_title.clone();
1926 let (_, intervened) = self.write_mem_config_merged(
1927 mount_idx,
1928 mem_name,
1929 note,
1930 &move |c: &mut memstead_schema::config::MemConfig| {
1931 c.title = target.clone();
1932 },
1933 )?;
1934 if !intervened.is_empty() {
1935 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1936 mem: mem_name.to_string(),
1937 fields: intervened,
1938 });
1939 }
1940
1941 Ok(crate::ops::SetMemTitleOutcome {
1942 mem: mem_name.to_string(),
1943 old_title,
1944 new_title,
1945 warnings,
1946 })
1947 }
1948
1949 pub fn set_mem_subject(
1953 &mut self,
1954 mem_name: &str,
1955 new_subject: Option<memstead_schema::MemSubject>,
1956 note: Option<&str>,
1957 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1958 let mount_idx = self
1959 .mounts
1960 .iter()
1961 .position(|m| m.mount.mem == mem_name)
1962 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1963 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1964 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1965 }
1966
1967 let mut warnings = self.reload_if_stale(Some(mem_name));
1968 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
1969 warnings.push(w);
1970 }
1971
1972 let old_subject = self.mounts[mount_idx]
1973 .mem_config
1974 .as_ref()
1975 .and_then(|c| c.subject.clone());
1976 let target = new_subject.clone();
1977 let (_, intervened) = self.write_mem_config_merged(
1978 mount_idx,
1979 mem_name,
1980 note,
1981 &move |c: &mut memstead_schema::config::MemConfig| {
1982 c.subject = target.clone();
1983 },
1984 )?;
1985 if !intervened.is_empty() {
1986 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1987 mem: mem_name.to_string(),
1988 fields: intervened,
1989 });
1990 }
1991
1992 Ok(crate::ops::SetMemSubjectOutcome {
1993 mem: mem_name.to_string(),
1994 old_subject,
1995 new_subject,
1996 warnings,
1997 })
1998 }
1999
2000 pub fn set_mem_internal(
2012 &mut self,
2013 mem_name: &str,
2014 internal: bool,
2015 note: Option<&str>,
2016 ) -> Result<crate::ops::SetMemInternalOutcome, EngineError> {
2017 let mount_idx = self
2018 .mounts
2019 .iter()
2020 .position(|m| m.mount.mem == mem_name)
2021 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2022 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2023 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2024 }
2025
2026 let _ = self.reload_if_stale(Some(mem_name));
2027
2028 let (_, intervened) = self.write_mem_config_merged(
2029 mount_idx,
2030 mem_name,
2031 note,
2032 &move |c: &mut memstead_schema::config::MemConfig| {
2033 if internal {
2034 c.extra
2035 .insert("internal".to_string(), serde_json::Value::Bool(true));
2036 } else {
2037 c.extra.remove("internal");
2038 }
2039 },
2040 )?;
2041 let mut warnings = Vec::new();
2046 if !intervened.is_empty() {
2047 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2048 mem: mem_name.to_string(),
2049 fields: intervened,
2050 });
2051 }
2052
2053 Ok(crate::ops::SetMemInternalOutcome {
2054 mem: mem_name.to_string(),
2055 internal,
2056 warnings,
2057 })
2058 }
2059
2060 pub fn set_mem_sync_state(
2085 &mut self,
2086 mem_name: &str,
2087 key: &str,
2088 token: &str,
2089 note: Option<&str>,
2090 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
2091 let mount_idx = self
2094 .mounts
2095 .iter()
2096 .position(|m| m.mount.mem == mem_name)
2097 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2098 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2099 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2100 }
2101
2102 let mut warnings = self.reload_if_stale(Some(mem_name));
2106 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
2107 warnings.push(w);
2108 }
2109
2110 let seen: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
2117 let key_owned = key.to_string();
2118 let token_owned = token.to_string();
2119 let (_, intervened) = self.write_mem_config_merged(
2120 mount_idx,
2121 mem_name,
2122 note,
2123 &|c: &mut memstead_schema::config::MemConfig| {
2124 *seen.borrow_mut() = if token_owned.is_empty() {
2125 c.sync_state.remove(&key_owned)
2126 } else {
2127 c.sync_state.insert(key_owned.clone(), token_owned.clone())
2128 };
2129 },
2130 )?;
2131 let previous = seen.into_inner();
2132 let removed = token.is_empty() && previous.is_some();
2135 if !intervened.is_empty() {
2136 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2137 mem: mem_name.to_string(),
2138 fields: intervened,
2139 });
2140 }
2141
2142 Ok(crate::ops::SetMemSyncStateOutcome {
2143 mem: mem_name.to_string(),
2144 key: key.to_string(),
2145 previous,
2146 removed,
2147 warnings,
2148 })
2149 }
2150
2151 pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
2198 let pending: Vec<String> = self
2199 .mounts
2200 .iter()
2201 .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
2202 .map(|m| m.mount.mem.clone())
2203 .collect();
2204 for name in pending {
2205 match self.reload_one_mem(&name) {
2206 Ok(_) => {
2207 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
2208 state.deferred = false;
2209 }
2210 let store = &self.store;
2216 self.load_warnings.retain(|w| match w {
2217 crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
2218 store.get(resolved_id).is_none_or(|e| e.stub)
2219 }
2220 _ => true,
2221 });
2222 }
2223 Err(e) => {
2224 let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
2229 continue;
2230 };
2231 let removed = self.mounts.remove(idx);
2232 self.schemas_remove(&name);
2233 self.quarantined.push(crate::engine::QuarantinedMem {
2234 mount: removed.mount,
2235 reason_code: e.code().to_string(),
2236 reason_message: e.to_string(),
2237 });
2238 self.mem_router = std::sync::Arc::new(
2239 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2240 );
2241 self.invalidate_communities();
2242 self.invalidate_search_indexes();
2248 }
2249 }
2250 }
2251 }
2252
2253 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2254 if self.quarantine_reason(mem).is_some() {
2277 return self.reattach_quarantined_mem(mem);
2278 }
2279 let mut sink: Vec<WarningHint> = Vec::new();
2280 let result = self.reload_one_mem_inner(mem, &mut sink)?;
2281 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2282 self.load_warnings
2283 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2284 Ok(result)
2285 }
2286
2287 fn set_schema_on_quarantined(
2297 &mut self,
2298 mem: &str,
2299 target: &memstead_schema::SchemaRef,
2300 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2301 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2302 if self.resolve_schema_by_ref(target).is_none() {
2304 let consulted: Vec<_> = self
2305 .workspace_schemas
2306 .iter()
2307 .chain(self.builtin_schemas.iter())
2308 .cloned()
2309 .collect();
2310 return Err(EngineError::SchemaNotFound {
2311 mem: mem.to_string(),
2312 pin: target.as_display(),
2313 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2314 &target.name,
2315 &target.version,
2316 &consulted,
2317 ),
2318 install_hint: None,
2319 }
2320 .with_schema_install_probe(self.workspace_root()));
2321 }
2322 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2323 return Err(self.unknown_mem_error(mem));
2324 };
2325 self.quarantined[q_idx].mount.schema = Some(target.clone());
2329 self.quarantined[q_idx].mount.migration_target = None;
2330 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2331 let _ = bump_backend_schema_pin(backend.as_ref(), target);
2332 }
2333 self.persist_state()?;
2334 let _ = self.reattach_quarantined_mem(mem);
2339 Ok(SetSchemaOutcome {
2340 mem: mem.to_string(),
2341 schema_pin: target.as_display(),
2342 migration_target: None,
2343 outcome: SetSchemaResult::Switched,
2344 findings: Vec::new(),
2345 })
2346 }
2347
2348 fn reattach_quarantined_mem(
2356 &mut self,
2357 mem: &str,
2358 ) -> Result<crate::ops::ReloadResult, EngineError> {
2359 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2360 return Err(self.unknown_mem_error(mem));
2361 };
2362 let mount = self.quarantined[q_idx].mount.clone();
2363
2364 let requarantine = |this: &mut Self, e: &EngineError| {
2365 this.quarantined[q_idx].reason_code = e.code().to_string();
2366 this.quarantined[q_idx].reason_message = e.to_string();
2367 };
2368
2369 let backend = match (self.backend_factory)(&mount) {
2370 Ok(b) => b,
2371 Err(e) => {
2372 let err = EngineError::Mem(e.to_string());
2373 requarantine(self, &err);
2374 return Err(self.unknown_mem_error(mem));
2375 }
2376 };
2377
2378 let last_known_head = backend.current_head().ok().flatten();
2380 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2381 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2382 memstead_schema::config::parse_mem_config(&value).ok()
2383 });
2384 let archive_provenance = backend
2385 .read_archive_provenance()
2386 .ok()
2387 .flatten()
2388 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2389 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2390 let effective_pin = mount
2391 .migration_target
2392 .clone()
2393 .or(config_pin)
2394 .or(mount.schema.clone());
2395 let Some(effective_pin) = effective_pin else {
2396 let err = EngineError::MemConfigIncomplete {
2397 mem: mem.to_string(),
2398 missing_fields: vec!["schema".to_string()],
2399 };
2400 requarantine(self, &err);
2401 return Err(self.unknown_mem_error(mem));
2402 };
2403 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2404 .workspace_schemas
2405 .iter()
2406 .chain(self.builtin_schemas.iter())
2407 .cloned()
2408 .collect();
2409 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2410 Ok(s) => s,
2411 Err(sources) => {
2412 let err = EngineError::SchemaNotFound {
2413 mem: mem.to_string(),
2414 pin: effective_pin.as_display(),
2415 sources,
2416 install_hint: None,
2417 }
2418 .with_schema_install_probe(self.workspace_root());
2419 requarantine(self, &err);
2420 return Err(self.unknown_mem_error(mem));
2421 }
2422 };
2423
2424 self.quarantined.remove(q_idx);
2428 self.schemas_insert(mem.to_string(), schema);
2429 self.mounts.push(crate::engine::MountedBackend {
2430 mount,
2431 backend,
2432 last_known_head,
2433 mem_config,
2434 archive_provenance,
2435 deferred: false,
2438 });
2439 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2440 &self.mounts,
2441 ));
2442 let mut sink: Vec<WarningHint> = Vec::new();
2443 match self.reload_one_mem_inner(mem, &mut sink) {
2444 Ok(result) => {
2445 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2446 self.load_warnings
2447 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2448 self.invalidate_communities();
2449 Ok(result)
2450 }
2451 Err(e) => {
2452 let mount_idx = self.mounts.len() - 1;
2453 let mounted = self.mounts.remove(mount_idx);
2454 self.schemas_remove(mem);
2455 self.mem_router = std::sync::Arc::new(
2456 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2457 );
2458 self.quarantined.push(crate::engine::QuarantinedMem {
2459 mount: mounted.mount,
2460 reason_code: e.code().to_string(),
2461 reason_message: e.to_string(),
2462 });
2463 self.invalidate_communities();
2467 self.invalidate_search_indexes();
2468 Err(e)
2469 }
2470 }
2471 }
2472
2473 fn reload_one_mem_inner(
2479 &mut self,
2480 mem: &str,
2481 warnings_sink: &mut Vec<WarningHint>,
2482 ) -> Result<crate::ops::ReloadResult, EngineError> {
2483 let mount_idx = self
2486 .mounts
2487 .iter()
2488 .position(|m| m.mount.mem == mem)
2489 .ok_or_else(|| self.unknown_mem_error(mem))?;
2490 let schema = self
2491 .schemas
2492 .get(mem)
2493 .cloned()
2494 .ok_or_else(|| self.unknown_mem_error(mem))?;
2495
2496 let pre: HashMap<EntityId, String> = self
2498 .store
2499 .all_entities()
2500 .filter(|e| !e.stub && e.mem == mem)
2501 .map(|e| (e.id.clone(), e.content_hash.clone()))
2502 .collect();
2503 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2504
2505 let backend = self.mounts[mount_idx].backend.as_ref();
2508 let (entries, read_errors) = collect_source_entries(backend)?;
2509 let unbacked = super::boot::unbacked_mount_warning(
2514 &self.mounts[mount_idx].mount,
2515 backend,
2516 Some(entries.len()),
2517 );
2518 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2519
2520 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2524 let known_suffixes: Vec<String> = mem_names
2525 .iter()
2526 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2527 .collect();
2528
2529 self.store.remove_entities_by_mem(mem);
2531 if let Some(w) = unbacked {
2532 warnings_sink.push(w);
2533 }
2534 let fallback = engine_fallback_type();
2535 push_entities_into_store(
2536 &mut self.store,
2537 load_result.entities,
2538 fallback.as_ref(),
2539 Some(crate::entity::store_builder::LoadCollector {
2540 warnings: warnings_sink,
2541 known_suffixes: &known_suffixes,
2542 mem_names: &mem_names,
2543 }),
2544 );
2545 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2552 .mounts
2553 .iter()
2554 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2555 .collect();
2556 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2564 crate::entity::store_builder::validate_loaded_relations(
2565 &mut self.store,
2566 &self.schemas,
2567 &mount_caps,
2568 warnings_sink,
2569 );
2570 crate::entity::store_builder::remap_alias_target_edge_sources(
2571 &mut self.store,
2572 &self.schemas,
2573 );
2574 if let crate::workspace::MountStorage::Folder { path } =
2584 &self.mounts[mount_idx].mount.storage
2585 {
2586 let root = path.clone();
2587 self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2588 self.load_errors
2592 .extend(load_result.errors.into_iter().map(|(p, m)| {
2593 let abs = if p.is_relative() { root.join(&p) } else { p };
2594 (abs, m)
2595 }));
2596 } else {
2597 self.load_errors.extend(load_result.errors);
2598 }
2599
2600 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2608 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2609 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2610 {
2611 self.mounts[mount_idx].mem_config = Some(cfg);
2612 }
2613
2614 let mut added: Vec<EntityId> = Vec::new();
2616 let mut changed: Vec<EntityId> = Vec::new();
2617 for entity in self.store.all_entities() {
2618 if entity.stub || entity.mem != mem {
2619 continue;
2620 }
2621 match pre.get(&entity.id) {
2622 None => added.push(entity.id.clone()),
2623 Some(prev_hash) if prev_hash != &entity.content_hash => {
2624 changed.push(entity.id.clone());
2625 }
2626 Some(_) => {}
2627 }
2628 }
2629 let post_ids: std::collections::HashSet<EntityId> = self
2630 .store
2631 .all_entities()
2632 .filter(|e| !e.stub && e.mem == mem)
2633 .map(|e| e.id.clone())
2634 .collect();
2635 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2636 added.sort_by(|a, b| a.0.cmp(&b.0));
2637 changed.sort_by(|a, b| a.0.cmp(&b.0));
2638 removed.sort_by(|a, b| a.0.cmp(&b.0));
2639
2640 self.invalidate_communities();
2641 self.invalidate_search_indexes();
2642
2643 Ok(crate::ops::ReloadResult {
2644 added,
2645 changed,
2646 removed,
2647 })
2648 }
2649
2650 pub fn reload_one_mem_report(
2678 &mut self,
2679 mem: &str,
2680 ) -> Result<crate::ops::ReloadReport, EngineError> {
2681 let tracks_head = self
2691 .mounts
2692 .iter()
2693 .find(|m| m.mount.mem == mem)
2694 .and_then(|m| m.backend.current_head().ok().flatten())
2695 .is_some();
2696 let head_before = if tracks_head {
2697 self.mounts
2698 .iter()
2699 .find(|m| m.mount.mem == mem)
2700 .and_then(|m| m.last_known_head.clone())
2701 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2702 } else {
2703 crate::ops::EMPTY_TREE_SHA.to_string()
2704 };
2705
2706 let result = self.reload_one_mem(mem)?;
2707
2708 let head_after_raw = self
2715 .mounts
2716 .iter()
2717 .find(|m| m.mount.mem == mem)
2718 .and_then(|m| m.backend.current_head().ok().flatten());
2719 if let Some(new_head) = head_after_raw.clone()
2720 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2721 {
2722 m.last_known_head = Some(new_head);
2723 }
2724 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2725
2726 let entities_loaded = self
2727 .store
2728 .all_entities()
2729 .filter(|e| !e.stub && e.mem == mem)
2730 .count();
2731
2732 let mut changed_entity_ids: Vec<EntityId> = result
2737 .added
2738 .into_iter()
2739 .chain(result.changed)
2740 .chain(result.removed)
2741 .collect();
2742 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2743
2744 Ok(crate::ops::ReloadReport {
2745 mem: mem.to_string(),
2746 head_before,
2747 head_after,
2748 entities_loaded,
2749 changed_entity_ids,
2750 })
2751 }
2752
2753 pub fn reload_each_writable_mem_reports(
2793 &mut self,
2794 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2795 self.refresh_workspace_settings_if_possible();
2796 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2797 let mut out = Vec::with_capacity(names.len());
2798 for name in names {
2799 let report = self.reload_one_mem_report(&name)?;
2800 out.push(report);
2801 }
2802 Ok(out)
2803 }
2804
2805 fn refresh_workspace_settings_if_possible(&mut self) {
2817 let Some(root) = self.workspace_root.clone() else {
2818 return;
2819 };
2820 let store = crate::workspace_store::FileWorkspaceStore::new();
2821 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2822 Ok(w) => w,
2823 Err(_) => return,
2824 };
2825 self.set_settings(workspace.settings);
2826 }
2827
2828 pub fn reload_each_writable_mem(
2844 &mut self,
2845 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2846 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2847 let mut sink = std::mem::take(&mut self.load_warnings);
2853 sink.clear();
2854 let mut out = Vec::with_capacity(names.len());
2855 let mut loop_err = None;
2856 for name in names {
2857 match self.reload_one_mem_inner(&name, &mut sink) {
2858 Ok(result) => out.push((name, result)),
2859 Err(e) => {
2860 loop_err = Some(e);
2861 break;
2862 }
2863 }
2864 }
2865 self.load_warnings = sink;
2866 if let Some(e) = loop_err {
2867 return Err(e);
2868 }
2869 Ok(out)
2870 }
2871}
2872
2873fn changed_config_fields(
2892 cached: &memstead_schema::config::MemConfig,
2893 stored_bytes: &[u8],
2894) -> Vec<String> {
2895 let (Ok(a), Ok(b)) = (
2896 serde_json::to_value(cached),
2897 serde_json::from_slice::<serde_json::Value>(stored_bytes),
2898 ) else {
2899 return Vec::new();
2900 };
2901 let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
2902 return Vec::new();
2903 };
2904 let mut keys: std::collections::BTreeSet<&String> = a.keys().collect();
2905 keys.extend(b.keys());
2906 keys.into_iter()
2907 .filter(|k| a.get(*k) != b.get(*k))
2908 .map(|k| k.to_string())
2909 .collect()
2910}
2911
2912pub fn bump_backend_schema_pin(
2913 backend: &dyn crate::backend::MemBackend,
2914 target: &memstead_schema::SchemaRef,
2915) -> Result<Option<serde_json::Value>, EngineError> {
2916 let Some(bytes) = backend
2917 .read_mem_config()
2918 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2919 else {
2920 return Ok(None);
2921 };
2922 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2923 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2924 value["schema"] = serde_json::Value::String(target.as_display());
2925 let new_bytes = serde_json::to_vec_pretty(&value)
2926 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2927 backend
2928 .write_mem_config(&new_bytes)
2929 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2930 Ok(Some(value))
2931}
2932
2933fn merge_mount_rosters(
2947 baseline: &[crate::workspace::Mount],
2948 ours: &[crate::workspace::Mount],
2949 on_disk: Vec<crate::workspace::Mount>,
2950) -> Vec<crate::workspace::Mount> {
2951 use std::collections::{HashMap, HashSet};
2952
2953 let ours_names: HashSet<&str> = ours.iter().map(|m| m.mem.as_str()).collect();
2954 let removed_by_us: HashSet<&str> = baseline
2955 .iter()
2956 .map(|m| m.mem.as_str())
2957 .filter(|n| !ours_names.contains(n))
2958 .collect();
2959 let baseline_by_name: HashMap<&str, &crate::workspace::Mount> =
2960 baseline.iter().map(|m| (m.mem.as_str(), m)).collect();
2961
2962 let mut merged: Vec<crate::workspace::Mount> = on_disk
2963 .into_iter()
2964 .filter(|m| !removed_by_us.contains(m.mem.as_str()))
2965 .collect();
2966
2967 for mount in ours {
2968 let untouched_by_us = baseline_by_name
2969 .get(mount.mem.as_str())
2970 .is_some_and(|b| *b == mount);
2971 match merged.iter_mut().find(|d| d.mem == mount.mem) {
2972 Some(slot) => {
2973 if !untouched_by_us {
2974 *slot = mount.clone();
2975 }
2976 }
2977 None => merged.push(mount.clone()),
2978 }
2979 }
2980 merged
2981}
2982
2983#[cfg(test)]
2984mod tests {
2985
2986 use tempfile::TempDir;
2987
2988 use crate::backend::{BackendError, MemBackend};
2989 use crate::engine::test_helpers::*;
2990 use crate::engine::{Engine, EngineError};
2991 use crate::mem::MemOrigin;
2992 use crate::ops::WarningHint;
2993 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
2994
2995 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
2996 let manifest = format!(
2997 r#"name: {manifest_name}
2998version: 1.0.0
2999description: Install-gate test schema
3000when_to_use: Tests
3001types:
3002 - sample
3003relationships:
3004 mode: strict
3005 definitions:
3006 - name: PART_OF
3007 description: hier
3008 default_weight: 3.0
3009 - name: _default
3010 description: fallback
3011 default_weight: 1.0
3012community:
3013 resolution: 1.0
3014 seed: 42
3015"#
3016 );
3017 let type_yaml = format!(
3018 r#"name: sample
3019description: t
3020when_to_use: tests
3021sections:
3022 - key: body
3023 heading: {heading}
3024 required: true
3025 search_weight: 10.0
3026 catch_all: true
3027 write_rules: []
3028metadata_fields: []
3029title_weight: 100.0
3030text_fields:
3031 - body
3032hierarchy_relationship: PART_OF
3033no_self_loop_relationships: []
3034updatable_fields:
3035 - title
3036 - body
3037health_required_fields:
3038 - body
3039staleness_threshold_days: 90
3040write_rules: []
3041"#
3042 );
3043 vec![
3044 ("schema.yaml".to_string(), manifest.into_bytes()),
3045 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3046 ]
3047 }
3048
3049 #[test]
3053 fn install_gate_refuses_non_roundtrip_heading() {
3054 let ok =
3055 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
3056 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
3057
3058 let err = Engine::validate_schema_package(
3059 "gate",
3060 "1.0.0",
3061 &schema_package_files("Body Text", "gate"),
3062 )
3063 .expect_err("non-deriving heading must refuse install");
3064 match &err {
3065 EngineError::SchemaPackageInvalid { name, message, .. } => {
3066 assert_eq!(name, "gate");
3067 assert!(
3068 message.contains("'body'") && message.contains("'Body Text'"),
3069 "message names the offending tuple: {message}"
3070 );
3071 }
3072 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3073 }
3074 }
3075
3076 fn exemplar_package_files(
3083 section_key: &str,
3084 status_value: &str,
3085 rel_type: &str,
3086 ) -> Vec<(String, Vec<u8>)> {
3087 let manifest = r#"name: gate
3088version: 1.0.0
3089description: exemplar gate fixture
3090when_to_use: tests
3091types:
3092 - sample
3093 - other
3094relationships:
3095 mode: strict
3096 definitions:
3097 - name: PART_OF
3098 description: hier
3099 default_weight: 3.0
3100 - name: REFINES
3101 description: pinned
3102 default_weight: 1.0
3103 source_types: [other]
3104 - name: _default
3105 description: fallback
3106 default_weight: 1.0
3107community:
3108 resolution: 1.0
3109 seed: 42
3110"#
3111 .to_string();
3112 let type_yaml = format!(
3113 r#"name: sample
3114description: t
3115when_to_use: tests
3116sections:
3117 - key: body
3118 heading: Body
3119 required: true
3120 search_weight: 10.0
3121 catch_all: true
3122 write_rules: []
3123metadata_fields:
3124 - key: status
3125 description: workflow state
3126 field_type: string
3127 enum_values: [draft, final]
3128title_weight: 100.0
3129text_fields:
3130 - body
3131hierarchy_relationship: PART_OF
3132no_self_loop_relationships: []
3133updatable_fields:
3134 - title
3135 - body
3136health_required_fields:
3137 - body
3138staleness_threshold_days: 90
3139write_rules: []
3140exemplar:
3141 title: A Conforming Sample
3142 metadata:
3143 status: "{status_value}"
3144 sections:
3145 {section_key}: "One canonical body paragraph."
3146 relations:
3147 - to: parent-placeholder
3148 type: {rel_type}
3149"#
3150 );
3151 let other_yaml = r#"name: other
3152description: shape-pin partner
3153when_to_use: tests
3154sections:
3155 - key: body
3156 heading: Body
3157 required: true
3158 search_weight: 10.0
3159 catch_all: true
3160 write_rules: []
3161metadata_fields: []
3162title_weight: 100.0
3163text_fields:
3164 - body
3165hierarchy_relationship: PART_OF
3166no_self_loop_relationships: []
3167updatable_fields:
3168 - title
3169 - body
3170health_required_fields:
3171 - body
3172staleness_threshold_days: 90
3173write_rules: []
3174"#
3175 .to_string();
3176 vec![
3177 ("schema.yaml".to_string(), manifest.into_bytes()),
3178 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3179 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
3180 ]
3181 }
3182
3183 #[test]
3190 fn install_gate_validates_exemplars_through_the_real_create_path() {
3191 let ok = Engine::validate_schema_package(
3193 "gate",
3194 "1.0.0",
3195 &exemplar_package_files("body", "draft", "PART_OF"),
3196 );
3197 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
3198
3199 let err = Engine::validate_schema_package(
3201 "gate",
3202 "1.0.0",
3203 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
3204 )
3205 .expect_err("wrong section key must refuse");
3206 match &err {
3207 EngineError::SchemaPackageInvalid { message, .. } => {
3208 assert!(
3209 message.contains("'sample'") && message.contains("exemplar"),
3210 "names type and calls out the exemplar: {message}"
3211 );
3212 assert!(
3213 message.contains("UNKNOWN_SECTION")
3214 || message.contains("MISSING_REQUIRED_SECTION"),
3215 "carries the typed defect code: {message}"
3216 );
3217 }
3218 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3219 }
3220
3221 let err = Engine::validate_schema_package(
3223 "gate",
3224 "1.0.0",
3225 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
3226 )
3227 .expect_err("illegal enum value must refuse");
3228 assert!(
3229 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3230 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
3231 "got {err:?}"
3232 );
3233
3234 let err = Engine::validate_schema_package(
3237 "gate",
3238 "1.0.0",
3239 &exemplar_package_files("body", "draft", "REFINES"),
3240 )
3241 .expect_err("relationship shape violation must refuse");
3242 assert!(
3243 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3244 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
3245 "got {err:?}"
3246 );
3247 }
3248
3249 #[test]
3254 fn worked_example_package_exemplars_validate() {
3255 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3256 .join("../memstead-schema/examples/minimal");
3257 let schema = std::sync::Arc::new(
3258 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
3259 );
3260 assert!(
3261 schema.types.values().all(|td| td.exemplar.is_some()),
3262 "every worked-example type models the exemplar practice"
3263 );
3264 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
3265 }
3266
3267 #[test]
3274 fn builtin_exemplars_validate_through_the_install_gate() {
3275 let schemas = memstead_schema::builtins::load_builtin_schemas()
3276 .expect("built-in schemas always load");
3277 for schema in &schemas {
3279 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
3280 let (name, version) = schema.id();
3281 panic!("built-in {name}@{version}: {defect}");
3282 }
3283 }
3284 let mut newest: std::collections::HashMap<
3286 String,
3287 &std::sync::Arc<memstead_schema::Schema>,
3288 > = std::collections::HashMap::new();
3289 for schema in &schemas {
3290 let name = schema.manifest.name.clone();
3291 match newest.get(&name) {
3292 Some(cur) if cur.version >= schema.version => {}
3293 _ => {
3294 newest.insert(name, schema);
3295 }
3296 }
3297 }
3298 for (name, schema) in &newest {
3299 for (type_name, td) in &schema.types {
3300 assert!(
3301 td.exemplar.is_some(),
3302 "built-in {name}@{} type '{type_name}' has no exemplar — the \
3303 reference schemas model the practice completely",
3304 schema.version
3305 );
3306 }
3307 }
3308 }
3309
3310 #[test]
3315 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
3316 let mut files = exemplar_package_files("body", "draft", "PART_OF");
3317 let patched = String::from_utf8(files[1].1.clone())
3318 .unwrap()
3319 .replace("to: parent-placeholder", "to: other--real-entity");
3320 files[1].1 = patched.into_bytes();
3321 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3322 .expect_err("mem-prefixed exemplar target must refuse");
3323 assert!(
3324 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3325 if message.contains("bare") && message.contains("'sample'")),
3326 "got {err:?}"
3327 );
3328 }
3329
3330 #[test]
3334 fn install_gate_refuses_manifest_identity_mismatch() {
3335 let err = Engine::validate_schema_package(
3336 "gate",
3337 "1.0.0",
3338 &schema_package_files("Body", "other"),
3339 )
3340 .expect_err("identity mismatch must refuse install");
3341 assert!(
3342 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3343 if message.contains("other@1.0.0")),
3344 "got {err:?}"
3345 );
3346 }
3347
3348 #[test]
3349 fn reload_each_writable_mem_repopulates_load_warnings() {
3350 let tmp = TempDir::new().unwrap();
3354 let mem_dir = tmp.path().to_path_buf();
3355 let writer = FilesystemMemWriter::new(mem_dir.clone());
3356 let mut mount = folder_mount("specs", mem_dir.clone());
3359 mount.schema = Some("default@1.3.0".parse().unwrap());
3360 let mut engine =
3361 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3362 assert!(
3365 engine
3366 .load_warnings()
3367 .iter()
3368 .all(|w| w.code() == "MOUNT_UNBACKED"),
3369 "clean boot has no warnings beyond the empty-mount one: {:?}",
3370 engine.load_warnings()
3371 );
3372
3373 let body =
3375 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3376 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3377
3378 engine.reload_each_writable_mem().unwrap();
3379 let warnings = engine.load_warnings();
3380 assert!(
3381 warnings
3382 .iter()
3383 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3384 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3385 );
3386 }
3387
3388 #[test]
3395 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3396 let tmp = TempDir::new().unwrap();
3397 let mem_dir = tmp.path().to_path_buf();
3398 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3400 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3401 let writer = FilesystemMemWriter::new(mem_dir.clone());
3402 let mut engine = Engine::from_mounts(vec![(
3403 folder_mount("specs", mem_dir.clone()),
3404 Box::new(writer) as Box<dyn MemBackend>,
3405 )])
3406 .unwrap();
3407 assert!(
3409 !engine
3410 .load_warnings()
3411 .iter()
3412 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3413 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3414 engine.load_warnings()
3415 );
3416
3417 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";
3419 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3420
3421 engine.reload_each_writable_mem().unwrap();
3422
3423 let invalid: Vec<_> = engine
3424 .load_warnings()
3425 .iter()
3426 .filter_map(|w| match w {
3427 WarningHint::ParsedRelationInvalid {
3428 rel_type,
3429 reason,
3430 origin,
3431 ..
3432 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3433 _ => None,
3434 })
3435 .collect();
3436 assert_eq!(
3437 invalid.len(),
3438 1,
3439 "reload must surface the parse-time drift, got: {invalid:?}",
3440 );
3441 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3442 assert_eq!(invalid[0].1, "unknown_rel_type");
3443 assert_eq!(invalid[0].2, "writable");
3444 }
3445
3446 #[test]
3447 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3448 let tmp = TempDir::new().unwrap();
3455 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3456 let a_dir = tmp.path().join("a");
3457 std::fs::create_dir_all(&a_dir).unwrap();
3458 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3459 let b_dir = tmp.path().join("b");
3460 std::fs::create_dir_all(&b_dir).unwrap();
3461 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3462 let mut engine = Engine::from_mounts(vec![
3463 (
3464 folder_mount("alpha", a_dir.clone()),
3465 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3466 ),
3467 (
3468 folder_mount("beta", b_dir.clone()),
3469 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3470 ),
3471 ])
3472 .unwrap();
3473 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3474 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3475 assert!(
3476 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3477 "boot must populate one warning per mem: {pre:?}"
3478 );
3479
3480 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3482 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3483 engine.reload_one_mem("alpha").unwrap();
3484
3485 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3486 assert!(
3487 !post.contains(&"alpha".to_string()),
3488 "reload must drop the healed mem's stale warning: {post:?}"
3489 );
3490 assert!(
3491 post.contains(&"beta".to_string()),
3492 "reload of alpha must not clear beta's slice: {post:?}"
3493 );
3494 }
3495
3496 #[test]
3497 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3498 let tmp = TempDir::new().unwrap();
3503 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3504 let a_dir = tmp.path().join("a");
3505 std::fs::create_dir_all(&a_dir).unwrap();
3506 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3507 let b_dir = tmp.path().join("b");
3508 std::fs::create_dir_all(&b_dir).unwrap();
3509 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3510 let mut engine = Engine::from_mounts(vec![
3511 (
3512 folder_mount("alpha", a_dir.clone()),
3513 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3514 ),
3515 (
3516 folder_mount("beta", b_dir.clone()),
3517 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3518 ),
3519 ])
3520 .unwrap();
3521 assert!(
3522 engine
3523 .load_warnings()
3524 .iter()
3525 .any(|w| w.source_mem() == Some("alpha")),
3526 "boot must carry alpha-sourced warnings"
3527 );
3528
3529 engine.unregister_writable_mem("alpha").unwrap();
3530
3531 let post = engine.load_warnings();
3532 assert!(
3533 !post.iter().any(|w| w.source_mem() == Some("alpha")),
3534 "delete must purge the removed mem's warnings: {post:?}"
3535 );
3536 assert!(
3537 post.iter().any(|w| w.source_mem() == Some("beta")),
3538 "delete of alpha must keep beta's warnings: {post:?}"
3539 );
3540 }
3541
3542 #[test]
3543 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3544 let tmp = TempDir::new().unwrap();
3550 let a_dir = tmp.path().join("a");
3551 std::fs::create_dir_all(&a_dir).unwrap();
3552 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";
3553 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3554 let b_dir = tmp.path().join("b");
3555 std::fs::create_dir_all(&b_dir).unwrap();
3556 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3557 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3558 let mut engine = Engine::from_mounts(vec![
3559 (
3560 folder_mount("alpha", a_dir.clone()),
3561 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3562 ),
3563 (
3564 folder_mount("beta", b_dir.clone()),
3565 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3566 ),
3567 ])
3568 .unwrap();
3569 let alpha_sourced = |engine: &Engine| {
3570 engine
3571 .load_warnings()
3572 .iter()
3573 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3574 };
3575 assert!(
3576 alpha_sourced(&engine),
3577 "boot must flag alpha's invalid row: {:?}",
3578 engine.load_warnings()
3579 );
3580
3581 engine.unregister_writable_mem("beta").unwrap();
3582
3583 assert!(
3584 alpha_sourced(&engine),
3585 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3586 engine.load_warnings()
3587 );
3588 }
3589
3590 #[test]
3598 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3599 let tmp = TempDir::new().unwrap();
3600 let mem_dir = tmp.path().to_path_buf();
3601 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3602 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3603 let writer = FilesystemMemWriter::new(mem_dir.clone());
3604 let mut engine = Engine::from_mounts(vec![(
3605 folder_mount("specs", mem_dir.clone()),
3606 Box::new(writer) as Box<dyn MemBackend>,
3607 )])
3608 .unwrap();
3609 assert!(
3610 !engine.load_warnings().is_empty(),
3611 "boot must populate load_warnings"
3612 );
3613
3614 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3617 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3618 engine.reload_each_writable_mem_reports().unwrap();
3619 assert!(
3620 engine.load_warnings().is_empty(),
3621 "reports sweep must drop healed warnings: {:?}",
3622 engine.load_warnings()
3623 );
3624
3625 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3627 engine.reload_each_writable_mem_reports().unwrap();
3628 assert!(
3629 engine
3630 .load_warnings()
3631 .iter()
3632 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3633 "reports sweep must surface fresh drift: {:?}",
3634 engine.load_warnings()
3635 );
3636 }
3637
3638 #[test]
3646 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3647 let tmp = TempDir::new().unwrap();
3648 let a_dir = tmp.path().join("a");
3649 let b_dir = tmp.path().join("b");
3650 std::fs::create_dir_all(&a_dir).unwrap();
3651 std::fs::create_dir_all(&b_dir).unwrap();
3652 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3653 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3654 let mut engine = Engine::from_mounts(vec![
3655 (
3656 folder_mount("specs", a_dir),
3657 Box::new(a_writer) as Box<dyn MemBackend>,
3658 ),
3659 (
3660 folder_mount("memos", b_dir),
3661 Box::new(b_writer) as Box<dyn MemBackend>,
3662 ),
3663 ])
3664 .unwrap();
3665
3666 let mut settings = crate::workspace::WorkspaceSettings::default();
3668 settings.cross_mem_links.insert(
3669 "specs".to_string(),
3670 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3671 );
3672 engine.set_settings(settings);
3673
3674 let (actor, client) = cli_actor();
3675 let source = engine
3676 .create_entity(
3677 empty_create_args("specs", "Source"),
3678 actor,
3679 Some(&client),
3680 None,
3681 )
3682 .unwrap();
3683 let target = engine
3684 .create_entity(
3685 empty_create_args("memos", "Target"),
3686 actor,
3687 Some(&client),
3688 None,
3689 )
3690 .unwrap();
3691 engine
3692 .relate_entity(
3693 crate::engine::RelateEntityArgs {
3694 source: source.id.clone(),
3695 expected_hash: Some(source.content_hash.clone()),
3696 rel_type: "USES".to_string(),
3697 target: target.id.clone(),
3698 remove: false,
3699 description: None,
3700 dry_run: false,
3701 },
3702 actor,
3703 Some(&client),
3704 None,
3705 )
3706 .unwrap();
3707
3708 let has_edge = |e: &Engine| {
3710 let out = e
3711 .store()
3712 .outgoing(&source.id)
3713 .iter()
3714 .any(|edge| edge.target == target.id);
3715 let inc = e
3716 .store()
3717 .incoming(&target.id)
3718 .iter()
3719 .any(|edge| edge.from == source.id);
3720 (out, inc)
3721 };
3722
3723 assert_eq!(
3724 has_edge(&engine),
3725 (true, true),
3726 "edge must be indexed in both directions after relate",
3727 );
3728
3729 engine.reload_one_mem("memos").unwrap();
3731 assert_eq!(
3732 has_edge(&engine),
3733 (true, true),
3734 "cross-mem edge into B must survive a per-mem reload of B",
3735 );
3736
3737 engine.reload_each_writable_mem().unwrap();
3740 assert_eq!(
3741 has_edge(&engine),
3742 (true, true),
3743 "per-mem and workspace reload converge on the same edge",
3744 );
3745
3746 assert!(
3749 engine
3750 .store()
3751 .get(&source.id)
3752 .unwrap()
3753 .relationships
3754 .iter()
3755 .any(|r| r.target == target.id),
3756 "source record must retain the relationship throughout",
3757 );
3758 }
3759
3760 #[test]
3766 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3767 let tmp = TempDir::new().unwrap();
3768 let a_dir = tmp.path().join("a");
3769 let b_dir = tmp.path().join("b");
3770 std::fs::create_dir_all(&a_dir).unwrap();
3771 std::fs::create_dir_all(&b_dir).unwrap();
3772 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3773 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3774 let mut engine = Engine::from_mounts(vec![
3775 (
3776 folder_mount("specs", a_dir),
3777 Box::new(a_writer) as Box<dyn MemBackend>,
3778 ),
3779 (
3780 folder_mount("memos", b_dir),
3781 Box::new(b_writer) as Box<dyn MemBackend>,
3782 ),
3783 ])
3784 .unwrap();
3785 let mut settings = crate::workspace::WorkspaceSettings::default();
3786 settings.cross_mem_links.insert(
3787 "specs".to_string(),
3788 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3789 );
3790 engine.set_settings(settings);
3791
3792 let (actor, client) = cli_actor();
3793 let source = engine
3794 .create_entity(
3795 empty_create_args("specs", "Source"),
3796 actor,
3797 Some(&client),
3798 None,
3799 )
3800 .unwrap();
3801 let target = engine
3802 .create_entity(
3803 empty_create_args("memos", "Target"),
3804 actor,
3805 Some(&client),
3806 None,
3807 )
3808 .unwrap();
3809 engine
3810 .relate_entity(
3811 crate::engine::RelateEntityArgs {
3812 source: source.id.clone(),
3813 expected_hash: Some(source.content_hash.clone()),
3814 rel_type: "USES".to_string(),
3815 target: target.id.clone(),
3816 remove: false,
3817 description: None,
3818 dry_run: false,
3819 },
3820 actor,
3821 Some(&client),
3822 None,
3823 )
3824 .unwrap();
3825
3826 engine.reload_one_mem("specs").unwrap();
3827
3828 let out = engine
3829 .store()
3830 .outgoing(&source.id)
3831 .iter()
3832 .any(|edge| edge.target == target.id);
3833 let inc = engine
3834 .store()
3835 .incoming(&target.id)
3836 .iter()
3837 .any(|edge| edge.from == source.id);
3838 assert!(
3839 out && inc,
3840 "outgoing cross-mem edge must survive a source-mem reload"
3841 );
3842 }
3843
3844 #[test]
3845 fn workspace_root_setter_round_trips() {
3846 let tmp = TempDir::new().unwrap();
3847 let mem_dir = tmp.path().to_path_buf();
3848 let writer = FilesystemMemWriter::new(mem_dir.clone());
3849 let mut engine = Engine::from_mounts(vec![(
3850 folder_mount("specs", mem_dir),
3851 Box::new(writer) as Box<dyn MemBackend>,
3852 )])
3853 .unwrap();
3854 let root = tmp.path().to_path_buf();
3855 engine.set_workspace_root(root.clone());
3856 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3857 }
3858
3859 #[test]
3860 fn export_mem_folder_backend_produces_archive() {
3861 let tmp = TempDir::new().unwrap();
3865 let mem_dir = tmp.path().join("specs");
3866 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3867 let config_body = r#"{
3868 "format": 1,
3869 "schema": "default@1.0.0",
3870 "version": "1.0.0"
3871 }"#;
3872 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3873
3874 let writer = FilesystemMemWriter::new(mem_dir.clone());
3875 let engine = Engine::from_mounts(vec![(
3876 folder_mount("specs", mem_dir.clone()),
3877 Box::new(writer) as Box<dyn MemBackend>,
3878 )])
3879 .unwrap();
3880
3881 let archive_path = tmp.path().join("specs.mem");
3882 let result = engine.export_mem("specs", &archive_path).unwrap();
3883 assert!(archive_path.exists(), "archive must exist on disk");
3884 assert!(result.size_bytes > 0);
3885 assert_eq!(result.entity_count, 0);
3888 }
3889
3890 #[test]
3891 fn export_mem_unknown_mem_returns_unknown_mem() {
3892 let tmp = TempDir::new().unwrap();
3893 let mem_dir = tmp.path().to_path_buf();
3894 let writer = FilesystemMemWriter::new(mem_dir.clone());
3895 let engine = Engine::from_mounts(vec![(
3896 folder_mount("specs", mem_dir),
3897 Box::new(writer) as Box<dyn MemBackend>,
3898 )])
3899 .unwrap();
3900 let output = tmp.path().join("out.mem");
3901 let err = engine.export_mem("missing", &output).unwrap_err();
3902 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3903 }
3904
3905 #[test]
3906 fn export_mem_missing_config_returns_invalid_input() {
3907 let tmp = TempDir::new().unwrap();
3911 let mem_dir = tmp.path().to_path_buf();
3912 let writer = FilesystemMemWriter::new(mem_dir.clone());
3913 let engine = Engine::from_mounts(vec![(
3914 folder_mount("specs", mem_dir),
3915 Box::new(writer) as Box<dyn MemBackend>,
3916 )])
3917 .unwrap();
3918 let output = tmp.path().join("out.mem");
3919 let err = engine.export_mem("specs", &output).unwrap_err();
3920 assert!(matches!(err, EngineError::InvalidInput(_)));
3921 }
3922
3923 #[test]
3924 fn export_mem_archive_backend_returns_sealed() {
3925 let tmp = TempDir::new().unwrap();
3928 let archive_path = build_archive(
3929 tmp.path(),
3930 "ext",
3931 &[(
3932 ".memstead/config.json",
3933 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3934 )],
3935 );
3936 let engine = Engine::from_mounts(vec![(
3937 archive_mount("ext", archive_path.clone()),
3938 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3939 )])
3940 .unwrap();
3941 let output = tmp.path().join("out.mem");
3942 let err = engine.export_mem("ext", &output).unwrap_err();
3943 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3944 }
3945
3946 #[test]
3947 fn export_markdown_writes_unchanged_files_zero_writes() {
3948 let tmp = TempDir::new().unwrap();
3953 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3954 let result = engine.export_markdown(None, None).unwrap();
3955 assert_eq!(
3956 result.written, 0,
3957 "freshly-created entity's file already matches generated markdown"
3958 );
3959 assert_eq!(
3960 result.unchanged, 1,
3961 "the one seeded entity counts as unchanged"
3962 );
3963 assert!(
3964 result.skipped_mounts.is_empty(),
3965 "folder-only workspace has no skipped mounts"
3966 );
3967 }
3968
3969 #[test]
3970 fn export_markdown_skips_non_folder_mounts() {
3971 let tmp = TempDir::new().unwrap();
3975 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3976 let engine = Engine::from_mounts(vec![(
3977 archive_mount("ext", archive_path.clone()),
3978 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3979 )])
3980 .unwrap();
3981 let result = engine.export_markdown(None, None).unwrap();
3982 assert_eq!(result.written, 0);
3983 assert_eq!(result.unchanged, 0);
3984 assert_eq!(
3985 result.skipped_mounts.len(),
3986 1,
3987 "archive mount is in the skipped list"
3988 );
3989 let entry = &result.skipped_mounts[0];
3990 assert_eq!(entry.mem, "ext");
3991 assert_eq!(entry.active_backend, "archive");
3992 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
3993 }
3994
3995 #[test]
3996 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
3997 let tmp = TempDir::new().unwrap();
4001 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4002 let engine = Engine::from_mounts(vec![(
4003 archive_mount("ext", archive_path.clone()),
4004 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4005 )])
4006 .unwrap();
4007 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
4008 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
4009 let details = err.details();
4010 assert_eq!(details["mem"], "ext");
4011 assert_eq!(details["active_backend"], "archive");
4012 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
4013 }
4014
4015 #[test]
4016 fn register_writable_mem_adds_mount_and_router_entry() {
4017 let tmp = TempDir::new().unwrap();
4020 let mem_a = tmp.path().join("a");
4021 std::fs::create_dir_all(&mem_a).unwrap();
4022 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4023
4024 let mut engine = Engine::from_mounts(vec![(
4025 folder_mount("alpha", mem_a),
4026 Box::new(writer_a) as Box<dyn MemBackend>,
4027 )])
4028 .unwrap();
4029 assert!(engine.mem_router().is_writable("alpha"));
4030
4031 let mem_b = tmp.path().join("b");
4032 std::fs::create_dir_all(&mem_b).unwrap();
4033 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4034
4035 engine
4036 .register_writable_mem(
4037 folder_mount("beta", mem_b.clone()),
4038 Box::new(writer_b) as Box<dyn MemBackend>,
4039 MemOrigin::ExplicitToml,
4040 )
4041 .unwrap();
4042
4043 assert!(engine.mem_router().is_writable("alpha"));
4045 assert!(engine.mem_router().is_writable("beta"));
4046 assert!(engine.mem_router().is_visible("beta"));
4047
4048 assert!(engine.mount("beta").is_some());
4050 assert!(engine.schemas().contains_key("beta"));
4051
4052 assert_eq!(
4054 engine.mem_router().dir_for_mem("beta"),
4055 Some(mem_b.as_path()),
4056 );
4057 }
4058
4059 #[test]
4065 fn register_writable_mem_resolves_schema_from_mem_config() {
4066 let tmp = TempDir::new().unwrap();
4067 let mem_a = tmp.path().join("a");
4068 std::fs::create_dir_all(&mem_a).unwrap();
4069 let mut engine = Engine::from_mounts(vec![(
4070 folder_mount("alpha", mem_a.clone()),
4071 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
4072 )])
4073 .unwrap();
4074
4075 let mem_b = tmp.path().join("b");
4076 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
4077 std::fs::write(
4078 mem_b.join(".memstead").join("config.json"),
4079 r#"{"schema":"software@0.1.0"}"#,
4080 )
4081 .unwrap();
4082 let mount_b = crate::workspace::Mount {
4083 mem: "beta".to_string(),
4084 schema: Some(memstead_schema::SchemaRef::new(
4085 "totally-not-a-schema",
4086 semver::Version::new(9, 9, 9),
4087 )),
4088 storage: crate::workspace::MountStorage::Folder {
4089 path: mem_b.clone(),
4090 },
4091 capability: crate::workspace::MountCapability::Write,
4092 lifecycle: crate::workspace::MountLifecycle::Eager,
4093 cross_linkable: true,
4094 migration_target: None,
4095 };
4096 engine
4097 .register_writable_mem(
4098 mount_b,
4099 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
4100 MemOrigin::ExplicitToml,
4101 )
4102 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
4103
4104 assert!(engine.schemas().contains_key("beta"));
4105 let surfaced = engine.load_warnings().iter().any(|w| {
4106 matches!(
4107 w,
4108 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
4109 if mem == "beta"
4110 && config_pin == "software@0.1.0"
4111 && mount_pin == "totally-not-a-schema@9.9.9"
4112 )
4113 });
4114 assert!(
4115 surfaced,
4116 "SchemaPinMismatch must surface for beta: {:?}",
4117 engine.load_warnings(),
4118 );
4119 }
4120
4121 #[test]
4122 fn register_writable_mem_rejects_existing_name() {
4123 let tmp = TempDir::new().unwrap();
4126 let mem_a = tmp.path().join("a");
4127 std::fs::create_dir_all(&mem_a).unwrap();
4128 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4129
4130 let mut engine = Engine::from_mounts(vec![(
4131 folder_mount("alpha", mem_a),
4132 Box::new(writer_a) as Box<dyn MemBackend>,
4133 )])
4134 .unwrap();
4135 let mount_count_pre = engine.mounts().len();
4136
4137 let mem_collide = tmp.path().join("alpha-2");
4138 std::fs::create_dir_all(&mem_collide).unwrap();
4139 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
4140
4141 let err = engine
4142 .register_writable_mem(
4143 folder_mount("alpha", mem_collide),
4144 Box::new(writer_collide) as Box<dyn MemBackend>,
4145 MemOrigin::ExplicitToml,
4146 )
4147 .unwrap_err();
4148 match err {
4149 EngineError::MemNameCollision {
4150 name,
4151 source_origin,
4152 } => {
4153 assert_eq!(name, "alpha");
4154 assert!(
4159 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
4160 );
4161 }
4162 other => panic!("expected MemNameCollision, got {other:?}"),
4163 }
4164
4165 assert_eq!(engine.mounts().len(), mount_count_pre);
4167 }
4168
4169 #[test]
4170 fn register_writable_mem_loads_entities_into_store() {
4171 let tmp = TempDir::new().unwrap();
4174 let mem_a = tmp.path().join("a");
4175 std::fs::create_dir_all(&mem_a).unwrap();
4176 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4177
4178 let mut engine = Engine::from_mounts(vec![(
4179 folder_mount("alpha", mem_a),
4180 Box::new(writer_a) as Box<dyn MemBackend>,
4181 )])
4182 .unwrap();
4183 let pre_count = engine.store().all_entities().count();
4184
4185 let mem_b = tmp.path().join("b");
4187 std::fs::create_dir_all(&mem_b).unwrap();
4188 std::fs::write(
4189 mem_b.join("b1.md"),
4190 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4191 )
4192 .unwrap();
4193 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4194
4195 engine
4196 .register_writable_mem(
4197 folder_mount("beta", mem_b),
4198 Box::new(writer_b) as Box<dyn MemBackend>,
4199 MemOrigin::ExplicitToml,
4200 )
4201 .unwrap();
4202
4203 let post_count = engine.store().all_entities().count();
4204 assert!(post_count > pre_count, "register must load entities");
4205 let beta_count = engine
4206 .store()
4207 .all_entities()
4208 .filter(|e| e.mem == "beta")
4209 .count();
4210 assert_eq!(beta_count, 1);
4211 }
4212
4213 #[test]
4214 fn register_then_unregister_round_trips() {
4215 let tmp = TempDir::new().unwrap();
4219 let mem_a = tmp.path().join("a");
4220 std::fs::create_dir_all(&mem_a).unwrap();
4221 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4222
4223 let mut engine = Engine::from_mounts(vec![(
4224 folder_mount("alpha", mem_a),
4225 Box::new(writer_a) as Box<dyn MemBackend>,
4226 )])
4227 .unwrap();
4228 let pre_mounts = engine.mounts().len();
4229
4230 let mem_b = tmp.path().join("b");
4231 std::fs::create_dir_all(&mem_b).unwrap();
4232 let writer_b = FilesystemMemWriter::new(mem_b);
4233
4234 engine
4235 .register_writable_mem(
4236 folder_mount("beta", tmp.path().join("b")),
4237 Box::new(writer_b) as Box<dyn MemBackend>,
4238 MemOrigin::ExplicitToml,
4239 )
4240 .unwrap();
4241 assert_eq!(engine.mounts().len(), pre_mounts + 1);
4242
4243 let removed = engine.unregister_writable_mem("beta").unwrap();
4244 assert!(removed.is_some());
4245 assert_eq!(engine.mounts().len(), pre_mounts);
4246 assert!(!engine.mem_router().is_writable("beta"));
4247 }
4248
4249 #[test]
4250 fn unregister_writable_mem_returns_false_for_unknown_name() {
4251 let tmp = TempDir::new().unwrap();
4255 let mem_dir = tmp.path().to_path_buf();
4256 let writer = FilesystemMemWriter::new(mem_dir.clone());
4257 let mut engine = Engine::from_mounts(vec![(
4258 folder_mount("specs", mem_dir),
4259 Box::new(writer) as Box<dyn MemBackend>,
4260 )])
4261 .unwrap();
4262 let removed = engine.unregister_writable_mem("missing").unwrap();
4263 assert!(removed.is_none(), "unknown mem returns Ok(None)");
4264 assert!(engine.mem_router().is_writable("specs"));
4266 }
4267
4268 #[test]
4269 fn unregister_writable_mem_drops_mount_and_router_entry() {
4270 let tmp = TempDir::new().unwrap();
4275 let mem_a = tmp.path().join("a");
4276 std::fs::create_dir_all(&mem_a).unwrap();
4277 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4278 let mem_b = tmp.path().join("b");
4279 std::fs::create_dir_all(&mem_b).unwrap();
4280 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4281
4282 let mut engine = Engine::from_mounts(vec![
4283 (
4284 folder_mount("alpha", mem_a),
4285 Box::new(writer_a) as Box<dyn MemBackend>,
4286 ),
4287 (
4288 folder_mount("beta", mem_b),
4289 Box::new(writer_b) as Box<dyn MemBackend>,
4290 ),
4291 ])
4292 .unwrap();
4293
4294 let removed = engine.unregister_writable_mem("alpha").unwrap();
4295 assert!(removed.is_some());
4296
4297 assert!(!engine.mem_router().is_writable("alpha"));
4299 assert!(!engine.mem_router().is_visible("alpha"));
4300 assert!(engine.mount("alpha").is_none());
4301
4302 assert!(engine.mem_router().is_writable("beta"));
4304 assert!(engine.mount("beta").is_some());
4305 }
4306
4307 #[test]
4308 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
4309 let tmp = TempDir::new().unwrap();
4313 let mem_a = tmp.path().join("a");
4314 std::fs::create_dir_all(&mem_a).unwrap();
4315 std::fs::write(
4316 mem_a.join("a1.md"),
4317 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
4318 )
4319 .unwrap();
4320 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4321
4322 let mem_b = tmp.path().join("b");
4323 std::fs::create_dir_all(&mem_b).unwrap();
4324 std::fs::write(
4325 mem_b.join("b1.md"),
4326 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4327 )
4328 .unwrap();
4329 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4330
4331 let mut engine = Engine::from_mounts(vec![
4332 (
4333 folder_mount("alpha", mem_a),
4334 Box::new(writer_a) as Box<dyn MemBackend>,
4335 ),
4336 (
4337 folder_mount("beta", mem_b),
4338 Box::new(writer_b) as Box<dyn MemBackend>,
4339 ),
4340 ])
4341 .unwrap();
4342
4343 let pre_total = engine.store().all_entities().count();
4344 assert!(pre_total >= 2, "both mems must load entities");
4345
4346 engine.unregister_writable_mem("alpha").unwrap();
4347
4348 let alpha_remaining = engine
4350 .store()
4351 .all_entities()
4352 .filter(|e| e.mem == "alpha")
4353 .count();
4354 assert_eq!(alpha_remaining, 0);
4355
4356 let beta_remaining = engine
4358 .store()
4359 .all_entities()
4360 .filter(|e| e.mem == "beta")
4361 .count();
4362 assert!(beta_remaining > 0, "beta entities must survive");
4363 }
4364 #[test]
4365 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4366 let tmp = TempDir::new().unwrap();
4367 let mut engine = build_demo_engine(&tmp);
4368 let result = engine
4369 .reload_one_mem("specs")
4370 .expect("reload on stable disk must succeed");
4371 assert!(result.added.is_empty(), "added: {:?}", result.added);
4372 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4373 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4374 }
4375
4376 #[test]
4377 fn reload_one_mem_picks_up_external_addition() {
4378 let tmp = TempDir::new().unwrap();
4379 let mut engine = build_demo_engine(&tmp);
4380 std::fs::write(
4383 tmp.path().join("external.md"),
4384 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4385 )
4386 .unwrap();
4387 let result = engine.reload_one_mem("specs").unwrap();
4388 assert_eq!(
4389 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4390 vec!["specs--external"]
4391 );
4392 assert!(result.changed.is_empty());
4393 assert!(result.removed.is_empty());
4394 assert!(
4396 engine
4397 .get_entity(&crate::EntityId::new("specs", "external"))
4398 .is_some()
4399 );
4400 }
4401
4402 #[test]
4403 fn reload_one_mem_picks_up_external_removal() {
4404 let tmp = TempDir::new().unwrap();
4405 let mut engine = build_demo_engine(&tmp);
4406 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4409 let result = engine.reload_one_mem("specs").unwrap();
4410 assert!(result.added.is_empty());
4411 assert!(result.changed.is_empty());
4412 assert_eq!(
4413 result
4414 .removed
4415 .iter()
4416 .map(|i| i.as_ref())
4417 .collect::<Vec<_>>(),
4418 vec!["specs--lonely-three"]
4419 );
4420 }
4421
4422 #[test]
4423 fn reload_one_mem_picks_up_external_change() {
4424 let tmp = TempDir::new().unwrap();
4425 let mut engine = build_demo_engine(&tmp);
4426 std::fs::write(
4429 tmp.path().join("source-one.md"),
4430 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4431 )
4432 .unwrap();
4433 let result = engine.reload_one_mem("specs").unwrap();
4434 assert!(result.added.is_empty());
4435 assert_eq!(
4436 result
4437 .changed
4438 .iter()
4439 .map(|i| i.as_ref())
4440 .collect::<Vec<_>>(),
4441 vec!["specs--source-one"]
4442 );
4443 assert!(result.removed.is_empty());
4444 }
4445
4446 #[test]
4447 fn reload_one_mem_rejects_unknown_mem() {
4448 let tmp = TempDir::new().unwrap();
4449 let mut engine = build_demo_engine(&tmp);
4450 let err = engine.reload_one_mem("nope").unwrap_err();
4451 match err {
4452 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4453 other => panic!("expected UnknownMem, got {other:?}"),
4454 }
4455 }
4456
4457 #[test]
4458 fn reload_each_writable_mem_returns_one_entry_per_mount() {
4459 let tmp = TempDir::new().unwrap();
4460 let mut engine = build_demo_engine(&tmp);
4461 let reports = engine
4462 .reload_each_writable_mem()
4463 .expect("batch reload on stable disk must succeed");
4464 assert_eq!(reports.len(), 1);
4465 assert_eq!(reports[0].0, "specs");
4466 assert!(reports[0].1.added.is_empty());
4467 assert!(reports[0].1.changed.is_empty());
4468 assert!(reports[0].1.removed.is_empty());
4469 }
4470
4471 #[test]
4474 fn settings_default_to_empty_on_fresh_engine() {
4475 let tmp = TempDir::new().unwrap();
4476 let engine = build_demo_engine(&tmp);
4477 let s = engine.settings();
4478 assert!(s.mem_create_rules.is_empty());
4479 assert!(s.mem_delete_rules.is_empty());
4480 assert!(s.cross_mem_links.is_empty());
4481 }
4482
4483 #[test]
4484 fn set_settings_replaces_workspace_policy() {
4485 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4486 let tmp = TempDir::new().unwrap();
4487 let mut engine = build_demo_engine(&tmp);
4488 let mut settings = WorkspaceSettings::default();
4489 settings.mem_create_rules.push(CreateRuleSetting {
4490 pattern: "exec-*".to_string(),
4491 schemas: vec!["default@1.0.0".to_string()],
4492 default_cross_links: None,
4493 });
4494 settings.mem_delete_rules.push(DeleteRuleSetting {
4495 pattern: "exec-*".to_string(),
4496 });
4497 engine.set_settings(settings);
4498 assert_eq!(engine.settings().mem_create_rules.len(), 1);
4499 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4500 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4501 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4502 }
4503
4504 #[test]
4507 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4508 let tmp = TempDir::new().unwrap();
4509 let mut engine = build_demo_engine(&tmp);
4510 std::fs::write(
4512 tmp.path().join("new-via-disk.md"),
4513 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4514 )
4515 .unwrap();
4516 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4517 std::fs::write(
4518 tmp.path().join("source-one.md"),
4519 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4520 )
4521 .unwrap();
4522
4523 let reports = engine.reload_each_writable_mem().unwrap();
4524 assert_eq!(reports.len(), 1);
4525 let (mem, result) = &reports[0];
4526 assert_eq!(mem, "specs");
4527 assert_eq!(
4528 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4529 vec!["specs--new-via-disk"]
4530 );
4531 assert_eq!(
4532 result
4533 .removed
4534 .iter()
4535 .map(|i| i.as_ref())
4536 .collect::<Vec<_>>(),
4537 vec!["specs--lonely-three"]
4538 );
4539 assert_eq!(
4540 result
4541 .changed
4542 .iter()
4543 .map(|i| i.as_ref())
4544 .collect::<Vec<_>>(),
4545 vec!["specs--source-one"]
4546 );
4547 }
4548
4549 #[test]
4552 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4553 let tmp = TempDir::new().unwrap();
4561 let mut engine = build_demo_engine(&tmp);
4562 let report = engine.reload_one_mem_report("specs").unwrap();
4563 assert_eq!(report.mem, "specs");
4564 assert_eq!(
4565 report.head_before, report.head_after,
4566 "unchanged disk → stable cursor"
4567 );
4568 assert!(
4569 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4570 "folder heads carry the changelog-ts cursor, got {}",
4571 report.head_after
4572 );
4573 assert_eq!(report.entities_loaded, 3);
4576 assert!(report.changed_entity_ids.is_empty());
4578 }
4579
4580 #[test]
4581 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4582 let tmp = TempDir::new().unwrap();
4587 let mut engine = build_demo_engine(&tmp);
4588 std::fs::write(
4589 tmp.path().join("new-via-disk.md"),
4590 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4591 )
4592 .unwrap();
4593 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4594 std::fs::write(
4595 tmp.path().join("source-one.md"),
4596 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4597 )
4598 .unwrap();
4599
4600 let report = engine.reload_one_mem_report("specs").unwrap();
4601 assert_eq!(report.mem, "specs");
4602 let ids: Vec<&str> = report
4603 .changed_entity_ids
4604 .iter()
4605 .map(|id| id.as_ref())
4606 .collect();
4607 assert_eq!(
4609 ids,
4610 vec![
4611 "specs--lonely-three",
4612 "specs--new-via-disk",
4613 "specs--source-one",
4614 ]
4615 );
4616 }
4617
4618 #[test]
4619 fn reload_one_mem_report_rejects_unknown_mem() {
4620 let tmp = TempDir::new().unwrap();
4621 let mut engine = build_demo_engine(&tmp);
4622 let err = engine.reload_one_mem_report("missing").unwrap_err();
4623 assert!(matches!(err, EngineError::UnknownMem(_)));
4624 }
4625
4626 #[test]
4627 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4628 let tmp = TempDir::new().unwrap();
4629 let mut engine = build_demo_engine(&tmp);
4630 let reports = engine.reload_each_writable_mem_reports().unwrap();
4631 assert_eq!(reports.len(), 1);
4632 assert_eq!(reports[0].mem, "specs");
4633 assert_eq!(reports[0].entities_loaded, 3);
4634 }
4635
4636 #[test]
4643 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4644 let tmp = TempDir::new().unwrap();
4645
4646 let memstead_dir = tmp.path().join(".memstead");
4649 std::fs::create_dir_all(&memstead_dir).unwrap();
4650 let workspace_toml = memstead_dir.join("workspace.toml");
4651 std::fs::write(
4652 &workspace_toml,
4653 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4654 )
4655 .unwrap();
4656 let mounts_json = memstead_dir.join("state").join("mounts.json");
4657 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4658 let mem_dir = tmp.path().join("specs");
4659 std::fs::create_dir_all(&mem_dir).unwrap();
4660 let mounts_body = format!(
4661 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4662 mem_dir.display(),
4663 );
4664 std::fs::write(&mounts_json, mounts_body).unwrap();
4665
4666 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4667 assert!(
4668 engine.settings().mem_create_rules.is_empty(),
4669 "boot-time settings carry no create rules"
4670 );
4671
4672 std::fs::write(
4674 &workspace_toml,
4675 "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",
4676 )
4677 .unwrap();
4678
4679 engine.reload_each_writable_mem_reports().unwrap();
4680
4681 let rules = &engine.settings().mem_create_rules;
4682 assert_eq!(
4683 rules.len(),
4684 1,
4685 "workspace-wide reload must refresh the policy"
4686 );
4687 assert_eq!(rules[0].pattern, "exec-*");
4688 }
4689
4690 const MIG_TYPE_TAIL: &str = r#"sections:
4695 - key: body
4696 heading: Body
4697 required: true
4698 search_weight: 10.0
4699 catch_all: true
4700 write_rules: []
4701title_weight: 100.0
4702text_fields:
4703 - body
4704hierarchy_relationship: _default
4705no_self_loop_relationships: []
4706updatable_fields: []
4707health_required_fields: []
4708staleness_threshold_days: 90
4709write_rules: []
4710"#;
4711
4712 fn mig_manifest(name: &str, version: &str) -> String {
4717 format!(
4718 r#"name: {name}
4719version: {version}
4720description: migration test schema
4721when_to_use: tests
4722types:
4723 - doc
4724relationships:
4725 mode: strict
4726 definitions:
4727 - name: USES
4728 description: link
4729 default_weight: 1.0
4730 - name: _default
4731 description: fallback
4732 default_weight: 1.0
4733community:
4734 resolution: 1.0
4735 seed: 42
4736"#
4737 )
4738 }
4739
4740 fn mig_type_yaml(with_status: bool) -> String {
4741 let metadata = if with_status {
4742 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4743 } else {
4744 "metadata_fields: []\n"
4745 };
4746 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4747 }
4748
4749 fn write_mig_schema(
4750 root: &std::path::Path,
4751 dir: &str,
4752 name: &str,
4753 version: &str,
4754 with_status: bool,
4755 ) {
4756 let d = root.join(dir);
4757 std::fs::create_dir_all(d.join("types")).unwrap();
4758 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4759 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4760 }
4761
4762 #[test]
4779 fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4780 for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4781 run_mutation_sequence(seed);
4782 }
4783 }
4784
4785 struct Xorshift(u64);
4786 impl Xorshift {
4787 fn next(&mut self) -> u64 {
4788 let mut x = self.0;
4789 x ^= x << 13;
4790 x ^= x >> 7;
4791 x ^= x << 17;
4792 self.0 = x;
4793 x
4794 }
4795 fn pick(&mut self, n: usize) -> usize {
4796 (self.next() % n as u64) as usize
4797 }
4798 }
4799
4800 fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4801 let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4804 let live = engine.search_indexes();
4805 let mut live_mems: Vec<&String> = live.keys().collect();
4806 let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4807 live_mems.sort();
4808 fresh_mems.sort();
4809 assert_eq!(
4810 live_mems, fresh_mems,
4811 "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4812 );
4813 for (mem, idx) in live {
4814 let mut got = idx.stored_ids().unwrap();
4815 let mut want = fresh[mem].stored_ids().unwrap();
4816 got.sort();
4817 want.sort();
4818 assert_eq!(
4819 got, want,
4820 "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4821 );
4822 }
4823 let schema = engine
4826 .schemas
4827 .iter()
4828 .min_by(|a, b| a.0.cmp(b.0))
4829 .map(|(_, s)| s.clone())
4830 .expect("schema present");
4831 let weights_schema = schema.clone();
4832 let fresh_partition = crate::graph::community::detect_communities(
4833 engine.store(),
4834 schema.manifest.community.resolution,
4835 schema.manifest.community.seed,
4836 move |rel_type| {
4837 weights_schema
4838 .manifest
4839 .relationships
4840 .definitions
4841 .iter()
4842 .find(|d| d.name == rel_type)
4843 .map(|d| d.default_weight as f64)
4844 .unwrap_or(1.0)
4845 },
4846 );
4847 assert_eq!(
4848 engine.communities().entity_cluster_map,
4849 fresh_partition.entity_cluster_map,
4850 "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4851 );
4852 }
4853
4854 fn run_mutation_sequence(seed: u64) {
4855 use indexmap::IndexMap;
4856
4857 let (_tmp, mut engine) = migration_engine();
4858 let mut rng = Xorshift(seed);
4859 let mut live: Vec<crate::EntityId> = vec![
4860 crate::EntityId::new("specs", "one"),
4861 crate::EntityId::new("specs", "two"),
4862 ];
4863 let mut counter = 0usize;
4864 let mut kinds_hit: std::collections::HashSet<&'static str> =
4865 std::collections::HashSet::new();
4866
4867 const KINDS: [&str; 7] = [
4868 "create",
4869 "update",
4870 "relate",
4871 "delete",
4872 "rename",
4873 "batch_applied",
4874 "batch_refused",
4875 ];
4876
4877 let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4878 anchors: Vec::new(),
4879 anchors_unset: Vec::new(),
4880 id,
4881 expected_hash: None,
4882 sections: IndexMap::new(),
4883 append_sections: IndexMap::new(),
4884 patch_sections: IndexMap::new(),
4885 metadata: IndexMap::new(),
4886 metadata_unset: Vec::new(),
4887 declare_relations: Vec::new(),
4888 dry_run: false,
4889 relations_unset: Vec::new(),
4890 };
4891
4892 for op_i in 0..30usize {
4893 let kind = *KINDS
4896 .get(op_i)
4897 .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4898 match kind {
4899 "create" => {
4900 counter += 1;
4901 let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4902 args.entity_type = "doc".to_string();
4903 args.sections = IndexMap::from_iter([(
4904 "body".to_string(),
4905 format!("generated body {counter}"),
4906 )]);
4907 let out = engine
4908 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4909 .expect("generated create is conformant");
4910 live.push(out.id);
4911 kinds_hit.insert("create");
4912 }
4913 "update" => {
4914 let id = live[rng.pick(live.len())].clone();
4915 let mut args = bare_update(id);
4916 args.append_sections
4917 .insert("body".to_string(), format!("appended at op {op_i}"));
4918 engine
4919 .update_entity(args, crate::vcs::Actor::Cli, None, None)
4920 .expect("append update is conformant");
4921 kinds_hit.insert("update");
4922 }
4923 "relate" => {
4924 if live.len() >= 2 {
4925 let a = rng.pick(live.len());
4926 let mut b = rng.pick(live.len());
4927 if a == b {
4928 b = (b + 1) % live.len();
4929 }
4930 engine
4931 .relate_entity(
4932 crate::engine::RelateEntityArgs {
4933 source: live[a].clone(),
4934 expected_hash: None,
4935 rel_type: "USES".to_string(),
4936 target: live[b].clone(),
4937 remove: false,
4938 description: None,
4939 dry_run: false,
4940 },
4941 crate::vcs::Actor::Cli,
4942 None,
4943 None,
4944 )
4945 .expect("USES relate is legal under mig-a");
4946 kinds_hit.insert("relate");
4947 }
4948 }
4949 "delete" => {
4950 if live.len() > 2
4953 && let Some(pos) = (0..live.len()).find(|&i| {
4954 engine.store().incoming(&live[i]).is_empty()
4955 && engine
4956 .store()
4957 .get(&live[i])
4958 .is_some_and(|e| e.relationships.is_empty())
4959 })
4960 {
4961 let id = live.remove(pos);
4962 engine
4963 .delete_entity(
4964 crate::engine::DeleteEntityArgs {
4965 id: id.clone(),
4966 expected_hash: None,
4967 },
4968 crate::vcs::Actor::Cli,
4969 None,
4970 None,
4971 )
4972 .expect("reference-free delete lands");
4973 kinds_hit.insert("delete");
4974 }
4975 }
4976 "rename" => {
4977 counter += 1;
4978 let pos = rng.pick(live.len());
4979 let old = live[pos].clone();
4980 let out = engine
4981 .rename_entity(
4982 crate::engine::RenameEntityArgs {
4983 id: old,
4984 new_title: format!("Renamed {counter}"),
4985 expected_hash: None,
4986 },
4987 crate::vcs::Actor::Cli,
4988 None,
4989 None,
4990 )
4991 .expect("fresh-slug rename lands");
4992 live[pos] = out.new_id;
4993 kinds_hit.insert("rename");
4994 }
4995 "batch_applied" => {
4996 let id_a = live[rng.pick(live.len())].clone();
4997 let mut a = bare_update(id_a);
4998 a.append_sections
4999 .insert("body".to_string(), format!("batch line {op_i}"));
5000 let result = engine
5001 .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
5002 .expect("batch envelope");
5003 assert!(result.applied, "single-entry append batch applies");
5004 kinds_hit.insert("batch_applied");
5005 }
5006 "batch_refused" => {
5007 let id_a = live[rng.pick(live.len())].clone();
5008 let mut a = bare_update(id_a);
5009 a.append_sections
5010 .insert("body".to_string(), "doomed".to_string());
5011 let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
5012 let result = engine
5013 .batch_update(
5014 vec![(a, None), (missing, None)],
5015 crate::vcs::Actor::Cli,
5016 None,
5017 false,
5018 )
5019 .expect("refused batch returns a report-all envelope");
5020 assert!(!result.applied, "the missing target refuses the batch");
5021 kinds_hit.insert("batch_refused");
5022 }
5023 _ => unreachable!(),
5024 }
5025
5026 if op_i == 14 {
5027 engine
5031 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5032 .expect("integral switch");
5033 kinds_hit.insert("schema_switch");
5034 }
5035 if op_i == 19 {
5036 engine.reload_one_mem("specs").expect("reload lands");
5037 kinds_hit.insert("reload");
5038 }
5039
5040 if op_i % 10 == 9 {
5041 assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
5042 }
5043 }
5044
5045 assert_derived_oracles(&engine, seed, "sequence end");
5046
5047 for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
5048 assert!(
5049 kinds_hit.contains(kind),
5050 "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
5051 );
5052 }
5053 }
5054
5055 fn migration_engine() -> (tempfile::TempDir, Engine) {
5056 let tmp = tempfile::TempDir::new().unwrap();
5057 let schemas_dir = tmp.path().join("schemas");
5058 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5059 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5060 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
5061 let mem_dir = tmp.path().join("mem");
5062 std::fs::create_dir_all(&mem_dir).unwrap();
5063 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5064 let mut mount = folder_mount("specs", mem_dir);
5065 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5066 let mut engine = Engine::from_mounts_with_schemas_dir(
5067 vec![(
5068 mount,
5069 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5070 )],
5071 Some(&schemas_dir),
5072 )
5073 .unwrap();
5074 for title in ["One", "Two"] {
5075 let mut args = empty_create_args("specs", title);
5076 args.entity_type = "doc".to_string();
5077 args.sections =
5078 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
5079 engine
5080 .create_entity(args, crate::vcs::Actor::Cli, None, None)
5081 .expect("conformant create under mig-a");
5082 }
5083 (tmp, engine)
5084 }
5085
5086 fn sref(s: &str) -> memstead_schema::SchemaRef {
5087 s.parse().unwrap()
5088 }
5089
5090 #[test]
5096 fn set_schema_repairs_a_mount_expectation_ahead_of_the_served_pin() {
5097 let (_tmp, mut engine) = migration_engine();
5098 let idx = engine
5101 .mounts
5102 .iter()
5103 .position(|m| m.mount.mem == "specs")
5104 .unwrap();
5105 engine.mounts[idx].mount.schema = Some(sref("mig-b@0.1.0"));
5106 assert_eq!(engine.schemas.get("specs").unwrap().id().0, "mig-a");
5107
5108 let out = engine
5109 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5110 .unwrap();
5111 assert_eq!(
5115 out.outcome,
5116 crate::engine::SetSchemaResult::MigrationStarted,
5117 "a served pin behind the target enters the switch path, never a noop: {out:?}"
5118 );
5119 assert!(!out.findings.is_empty());
5120 assert_eq!(
5121 engine.schemas.get("specs").unwrap().id().0,
5122 "mig-b",
5123 "writes now validate against the target"
5124 );
5125
5126 let (_tmp2, mut clean) = migration_engine();
5129 let again = clean.set_mem_schema("specs", &sref("mig-a@0.1.0")).unwrap();
5130 assert_eq!(again.outcome, crate::engine::SetSchemaResult::Noop);
5131 }
5132
5133 #[test]
5134 fn set_schema_noop_on_current_pin() {
5135 let (_tmp, mut engine) = migration_engine();
5136 let out = engine
5137 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
5138 .unwrap();
5139 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
5140 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5141 assert_eq!(out.migration_target, None);
5142 assert!(out.findings.is_empty());
5143 }
5144
5145 #[test]
5154 fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
5155 let (_tmp, mut engine) = migration_engine();
5156
5157 let _ = engine.communities();
5158 let _ = engine.search_indexes();
5159 assert!(engine.community_memo.get().is_some());
5160 assert!(engine.search_indexes_memo.get().is_some());
5161 let store_gen_before = engine.store().generation();
5162
5163 engine
5164 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5165 .unwrap();
5166
5167 assert_eq!(
5168 engine.store().generation(),
5169 store_gen_before,
5170 "a schema switch mutates no store content"
5171 );
5172 assert!(
5173 engine.community_memo.get().is_none(),
5174 "the community memo must clear on a schema switch (weights derive from the schema)"
5175 );
5176 assert!(
5177 engine.search_indexes_memo.get().is_none(),
5178 "the search memo must clear on a schema switch (the field set derives from the schema)"
5179 );
5180 }
5181
5182 #[test]
5183 fn set_schema_switches_immediately_when_integral() {
5184 let (_tmp, mut engine) = migration_engine();
5187 let out = engine
5188 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5189 .unwrap();
5190 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5191 assert_eq!(out.schema_pin, "mig-a@0.2.0");
5192 assert_eq!(out.migration_target, None);
5193 assert!(out.findings.is_empty());
5194 assert_eq!(
5195 engine.schema_pin("specs").unwrap().as_display(),
5196 "mig-a@0.2.0"
5197 );
5198 assert!(engine.migration_target("specs").is_none());
5199 }
5200
5201 #[test]
5207 fn set_schema_switch_persists_pin_into_backend_config() {
5208 let tmp = tempfile::TempDir::new().unwrap();
5209 let schemas_dir = tmp.path().join("schemas");
5210 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5211 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5212 let mem_dir = tmp.path().join("mem");
5213 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
5214 std::fs::write(
5216 mem_dir.join(".memstead").join("config.json"),
5217 br#"{"schema":"mig-a@0.1.0"}"#,
5218 )
5219 .unwrap();
5220 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5221 let mut mount = folder_mount("specs", mem_dir.clone());
5222 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5223 let mut engine = Engine::from_mounts_with_schemas_dir(
5224 vec![(
5225 mount,
5226 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5227 )],
5228 Some(&schemas_dir),
5229 )
5230 .unwrap();
5231
5232 let out = engine
5233 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5234 .unwrap();
5235 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5236
5237 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
5240 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
5241 assert_eq!(
5242 cfg["schema"], "mig-a@0.2.0",
5243 "atomic switch must update the authoritative backend config"
5244 );
5245 }
5246
5247 #[test]
5248 fn set_schema_unknown_target_refuses_schema_not_found() {
5249 let (_tmp, mut engine) = migration_engine();
5250 let err = engine
5251 .set_mem_schema("specs", &sref("nope@9.9.9"))
5252 .unwrap_err();
5253 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
5254 assert!(engine.migration_target("specs").is_none());
5256 }
5257
5258 #[test]
5259 fn set_schema_migration_lifecycle_end_to_end() {
5260 let (_tmp, mut engine) = migration_engine();
5261 let target = sref("mig-b@0.1.0");
5262
5263 let out = engine.set_mem_schema("specs", &target).unwrap();
5265 assert_eq!(
5266 out.outcome,
5267 crate::engine::SetSchemaResult::MigrationStarted
5268 );
5269 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5270 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
5271 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
5272 assert!(
5273 out.findings
5274 .iter()
5275 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
5276 );
5277
5278 let one = crate::entity::EntityId::new("specs", "one");
5280 assert!(engine.store().get(&one).is_some());
5281
5282 let out = engine.set_mem_schema("specs", &target).unwrap();
5284 assert_eq!(
5285 out.outcome,
5286 crate::engine::SetSchemaResult::MigrationPending
5287 );
5288 assert_eq!(out.findings.len(), 2);
5289
5290 let mut bad = crate::engine::UpdateEntityArgs {
5294 anchors: Vec::new(),
5295 id: one.clone(),
5296 expected_hash: None,
5297 sections: indexmap::IndexMap::new(),
5298 append_sections: indexmap::IndexMap::new(),
5299 patch_sections: indexmap::IndexMap::new(),
5300 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
5301 metadata_unset: Vec::new(),
5302 declare_relations: Vec::new(),
5303 dry_run: false,
5304 relations_unset: Vec::new(),
5305 anchors_unset: Vec::new(),
5306 };
5307 let err = engine
5308 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
5309 .unwrap_err();
5310 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
5311 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
5312 engine
5313 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
5314 .expect("repair write validated against the migration target");
5315
5316 let out = engine.set_mem_schema("specs", &target).unwrap();
5318 assert_eq!(
5319 out.outcome,
5320 crate::engine::SetSchemaResult::MigrationPending
5321 );
5322 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
5323
5324 let two = crate::entity::EntityId::new("specs", "two");
5326 let repair = crate::engine::UpdateEntityArgs {
5327 anchors: Vec::new(),
5328 id: two.clone(),
5329 expected_hash: None,
5330 sections: indexmap::IndexMap::new(),
5331 append_sections: indexmap::IndexMap::new(),
5332 patch_sections: indexmap::IndexMap::new(),
5333 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
5334 metadata_unset: Vec::new(),
5335 declare_relations: Vec::new(),
5336 dry_run: false,
5337 relations_unset: Vec::new(),
5338 anchors_unset: Vec::new(),
5339 };
5340 engine
5341 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
5342 .unwrap();
5343 let out = engine.set_mem_schema("specs", &target).unwrap();
5344 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5345 assert_eq!(out.schema_pin, "mig-b@0.1.0");
5346 assert_eq!(out.migration_target, None);
5347 assert!(out.findings.is_empty());
5348 assert_eq!(
5349 engine.schema_pin("specs").unwrap().as_display(),
5350 "mig-b@0.1.0"
5351 );
5352 assert!(engine.migration_target("specs").is_none());
5353 }
5354
5355 #[test]
5360 fn relations_unset_works_during_migration_without_mode_flag() {
5361 let (_tmp, mut engine) = migration_engine();
5362 let one = crate::entity::EntityId::new("specs", "one");
5363 let two = crate::entity::EntityId::new("specs", "two");
5364 engine
5365 .relate_entity(
5366 crate::engine::RelateEntityArgs {
5367 source: one.clone(),
5368 expected_hash: None,
5369 rel_type: "USES".to_string(),
5370 target: two.clone(),
5371 remove: false,
5372 description: None,
5373 dry_run: false,
5374 },
5375 crate::vcs::Actor::Cli,
5376 None,
5377 None,
5378 )
5379 .unwrap();
5380 let shut = engine
5382 .update_entity(
5383 crate::engine::UpdateEntityArgs {
5384 anchors: Vec::new(),
5385 id: one.clone(),
5386 expected_hash: None,
5387 sections: indexmap::IndexMap::new(),
5388 append_sections: indexmap::IndexMap::new(),
5389 patch_sections: indexmap::IndexMap::new(),
5390 metadata: indexmap::IndexMap::new(),
5391 metadata_unset: Vec::new(),
5392 declare_relations: Vec::new(),
5393 dry_run: false,
5394 relations_unset: vec![crate::ops::RelationUnsetArg {
5395 rel_type: "USES".to_string(),
5396 target: two.clone(),
5397 }],
5398 anchors_unset: Vec::new(),
5399 },
5400 crate::vcs::Actor::Cli,
5401 None,
5402 None,
5403 )
5404 .unwrap_err();
5405 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5406
5407 engine
5411 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5412 .unwrap();
5413 engine
5414 .update_entity(
5415 crate::engine::UpdateEntityArgs {
5416 anchors: Vec::new(),
5417 id: one.clone(),
5418 expected_hash: None,
5419 sections: indexmap::IndexMap::new(),
5420 append_sections: indexmap::IndexMap::new(),
5421 patch_sections: indexmap::IndexMap::new(),
5422 metadata: indexmap::IndexMap::from_iter([(
5423 "status".to_string(),
5424 "open".to_string(),
5425 )]),
5426 metadata_unset: Vec::new(),
5427 declare_relations: Vec::new(),
5428 dry_run: false,
5429 relations_unset: vec![crate::ops::RelationUnsetArg {
5430 rel_type: "USES".to_string(),
5431 target: two.clone(),
5432 }],
5433 anchors_unset: Vec::new(),
5434 },
5435 crate::vcs::Actor::Cli,
5436 None,
5437 None,
5438 )
5439 .expect("repair-shaped update lands during migration without a flag");
5440 let entity = engine.store().get(&one).unwrap();
5441 assert!(entity.relationships.is_empty());
5442 }
5443
5444 #[test]
5449 fn boot_resumes_dual_pin_validation_against_target() {
5450 let (tmp, engine) = migration_engine();
5451 drop(engine);
5452 let schemas_dir = tmp.path().join("schemas");
5453 let mem_dir = tmp.path().join("mem");
5454 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5455 let mut mount = folder_mount("specs", mem_dir);
5456 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5457 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5458 let engine = Engine::from_mounts_with_schemas_dir(
5459 vec![(
5460 mount,
5461 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5462 )],
5463 Some(&schemas_dir),
5464 )
5465 .unwrap();
5466 let (name, version) = {
5468 let s = engine.schema_for("specs").unwrap();
5469 let (n, v) = s.id();
5470 (n.to_string(), v.to_string())
5471 };
5472 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5473 assert_eq!(
5476 engine.schema_pin("specs").unwrap().as_display(),
5477 "mig-a@0.1.0"
5478 );
5479 assert_eq!(
5480 engine.migration_target("specs").unwrap().as_display(),
5481 "mig-b@0.1.0"
5482 );
5483 }
5484
5485 #[test]
5495 fn every_lifecycle_setter_refuses_on_read_only_mount() {
5496 let tmp = TempDir::new().unwrap();
5497 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5498 let mut engine = Engine::from_mounts(vec![(
5499 archive_mount("ext", archive_path.clone()),
5500 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5501 )])
5502 .unwrap();
5503
5504 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5505 let attempts: Vec<(&str, EngineError)> = vec![
5506 (
5507 "set_mem_schema",
5508 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5509 ),
5510 (
5511 "set_mem_version",
5512 engine
5513 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5514 .unwrap_err(),
5515 ),
5516 (
5517 "set_mem_description",
5518 engine
5519 .set_mem_description("ext", Some("x".into()), None)
5520 .unwrap_err(),
5521 ),
5522 (
5523 "set_mem_title",
5524 engine
5525 .set_mem_title("ext", Some("x".into()), None)
5526 .unwrap_err(),
5527 ),
5528 (
5529 "set_mem_subject",
5530 engine.set_mem_subject("ext", None, None).unwrap_err(),
5531 ),
5532 (
5533 "set_mem_internal",
5534 engine.set_mem_internal("ext", true, None).unwrap_err(),
5535 ),
5536 (
5537 "set_mem_sync_state",
5538 engine
5539 .set_mem_sync_state("ext", "k", "t", None)
5540 .unwrap_err(),
5541 ),
5542 ];
5543 for (setter, err) in attempts {
5544 match err {
5545 EngineError::ReadOnlyMount(v) => {
5546 assert_eq!(v, "ext", "{setter} must name the refused mem")
5547 }
5548 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5549 }
5550 }
5551 }
5552
5553 #[test]
5559 fn no_config_setter_reverts_a_siblings_write() {
5560 type Setter = fn(&mut Engine) -> Result<(), EngineError>;
5561 let setters: Vec<(&str, Setter)> = vec![
5562 ("version", |e| {
5563 e.set_mem_version("specs", semver::Version::new(9, 0, 0), None)
5564 .map(|_| ())
5565 }),
5566 ("description", |e| {
5567 e.set_mem_description("specs", Some("mine".into()), None)
5568 .map(|_| ())
5569 }),
5570 ("title", |e| {
5571 e.set_mem_title("specs", Some("Mine".into()), None)
5572 .map(|_| ())
5573 }),
5574 ("internal", |e| {
5575 e.set_mem_internal("specs", true, None).map(|_| ())
5576 }),
5577 ("sync_state", |e| {
5578 e.set_mem_sync_state("specs", "src/facet", "tok", None)
5579 .map(|_| ())
5580 }),
5581 ("review_mark", |e| {
5585 e.set_review_mark("specs", None, None).map(|_| ())
5586 }),
5587 ];
5588
5589 for (name, set) in setters {
5590 let tmp = TempDir::new().unwrap();
5591 let mem_dir = tmp.path().to_path_buf();
5592 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5593 std::fs::create_dir_all(&meta).unwrap();
5594 let path = meta.join("config.json");
5595 std::fs::write(
5596 &path,
5597 br#"{"schema": "default@1.0.0", "version": "0.1.0"}"#.as_slice(),
5598 )
5599 .unwrap();
5600
5601 let writer = FilesystemMemWriter::new(mem_dir.clone());
5602 let mut engine = Engine::from_mounts(vec![(
5603 folder_mount("specs", mem_dir.clone()),
5604 Box::new(writer) as Box<dyn MemBackend>,
5605 )])
5606 .unwrap();
5607 engine
5610 .create_entity(
5611 crate::engine::test_helpers::empty_create_args("specs", "Seed"),
5612 crate::vcs::Actor::Cli,
5613 None,
5614 None,
5615 )
5616 .unwrap();
5617
5618 let mut sibling: memstead_schema::MemConfig =
5620 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5621 sibling
5622 .extra
5623 .insert("siblingMark".into(), serde_json::json!("kept"));
5624 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5625
5626 set(&mut engine).unwrap_or_else(|e| panic!("{name} setter failed: {e}"));
5627
5628 let after: memstead_schema::MemConfig =
5629 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5630 assert_eq!(
5631 after.extra.get("siblingMark"),
5632 Some(&serde_json::json!("kept")),
5633 "the {name} setter reverted a field it never set"
5634 );
5635 }
5636 }
5637
5638 #[test]
5641 fn intervention_is_reported_on_the_response_and_only_when_real() {
5642 let tmp = TempDir::new().unwrap();
5643 let mem_dir = tmp.path().to_path_buf();
5644 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5645 std::fs::create_dir_all(&meta).unwrap();
5646 let path = meta.join("config.json");
5647 std::fs::write(&path, br#"{"schema": "default@1.0.0"}"#.as_slice()).unwrap();
5648 let writer = FilesystemMemWriter::new(mem_dir.clone());
5649 let mut engine = Engine::from_mounts(vec![(
5650 folder_mount("specs", mem_dir.clone()),
5651 Box::new(writer) as Box<dyn MemBackend>,
5652 )])
5653 .unwrap();
5654
5655 let quiet = engine
5658 .set_mem_description("specs", Some("first".into()), None)
5659 .unwrap();
5660 assert!(
5661 !quiet
5662 .warnings
5663 .iter()
5664 .any(|w| w.code() == "CONFIG_WRITE_INTERVENED"),
5665 "single-writer workspace must stay silent: {:?}",
5666 quiet.warnings
5667 );
5668
5669 let mut sibling: memstead_schema::MemConfig =
5671 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5672 sibling.title = Some("theirs".into());
5673 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5674
5675 let loud = engine
5676 .set_mem_description("specs", Some("second".into()), None)
5677 .unwrap();
5678 let hint = loud
5679 .warnings
5680 .iter()
5681 .find(|w| w.code() == "CONFIG_WRITE_INTERVENED")
5682 .expect("intervention must be reported on the response");
5683 assert!(
5684 format!("{hint}").contains("title"),
5685 "the report names what they changed: {hint}"
5686 );
5687 let after: memstead_schema::MemConfig =
5689 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5690 assert_eq!(after.title.as_deref(), Some("theirs"));
5691 assert_eq!(after.description.as_deref(), Some("second"));
5692 }
5693}