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 fn set_identity(&mut self, identity: Option<String>) {
132 self.current_identity = identity;
133 }
134
135 pub fn current_identity(&self) -> Option<&str> {
138 self.current_identity.as_deref()
139 }
140
141 pub(crate) fn now_iso(&self) -> String {
145 crate::engine::mutation::iso_from_system_time((self.mutation_clock)())
146 }
147
148 pub fn set_git_branch_ops(&mut self, ops: GitBranchOps) {
154 self.git_branch_ops = Some(ops);
155 }
156
157 pub fn install_schema(
172 &self,
173 name: &str,
174 version: &str,
175 files: &[(String, Vec<u8>)],
176 ) -> Result<String, EngineError> {
177 Self::validate_schema_package(name, version, files)?;
183 let gitdir = self
189 .mounts
190 .iter()
191 .find_map(|m| match &m.mount.storage {
192 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
193 _ => None,
194 })
195 .or_else(|| {
196 self.workspace_root()
197 .map(|r| r.join("mem-repo").join(".git"))
198 })
199 .ok_or_else(|| {
200 EngineError::Mem(
201 "schema install requires a mem-repo workspace (no git-branch mount and \
202 no workspace root to resolve the mem-repo gitdir)"
203 .to_string(),
204 )
205 })?;
206 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
207 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
208 })?;
209 (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)
217 }
218
219 pub fn stage_sealed_schema(
247 &mut self,
248 mem: &str,
249 pin: &memstead_schema::SchemaRef,
250 files: &[(String, Vec<u8>)],
251 ) -> Result<SchemaStaging, EngineError> {
252 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
253 .workspace_schemas
254 .iter()
255 .chain(self.builtin_schemas.iter())
256 .cloned()
257 .collect();
258 if crate::engine::SchemaResolver::new(&catalogue)
259 .resolve(pin)
260 .is_ok()
261 {
262 return Ok(SchemaStaging::AlreadyResolvable);
263 }
264 if files.is_empty() {
265 return Ok(SchemaStaging::NoEmbeddedSchema);
269 }
270
271 let unloadable = |reason: String| EngineError::EmbeddedSchemaInvalid {
272 mem: mem.to_string(),
273 pin: pin.as_display(),
274 reason,
275 };
276 let schema =
279 memstead_schema::load_sealed_package(files).map_err(|e| unloadable(e.to_string()))?;
280 let (name, version) = schema.id();
281 if name != pin.name || version != pin.version {
282 return Err(unloadable(format!(
283 "the package declares '{name}@{version}' but the mem pins {}",
284 pin.as_display()
285 )));
286 }
287
288 self.write_to_local_schema_source(&name, &version.to_string(), files)?;
289 self.workspace_schemas.push(std::sync::Arc::new(schema));
290 Ok(SchemaStaging::Staged)
291 }
292
293 fn write_to_local_schema_source(
299 &self,
300 name: &str,
301 version: &str,
302 files: &[(String, Vec<u8>)],
303 ) -> Result<(), EngineError> {
304 let gitdir = self
305 .mounts
306 .iter()
307 .find_map(|m| match &m.mount.storage {
308 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
309 _ => None,
310 })
311 .or_else(|| {
312 self.workspace_root()
313 .map(|r| r.join("mem-repo").join(".git"))
314 })
315 .filter(|g| g.is_dir())
316 .ok_or_else(|| {
317 EngineError::Mem(
318 "staging a schema requires a mem-repo workspace — the folder workspace's \
319 `.memstead/schemas/` is the authoring tier and never holds sealed \
320 third-party packages"
321 .to_string(),
322 )
323 })?;
324 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
325 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
326 })?;
327 (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)?;
328 Ok(())
329 }
330
331 pub fn validate_schema_package(
342 name: &str,
343 version: &str,
344 files: &[(String, Vec<u8>)],
345 ) -> Result<(), EngineError> {
346 let invalid = |message: String| EngineError::SchemaPackageInvalid {
347 name: name.to_string(),
348 version: version.to_string(),
349 message,
350 };
351 let manifest_yaml = files
352 .iter()
353 .find(|(rel, _)| rel == "schema.yaml")
354 .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned())
355 .ok_or_else(|| invalid("package has no schema.yaml".to_string()))?;
356 let types: Vec<(String, String)> = files
357 .iter()
358 .filter_map(|(rel, bytes)| {
359 rel.strip_prefix("types/")
360 .and_then(|f| f.strip_suffix(".yaml"))
361 .map(|stem| {
362 (
363 stem.to_string(),
364 String::from_utf8_lossy(bytes).into_owned(),
365 )
366 })
367 })
368 .collect();
369 let schema = memstead_schema::load_schema_from_memory_with_format(
373 &manifest_yaml,
374 &types,
375 memstead_schema::loader::MetadataPolarityFormat::RequiredOptIn,
376 )
377 .map_err(|e| invalid(e.to_string()))?;
378 memstead_schema::check_section_heading_roundtrip(&schema)
379 .map_err(|e| invalid(e.to_string()))?;
380 memstead_schema::check_reserved_metadata_keys(&schema)
381 .map_err(|e| invalid(e.to_string()))?;
382 memstead_schema::check_section_formats(&schema).map_err(|e| invalid(e.to_string()))?;
383 let (declared_name, declared_version) =
384 (schema.manifest.name.as_str(), schema.version.to_string());
385 if declared_name != name || declared_version != version {
386 return Err(invalid(format!(
387 "manifest declares '{declared_name}@{declared_version}' but the package is \
388 being installed as '{name}@{version}'"
389 )));
390 }
391 Self::validate_schema_exemplars(&std::sync::Arc::new(schema)).map_err(invalid)?;
392 Ok(())
393 }
394
395 pub fn validate_schema_exemplars(
417 schema: &std::sync::Arc<memstead_schema::Schema>,
418 ) -> Result<(), String> {
419 let with_exemplars: Vec<&str> = schema
420 .manifest
421 .types
422 .iter()
423 .filter(|t| {
424 schema
425 .types
426 .get(t.as_str())
427 .is_some_and(|td| td.exemplar.is_some())
428 })
429 .map(String::as_str)
430 .collect();
431 if with_exemplars.is_empty() {
432 return Ok(());
433 }
434
435 let (name, version) = schema.id();
436 let mem = "exemplar";
437 let mount = crate::workspace::Mount {
438 mem: mem.to_string(),
439 schema: Some(memstead_schema::SchemaRef::new(name, version)),
440 storage: crate::workspace::MountStorage::InMemory,
441 capability: crate::workspace::MountCapability::Write,
442 lifecycle: crate::workspace::MountLifecycle::Eager,
443 cross_linkable: true,
444 migration_target: None,
445 };
446 let backend = Box::new(crate::storage::InMemoryBackend::new()) as Box<dyn MemBackend>;
447 let mut engine = Engine::from_mounts_with_schemas_dir_and_extra(
448 vec![(mount, backend)],
449 None,
450 vec![schema.clone()],
451 )
452 .map_err(|e| format!("exemplar validation could not boot: {e}"))?;
453
454 for type_name in with_exemplars {
455 let td = schema
456 .types
457 .get(type_name)
458 .expect("filtered on presence above");
459 let ex = td.exemplar.as_ref().expect("filtered on presence above");
460 let mut relations = Vec::with_capacity(ex.relations.len());
461 for r in &ex.relations {
462 let target = r.target_slug();
463 if target.contains("--") || target.trim().is_empty() {
464 return Err(format!(
465 "type '{type_name}' exemplar relation target '{target}' must be a bare \
466 placeholder slug (no `--`, non-empty) — exemplars live outside \
467 any mem",
468 ));
469 }
470 relations.push(crate::ops::RelateArg {
471 target: crate::entity::EntityId::new(mem, target),
472 rel_type: r.rel_type_name().to_string(),
473 description: r.description.clone(),
474 });
475 }
476 let args = crate::engine::CreateEntityArgs {
477 anchors: Vec::new(),
478 mem: mem.to_string(),
479 title: ex.title.clone(),
480 entity_type: type_name.to_string(),
481 sections: ex.sections.clone(),
482 metadata: ex.metadata.clone(),
483 relations,
484 dry_run: true,
485 };
486 if let Err(e) = engine.create_entity(args, crate::vcs::Actor::Cli, None, None) {
487 return Err(format!(
488 "type '{type_name}' exemplar does not conform: [{}] {e}",
489 e.code()
490 ));
491 }
492 }
493 Ok(())
494 }
495 pub fn unregister_writable_mem(
514 &mut self,
515 mem_name: &str,
516 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
517 let pos = self.mounts.iter().position(|m| m.mount.mem == mem_name);
518 let Some(idx) = pos else {
519 return Ok(None);
520 };
521
522 let mount = self.mounts.remove(idx);
528
529 self.schemas_remove(&mount.mount.mem);
532
533 let _removed = self.store.remove_entities_by_mem(mem_name);
538
539 self.load_warnings
550 .retain(|w| w.source_mem() != Some(mem_name));
551
552 Arc::make_mut(&mut self.mem_router).remove_writable(mem_name);
560
561 self.invalidate_communities();
566 self.invalidate_search_indexes();
567
568 Ok(Some(mount.backend))
569 }
570
571 pub fn register_read_mount(
579 &mut self,
580 mount: Mount,
581 backend: Box<dyn MemBackend>,
582 origin: MemOrigin,
583 ) -> Result<(), EngineError> {
584 self.register_writable_mem_inner(mount, backend, origin, true)
585 }
586
587 pub fn unregister_read_mount(
595 &mut self,
596 mem_name: &str,
597 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
598 let pos = self.mounts.iter().position(|m| {
599 m.mount.mem == mem_name
600 && m.mount.capability == crate::workspace::MountCapability::ReadOnly
601 });
602 let Some(idx) = pos else {
603 return Ok(None);
604 };
605 let mount = self.mounts.remove(idx);
606 self.schemas_remove(&mount.mount.mem);
607 let _removed = self.store.remove_entities_by_mem(mem_name);
608 self.load_warnings
609 .retain(|w| w.source_mem() != Some(mem_name));
610 Arc::make_mut(&mut self.mem_router).remove_read_only(mem_name);
611 self.invalidate_communities();
612 self.invalidate_search_indexes();
613 Ok(Some(mount.backend))
614 }
615
616 pub fn push_load_warning(&mut self, warning: crate::ops::WarningHint) {
621 self.load_warnings.push(warning);
622 }
623
624 pub fn register_writable_mem(
663 &mut self,
664 mount: Mount,
665 backend: Box<dyn MemBackend>,
666 origin: MemOrigin,
667 ) -> Result<(), EngineError> {
668 self.register_writable_mem_inner(mount, backend, origin, true)
669 }
670
671 fn register_writable_mem_inner(
681 &mut self,
682 mount: Mount,
683 backend: Box<dyn MemBackend>,
684 origin: MemOrigin,
685 run_global_passes: bool,
686 ) -> Result<(), EngineError> {
687 if let Some(existing) = self.mem_router.origin_for_mem(&mount.mem) {
689 return Err(EngineError::MemNameCollision {
690 name: mount.mem.clone(),
691 source_origin: existing.render_source(),
692 });
693 }
694 if self.mem_router.archive_path_for_mem(&mount.mem).is_some() {
695 return Err(EngineError::MemNameCollision {
696 name: mount.mem.clone(),
697 source_origin: "attached read mem".to_string(),
698 });
699 }
700
701 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
707 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
708 memstead_schema::config::parse_mem_config(&value).ok()
709 });
710
711 let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
721 self.workspace_schemas().to_vec();
722 builtin_schemas.extend(
723 memstead_schema::builtins::load_builtin_schemas()
724 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?,
725 );
726 let config_pin = mem_config.as_ref().and_then(|c| c.schema.as_ref());
727 let mount_pin = mount.schema.as_ref();
728 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
729 && cfg != mp
730 {
731 self.load_warnings
732 .push(crate::ops::WarningHint::SchemaPinMismatch {
733 mem: mount.mem.clone(),
734 config_pin: cfg.as_display(),
735 mount_pin: mp.as_display(),
736 });
737 }
738 let settled_pin = config_pin.or(mount_pin);
739 let effective_pin = mount
740 .migration_target
741 .as_ref()
742 .or(settled_pin)
743 .ok_or_else(|| EngineError::MemConfigIncomplete {
744 mem: mount.mem.clone(),
745 missing_fields: vec!["schema".to_string()],
746 })?
747 .clone();
748 let schema = crate::engine::SchemaResolver::new(&builtin_schemas)
749 .resolve(&effective_pin)
750 .map_err(|sources| {
751 EngineError::SchemaNotFound {
752 mem: mount.mem.clone(),
753 pin: effective_pin.as_display(),
754 sources,
755 install_hint: None,
756 }
757 .with_schema_install_probe(self.workspace_root())
758 })?;
759
760 let (entries, read_errors) = collect_source_entries(backend.as_ref())?;
768 if let Some(w) =
769 super::boot::unbacked_mount_warning(&mount, backend.as_ref(), Some(entries.len()))
770 {
771 self.load_warnings.push(w);
772 }
773 let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
774
775 let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
776 mem_names.push(mount.mem.clone());
777 let known_suffixes: Vec<String> = mem_names
778 .iter()
779 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
780 .collect();
781 let fallback = engine_fallback_type();
782 push_entities_into_store(
783 &mut self.store,
784 load_result.entities,
785 fallback.as_ref(),
786 Some(crate::entity::store_builder::LoadCollector {
787 warnings: &mut self.load_warnings,
788 known_suffixes: &known_suffixes,
789 mem_names: &mem_names,
790 }),
791 );
792 self.load_errors.extend(load_result.errors);
793
794 self.schemas_insert(mount.mem.clone(), schema);
796
797 if run_global_passes {
808 let mut mount_caps: std::collections::HashMap<
809 String,
810 crate::workspace::MountCapability,
811 > = self
812 .mounts
813 .iter()
814 .map(|m| (m.mount.mem.clone(), m.mount.capability))
815 .collect();
816 mount_caps.insert(mount.mem.clone(), mount.capability);
817 crate::entity::store_builder::validate_loaded_relations(
818 &mut self.store,
819 &self.schemas,
820 &mount_caps,
821 &mut self.load_warnings,
822 );
823 crate::entity::store_builder::remap_alias_target_edge_sources(
824 &mut self.store,
825 &self.schemas,
826 );
827 }
828
829 let last_known_head = backend.current_head().ok().flatten();
831 let mem_name_for_router = mount.mem.clone();
832 let storage_for_router = mount.storage.clone();
833 let mount_capability_for_router = mount.capability;
834 self.mounts.push(MountedBackend {
835 mount,
836 backend,
837 last_known_head,
838 mem_config,
839 archive_provenance: None,
842 deferred: false,
844 });
845
846 match (&mount_capability_for_router, &storage_for_router) {
854 (crate::workspace::MountCapability::ReadOnly, MountStorage::Archive { path }) => {
855 Arc::make_mut(&mut self.mem_router)
856 .add_read_only(mem_name_for_router, path.clone());
857 }
858 _ => {
859 let dir: Option<PathBuf> = match &storage_for_router {
860 MountStorage::Folder { path } => Some(path.clone()),
861 MountStorage::GitBranch { .. }
862 | MountStorage::Archive { .. }
863 | MountStorage::InMemory => None,
864 };
865 Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
866 }
867 }
868
869 if run_global_passes {
871 self.invalidate_communities();
872 self.invalidate_search_indexes();
873 }
874
875 Ok(())
876 }
877
878 fn finish_batched_registrations(&mut self) {
883 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
884 .mounts
885 .iter()
886 .map(|m| (m.mount.mem.clone(), m.mount.capability))
887 .collect();
888 crate::entity::store_builder::validate_loaded_relations(
889 &mut self.store,
890 &self.schemas,
891 &mount_caps,
892 &mut self.load_warnings,
893 );
894 crate::entity::store_builder::remap_alias_target_edge_sources(
895 &mut self.store,
896 &self.schemas,
897 );
898 self.invalidate_communities();
899 self.invalidate_search_indexes();
900 }
901
902 pub fn full_refresh(&mut self) -> crate::ops::FullRefreshReport {
927 let started = std::time::Instant::now();
928 let mut report = crate::ops::FullRefreshReport::default();
929
930 let Some(root) = self.workspace_root.clone() else {
931 report.failures.push(crate::ops::RefreshFailure {
932 item: "workspace".to_string(),
933 error: "engine has no workspace root (ad-hoc mount-list construction) — \
934 nothing to re-scan"
935 .to_string(),
936 });
937 report.elapsed_ms = started.elapsed().as_millis() as u64;
938 return report;
939 };
940
941 self.refresh_workspace_settings_if_possible();
944
945 use crate::schema_source::SchemaSource as _;
947 let mut fresh: Vec<std::sync::Arc<memstead_schema::Schema>> = Vec::new();
948 let mut sources_complete = true;
949 match crate::schema_source::FolderSchemaSource::for_workspace(&root).read_schemas() {
950 Ok(mut s) => fresh.append(&mut s),
951 Err(e) => {
952 sources_complete = false;
953 report.failures.push(crate::ops::RefreshFailure {
954 item: "schema-source:folder".to_string(),
955 error: e.to_string(),
956 });
957 }
958 }
959 if let Some(ops) = self.git_branch_ops() {
960 match (ops.read_ref_schemas)(&root) {
961 Ok(mut s) => fresh.append(&mut s),
962 Err(e) => {
963 sources_complete = false;
964 report.failures.push(crate::ops::RefreshFailure {
965 item: "schema-source:memstead-ref".to_string(),
966 error: e.to_string(),
967 });
968 }
969 }
970 }
971 let key = |s: &memstead_schema::Schema| {
972 let (name, version) = s.id();
973 format!("{name}@{version}")
974 };
975 let existing: std::collections::HashSet<String> =
976 self.workspace_schemas.iter().map(|s| key(s)).collect();
977 let fresh_keys: std::collections::HashSet<String> = fresh.iter().map(|s| key(s)).collect();
978 for schema in fresh {
979 let k = key(&schema);
980 if !existing.contains(&k) && !report.schemas_added.contains(&k) {
981 report.schemas_added.push(k);
982 self.workspace_schemas.push(schema);
983 }
984 }
985 report.schemas_added.sort();
986 if sources_complete {
990 report.schema_removals_skipped = existing
991 .difference(&fresh_keys)
992 .cloned()
993 .collect::<Vec<_>>();
994 report.schema_removals_skipped.sort();
995 }
996
997 let store = crate::workspace_store::FileWorkspaceStore::new();
999 match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
1000 Err(e) => {
1001 report.failures.push(crate::ops::RefreshFailure {
1002 item: "mount-manifest".to_string(),
1003 error: e.to_string(),
1004 });
1005 }
1006 Ok(workspace) => {
1007 let manifest_names: std::collections::HashSet<String> =
1008 workspace.mounts.iter().map(|m| m.mem.clone()).collect();
1009 let mut any_mounted = false;
1010 for mount in workspace.mounts {
1011 if self.mounts.iter().any(|m| m.mount.mem == mount.mem) {
1012 continue;
1013 }
1014 let name = mount.mem.clone();
1015 if mount.capability != crate::workspace::MountCapability::Write {
1016 report.failures.push(crate::ops::RefreshFailure {
1017 item: format!("mount:{name}"),
1018 error: "only writable mounts attach on a warm refresh — \
1019 restart the process to attach this mount"
1020 .to_string(),
1021 });
1022 continue;
1023 }
1024 let backend = match (self.backend_factory)(&mount) {
1025 Ok(b) => b,
1026 Err(e) => {
1027 report.failures.push(crate::ops::RefreshFailure {
1028 item: format!("mount:{name}"),
1029 error: e.to_string(),
1030 });
1031 continue;
1032 }
1033 };
1034 match self.register_writable_mem_inner(
1035 mount,
1036 backend,
1037 crate::mem::MemOrigin::ExplicitToml,
1038 false,
1039 ) {
1040 Ok(()) => {
1041 any_mounted = true;
1042 report.mems_mounted.push(name);
1043 }
1044 Err(e) => report.failures.push(crate::ops::RefreshFailure {
1045 item: format!("mount:{name}"),
1046 error: e.to_string(),
1047 }),
1048 }
1049 }
1050 report.mem_removals_skipped = self
1055 .mounts
1056 .iter()
1057 .filter(|m| {
1058 m.mount.capability == crate::workspace::MountCapability::Write
1059 && !manifest_names.contains(&m.mount.mem)
1060 })
1061 .map(|m| m.mount.mem.clone())
1062 .collect();
1063 report.mem_removals_skipped.sort();
1064 if any_mounted {
1065 self.finish_batched_registrations();
1067 }
1068 }
1069 }
1070
1071 report.elapsed_ms = started.elapsed().as_millis() as u64;
1072 report
1073 }
1074
1075 pub fn set_workspace_root(&mut self, root: PathBuf) {
1081 self.workspace_root = Some(root);
1082 }
1083
1084 pub fn persist_state(&self) -> Result<(), EngineError> {
1107 let Some(root) = self.workspace_root.as_ref() else {
1108 return Ok(());
1109 };
1110 use crate::workspace_store::WorkspaceStoreAdapter as _;
1111 let store = crate::FileWorkspaceStore::new();
1112 let map = |e: crate::workspace_store::StoreError| {
1113 EngineError::Mem(format!("persist workspace state: {e}"))
1114 };
1115
1116 let ours: Vec<crate::workspace::Mount> = self
1133 .mounts
1134 .iter()
1135 .map(|m| m.mount.clone())
1136 .chain(self.quarantined.iter().map(|q| q.mount.clone()))
1137 .collect();
1138
1139 for attempt in 0..8 {
1140 let expected = store.read_state_bytes(root).map_err(map)?;
1141 let on_disk: Vec<crate::workspace::Mount> = match expected.as_deref() {
1142 Some(bytes) => store.parse_state_bytes(root, bytes).map_err(map)?,
1143 None => Vec::new(),
1144 };
1145
1146 let merged = {
1147 let baseline = self.mounts_baseline.borrow();
1148 merge_mount_rosters(&baseline, &ours, on_disk)
1149 };
1150 let workspace = crate::workspace::Workspace {
1151 mounts: merged,
1152 settings: self.settings.clone(),
1153 };
1154
1155 if store
1156 .save_state_cas(root, &workspace, expected.as_deref())
1157 .map_err(map)?
1158 {
1159 *self.mounts_baseline.borrow_mut() = ours;
1160 return Ok(());
1161 }
1162 if attempt == 7 {
1163 return Err(EngineError::Mem(
1164 "workspace state is being written concurrently: eight compare-and-set \
1165 attempts all lost the race. Retry, or find the writer that is not \
1166 backing off."
1167 .to_string(),
1168 ));
1169 }
1170 }
1171 unreachable!("the loop returns on success and on exhaustion")
1172 }
1173 pub fn set_mem_schema(
1193 &mut self,
1194 mem: &str,
1195 target: &memstead_schema::SchemaRef,
1196 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1197 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1198 if self.quarantine_reason(mem).is_some() {
1204 return self.set_schema_on_quarantined(mem, target);
1205 }
1206 let mount_idx = self
1207 .mounts
1208 .iter()
1209 .position(|m| m.mount.mem == mem)
1210 .ok_or_else(|| self.unknown_mem_error(mem))?;
1211 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1217 return Err(EngineError::ReadOnlyMount(mem.to_string()));
1218 }
1219
1220 let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
1223 let consulted: Vec<_> = self
1226 .workspace_schemas
1227 .iter()
1228 .chain(self.builtin_schemas.iter())
1229 .cloned()
1230 .collect();
1231 EngineError::SchemaNotFound {
1232 mem: mem.to_string(),
1233 pin: target.as_display(),
1234 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1235 &target.name,
1236 &target.version,
1237 &consulted,
1238 ),
1239 install_hint: None,
1240 }
1241 .with_schema_install_probe(self.workspace_root())
1242 })?;
1243
1244 let current_pin = self.mounts[mount_idx].mount.schema.clone();
1249 let current_pin_display = current_pin
1250 .as_ref()
1251 .map(|p| p.as_display())
1252 .unwrap_or_else(|| "<unset>".to_string());
1253 let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1254
1255 let served_pin: Option<memstead_schema::SchemaRef> = self.schemas.get(mem).map(|s| {
1266 let (name, version) = s.id();
1267 memstead_schema::SchemaRef::new(name, version)
1268 });
1269 if served_pin.as_ref() == Some(target) && in_flight.is_none() {
1275 if current_pin.as_ref() == Some(target) {
1276 return Ok(SetSchemaOutcome {
1277 mem: mem.to_string(),
1278 schema_pin: current_pin_display,
1279 migration_target: in_flight.map(|t| t.as_display()),
1280 outcome: SetSchemaResult::Noop,
1281 findings: Vec::new(),
1282 });
1283 }
1284 self.mounts[mount_idx].mount.schema = Some(target.clone());
1285 self.mounts[mount_idx].mount.migration_target = None;
1286 self.persist_state()?;
1287 return Ok(SetSchemaOutcome {
1288 mem: mem.to_string(),
1289 schema_pin: target.as_display(),
1290 migration_target: None,
1291 outcome: SetSchemaResult::Switched,
1292 findings: Vec::new(),
1293 });
1294 }
1295
1296 let findings = crate::ops::integrity::conformance_findings(
1302 &self.store,
1303 mem,
1304 target_schema.as_ref(),
1305 &self.schemas,
1306 );
1307
1308 if findings.is_empty() {
1309 self.persist_mem_schema_pin(mount_idx, target)?;
1317 self.mounts[mount_idx].mount.schema = Some(target.clone());
1318 self.mounts[mount_idx].mount.migration_target = None;
1319 self.schemas_insert(mem.to_string(), target_schema);
1320 self.invalidate_communities();
1321 self.invalidate_search_indexes();
1326 self.persist_state()?;
1327 return Ok(SetSchemaOutcome {
1328 mem: mem.to_string(),
1329 schema_pin: target.as_display(),
1330 migration_target: None,
1331 outcome: SetSchemaResult::Switched,
1332 findings: Vec::new(),
1333 });
1334 }
1335
1336 let outcome = if in_flight.as_ref() == Some(target) {
1337 SetSchemaResult::MigrationPending
1338 } else {
1339 SetSchemaResult::MigrationStarted
1340 };
1341 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1342 self.schemas_insert(mem.to_string(), target_schema);
1345 self.invalidate_communities();
1346 self.invalidate_search_indexes();
1348 self.persist_state()?;
1349 Ok(SetSchemaOutcome {
1350 mem: mem.to_string(),
1351 schema_pin: current_pin_display,
1352 migration_target: Some(target.as_display()),
1353 outcome,
1354 findings,
1355 })
1356 }
1357
1358 pub(crate) fn write_mem_config_merged(
1407 &mut self,
1408 mount_idx: usize,
1409 mem_name: &str,
1410 note: Option<&str>,
1411 apply: &dyn Fn(&mut memstead_schema::config::MemConfig),
1412 ) -> Result<(memstead_schema::config::MemConfig, Vec<String>), EngineError> {
1413 let backend = self.mounts[mount_idx].backend.as_ref();
1414 let read = |b: &dyn crate::backend::MemBackend| -> Result<Vec<u8>, EngineError> {
1415 b.read_mem_config()
1416 .map_err(|e| EngineError::Mem(format!("read mem config for update: {e}")))?
1417 .ok_or_else(|| {
1418 EngineError::InvalidInput(format!(
1419 "mem '{mem_name}' has no stored MemConfig (initialize the mem via \
1420 `memstead init` or `memstead mem create` first)"
1421 ))
1422 })
1423 };
1424
1425 let stored = read(backend)?;
1426 let intervened = match self.mounts[mount_idx].mem_config.as_ref() {
1431 Some(cached) => changed_config_fields(cached, &stored),
1432 None => Vec::new(),
1433 };
1434
1435 let render =
1436 |raw: &[u8]| -> Result<(memstead_schema::config::MemConfig, Vec<u8>), EngineError> {
1437 let value: serde_json::Value = serde_json::from_slice(raw)
1438 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1439 let mut cfg = memstead_schema::config::parse_mem_config(&value)
1440 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1441 apply(&mut cfg);
1442 let mut bytes = serde_json::to_vec_pretty(&cfg)
1443 .map_err(|e| EngineError::Mem(format!("serialize mem config: {e}")))?;
1444 bytes.push(b'\n');
1445 Ok((cfg, bytes))
1446 };
1447 let (mut parsed, mut bytes) = render(&stored)?;
1448
1449 let mut expected = stored;
1458 for attempt in 0..8 {
1459 let wrote = self.mounts[mount_idx].backend.write_mem_config_cas(
1460 Some(&expected),
1461 &bytes,
1462 note,
1463 )?;
1464 if wrote {
1465 break;
1466 }
1467 if attempt == 7 {
1468 return Err(EngineError::Mem(format!(
1469 "mem '{mem_name}' config is being written concurrently: eight \
1470 compare-and-set attempts all lost the race. Retry, or find the \
1471 writer that is not backing off."
1472 )));
1473 }
1474 expected = read(self.mounts[mount_idx].backend.as_ref())?;
1475 let rendered = render(&expected)?;
1476 parsed = rendered.0;
1477 bytes = rendered.1;
1478 }
1479
1480 let mounted = &mut self.mounts[mount_idx];
1481 mounted.mem_config = Some(parsed.clone());
1482 if let Some(sha) = mounted.backend.current_head().ok().flatten() {
1485 mounted.last_known_head = Some(sha);
1486 }
1487 Ok((parsed, intervened))
1488 }
1489
1490 fn persist_mem_schema_pin(
1491 &mut self,
1492 mount_idx: usize,
1493 target: &memstead_schema::SchemaRef,
1494 ) -> Result<(), EngineError> {
1495 let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1496 if let Some(value) = value
1499 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1500 {
1501 self.mounts[mount_idx].mem_config = Some(cfg);
1502 }
1503 Ok(())
1504 }
1505
1506 pub fn export_markdown(
1535 &self,
1536 mem_filter: Option<&str>,
1537 schema_filter: Option<&str>,
1538 ) -> Result<crate::ops::ExportResult, EngineError> {
1539 use crate::workspace::MountStorage;
1540 let fallback = engine_fallback_type();
1541 let supported_backends = vec!["folder".to_string()];
1542
1543 if let Some(name) = mem_filter {
1544 let mount = self
1545 .mounts
1546 .iter()
1547 .find(|m| m.mount.mem == name)
1548 .ok_or_else(|| self.unknown_mem_error(name))?;
1549 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1550 return Err(EngineError::MarkdownExportUnsupportedBackend {
1551 mem: name.to_string(),
1552 active_backend: mount.mount.storage.backend_id().to_string(),
1553 supported_backends,
1554 });
1555 }
1556 }
1557
1558 let mut total_written = 0;
1559 let mut refused: Vec<crate::ops::RefusedEntity> = Vec::new();
1560 let mut total_unchanged = 0;
1561 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1562
1563 for mount in &self.mounts {
1564 let mem_name = mount.mount.mem.as_str();
1565 if let Some(filter) = mem_filter
1566 && mem_name != filter
1567 {
1568 continue;
1569 }
1570 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1571 skipped_mounts.push(crate::ops::SkippedMount {
1572 mem: mem_name.to_string(),
1573 active_backend: mount.mount.storage.backend_id().to_string(),
1574 reason: "backend_does_not_support_markdown_export".to_string(),
1575 });
1576 continue;
1577 };
1578 let schema = match self.schemas.get(mem_name) {
1579 Some(s) => s,
1580 None => continue,
1581 };
1582
1583 for entity in self.store.all_entities() {
1584 if entity.stub || entity.file_path.is_empty() {
1585 continue;
1586 }
1587 if entity.id.mem() != mem_name {
1588 continue;
1589 }
1590 if let Some(filter) = schema_filter
1591 && entity.entity_type != filter
1592 {
1593 continue;
1594 }
1595 let type_def = schema
1596 .get_type(&entity.entity_type)
1597 .unwrap_or_else(|| fallback.clone());
1598 let generated = generate_markdown(entity, type_def.as_ref());
1599
1600 let full_path = mem_dir.join(&entity.file_path);
1601 let needs_write = match std::fs::read_to_string(&full_path) {
1602 Ok(existing) => existing != generated,
1603 Err(_) => true,
1604 };
1605 if needs_write {
1606 match crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref()) {
1607 Ok(_) => total_written += 1,
1608 Err(e @ crate::entity::writer::WriteError::UnterminatedFence { .. }) => {
1613 refused.push(crate::ops::RefusedEntity {
1614 id: entity.id.to_string(),
1615 reason: "UNTERMINATED_FENCE_IN_STORED_BODY".to_string(),
1616 detail: e.to_string(),
1617 });
1618 }
1619 Err(_) => {}
1620 }
1621 } else {
1622 total_unchanged += 1;
1623 }
1624 }
1625 }
1626
1627 Ok(crate::ops::ExportResult {
1628 refused_entities: refused,
1629 written: total_written,
1630 unchanged: total_unchanged,
1631 skipped_mounts,
1632 })
1633 }
1634
1635 pub fn export_mem(
1653 &self,
1654 mem_name: &str,
1655 output_path: &std::path::Path,
1656 ) -> Result<crate::ops::MemExportResult, EngineError> {
1657 let mount = self
1658 .mounts
1659 .iter()
1660 .find(|m| m.mount.mem == mem_name)
1661 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1662 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1663 EngineError::InvalidInput(format!(
1664 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1665 ))
1666 })?;
1667 if config.version.is_none() {
1675 return Err(EngineError::MemConfigIncomplete {
1676 mem: mem_name.to_string(),
1677 missing_fields: vec!["version".to_string()],
1678 });
1679 }
1680 let fenced: Vec<String> = self
1686 .store
1687 .all_entities()
1688 .filter(|e| e.mem == mem_name && !e.stub)
1689 .filter(|e| {
1690 e.sections
1691 .values()
1692 .any(|v| crate::markdown::closing_fence_if_unterminated(v.trim()).is_some())
1693 })
1694 .map(|e| e.id.to_string())
1695 .collect();
1696 let workspace_root = self.workspace_root.as_deref();
1697 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1701 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1702 let exported = match &mount.mount.storage {
1703 MountStorage::Folder { path } => crate::ops::export::export_mem(
1704 path,
1705 config,
1706 output_path,
1707 workspace_root,
1708 workspace_schemas_dir,
1709 )
1710 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1711 MountStorage::GitBranch { gitdir, branch } => {
1712 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1713 EngineError::Backend(BackendError::Other(
1714 "git-branch export hook not installed (full flavour not loaded)"
1715 .to_string(),
1716 ))
1717 })?;
1718 let provenance_bytes = mount
1722 .backend
1723 .read_provenance(None)
1724 .ok()
1725 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
1726 .and_then(|prov| prov.to_archive_bytes().ok());
1727 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1730 (hook.export)(
1731 gitdir,
1732 branch,
1733 mem_name,
1734 config,
1735 output_path,
1736 workspace_root,
1737 workspace_schemas_dir,
1738 provenance_bytes.as_deref(),
1739 anchors_bytes.as_deref(),
1740 )
1741 .map_err(EngineError::Backend)
1742 }
1743 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1744 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1749 "export not yet supported for in-memory backend".to_string(),
1750 ))),
1751 };
1752 exported.map(|mut r| {
1753 r.unterminated_fence_entities = fenced;
1754 r
1755 })
1756 }
1757
1758 pub fn record_pipeline_edit_provenance(
1782 &self,
1783 mem: &str,
1784 kind: &str,
1785 edits: &[(String, Option<Vec<u8>>)],
1786 note: Option<&str>,
1787 verb: &str,
1788 ) -> Result<(), crate::backend::BackendError> {
1789 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1790 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1791 None => Ok(()),
1792 }
1793 }
1794
1795 pub fn set_mem_version(
1796 &mut self,
1797 mem_name: &str,
1798 new_version: semver::Version,
1799 note: Option<&str>,
1800 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1801 let mount_idx = self
1804 .mounts
1805 .iter()
1806 .position(|m| m.mount.mem == mem_name)
1807 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1808 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1809 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1810 }
1811
1812 let mut warnings = self.reload_if_stale(Some(mem_name));
1820 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1825 warnings.push(w);
1826 }
1827
1828 let old_version = self.mounts[mount_idx]
1832 .mem_config
1833 .as_ref()
1834 .and_then(|c| c.version.clone());
1835 let target = new_version.clone();
1836 let (_, intervened) = self.write_mem_config_merged(
1837 mount_idx,
1838 mem_name,
1839 note,
1840 &move |c: &mut memstead_schema::config::MemConfig| {
1841 c.version = Some(target.clone());
1842 },
1843 )?;
1844 if !intervened.is_empty() {
1845 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1846 mem: mem_name.to_string(),
1847 fields: intervened,
1848 });
1849 }
1850
1851 Ok(crate::ops::SetMemVersionOutcome {
1852 mem: mem_name.to_string(),
1853 old_version,
1854 new_version,
1855 warnings,
1856 })
1857 }
1858
1859 pub fn set_mem_description(
1866 &mut self,
1867 mem_name: &str,
1868 new_description: Option<String>,
1869 note: Option<&str>,
1870 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1871 let mount_idx = self
1872 .mounts
1873 .iter()
1874 .position(|m| m.mount.mem == mem_name)
1875 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1876 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1877 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1878 }
1879
1880 let mut warnings = self.reload_if_stale(Some(mem_name));
1881 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1882 warnings.push(w);
1883 }
1884
1885 let old_description = self.mounts[mount_idx]
1886 .mem_config
1887 .as_ref()
1888 .and_then(|c| c.description.clone());
1889 let target = new_description.clone();
1890 let (_, intervened) = self.write_mem_config_merged(
1891 mount_idx,
1892 mem_name,
1893 note,
1894 &move |c: &mut memstead_schema::config::MemConfig| {
1895 c.description = target.clone();
1896 },
1897 )?;
1898 if !intervened.is_empty() {
1899 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1900 mem: mem_name.to_string(),
1901 fields: intervened,
1902 });
1903 }
1904
1905 Ok(crate::ops::SetMemDescriptionOutcome {
1906 mem: mem_name.to_string(),
1907 old_description,
1908 new_description,
1909 warnings,
1910 })
1911 }
1912
1913 pub fn set_mem_title(
1918 &mut self,
1919 mem_name: &str,
1920 new_title: Option<String>,
1921 note: Option<&str>,
1922 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1923 let mount_idx = self
1924 .mounts
1925 .iter()
1926 .position(|m| m.mount.mem == mem_name)
1927 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1928 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1929 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1930 }
1931
1932 let mut warnings = self.reload_if_stale(Some(mem_name));
1933 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1934 warnings.push(w);
1935 }
1936
1937 let old_title = self.mounts[mount_idx]
1938 .mem_config
1939 .as_ref()
1940 .and_then(|c| c.title.clone());
1941 let target = new_title.clone();
1942 let (_, intervened) = self.write_mem_config_merged(
1943 mount_idx,
1944 mem_name,
1945 note,
1946 &move |c: &mut memstead_schema::config::MemConfig| {
1947 c.title = target.clone();
1948 },
1949 )?;
1950 if !intervened.is_empty() {
1951 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1952 mem: mem_name.to_string(),
1953 fields: intervened,
1954 });
1955 }
1956
1957 Ok(crate::ops::SetMemTitleOutcome {
1958 mem: mem_name.to_string(),
1959 old_title,
1960 new_title,
1961 warnings,
1962 })
1963 }
1964
1965 pub fn set_mem_subject(
1969 &mut self,
1970 mem_name: &str,
1971 new_subject: Option<memstead_schema::MemSubject>,
1972 note: Option<&str>,
1973 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1974 let mount_idx = self
1975 .mounts
1976 .iter()
1977 .position(|m| m.mount.mem == mem_name)
1978 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1979 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1980 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1981 }
1982
1983 let mut warnings = self.reload_if_stale(Some(mem_name));
1984 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
1985 warnings.push(w);
1986 }
1987
1988 let old_subject = self.mounts[mount_idx]
1989 .mem_config
1990 .as_ref()
1991 .and_then(|c| c.subject.clone());
1992 let target = new_subject.clone();
1993 let (_, intervened) = self.write_mem_config_merged(
1994 mount_idx,
1995 mem_name,
1996 note,
1997 &move |c: &mut memstead_schema::config::MemConfig| {
1998 c.subject = target.clone();
1999 },
2000 )?;
2001 if !intervened.is_empty() {
2002 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2003 mem: mem_name.to_string(),
2004 fields: intervened,
2005 });
2006 }
2007
2008 Ok(crate::ops::SetMemSubjectOutcome {
2009 mem: mem_name.to_string(),
2010 old_subject,
2011 new_subject,
2012 warnings,
2013 })
2014 }
2015
2016 pub fn set_mem_internal(
2028 &mut self,
2029 mem_name: &str,
2030 internal: bool,
2031 note: Option<&str>,
2032 ) -> Result<crate::ops::SetMemInternalOutcome, EngineError> {
2033 let mount_idx = self
2034 .mounts
2035 .iter()
2036 .position(|m| m.mount.mem == mem_name)
2037 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2038 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2039 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2040 }
2041
2042 let _ = self.reload_if_stale(Some(mem_name));
2043
2044 let (_, intervened) = self.write_mem_config_merged(
2045 mount_idx,
2046 mem_name,
2047 note,
2048 &move |c: &mut memstead_schema::config::MemConfig| {
2049 if internal {
2050 c.extra
2051 .insert("internal".to_string(), serde_json::Value::Bool(true));
2052 } else {
2053 c.extra.remove("internal");
2054 }
2055 },
2056 )?;
2057 let mut warnings = Vec::new();
2062 if !intervened.is_empty() {
2063 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2064 mem: mem_name.to_string(),
2065 fields: intervened,
2066 });
2067 }
2068
2069 Ok(crate::ops::SetMemInternalOutcome {
2070 mem: mem_name.to_string(),
2071 internal,
2072 warnings,
2073 })
2074 }
2075
2076 pub fn set_mem_sync_state(
2101 &mut self,
2102 mem_name: &str,
2103 key: &str,
2104 token: &str,
2105 note: Option<&str>,
2106 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
2107 let mount_idx = self
2110 .mounts
2111 .iter()
2112 .position(|m| m.mount.mem == mem_name)
2113 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2114 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2115 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2116 }
2117
2118 let mut warnings = self.reload_if_stale(Some(mem_name));
2122 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
2123 warnings.push(w);
2124 }
2125
2126 let seen: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
2133 let key_owned = key.to_string();
2134 let token_owned = token.to_string();
2135 let (_, intervened) = self.write_mem_config_merged(
2136 mount_idx,
2137 mem_name,
2138 note,
2139 &|c: &mut memstead_schema::config::MemConfig| {
2140 *seen.borrow_mut() = if token_owned.is_empty() {
2141 c.sync_state.remove(&key_owned)
2142 } else {
2143 c.sync_state.insert(key_owned.clone(), token_owned.clone())
2144 };
2145 },
2146 )?;
2147 let previous = seen.into_inner();
2148 let removed = token.is_empty() && previous.is_some();
2151 if !intervened.is_empty() {
2152 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2153 mem: mem_name.to_string(),
2154 fields: intervened,
2155 });
2156 }
2157
2158 Ok(crate::ops::SetMemSyncStateOutcome {
2159 mem: mem_name.to_string(),
2160 key: key.to_string(),
2161 previous,
2162 removed,
2163 warnings,
2164 })
2165 }
2166
2167 pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
2214 let pending: Vec<String> = self
2215 .mounts
2216 .iter()
2217 .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
2218 .map(|m| m.mount.mem.clone())
2219 .collect();
2220 for name in pending {
2221 match self.reload_one_mem(&name) {
2222 Ok(_) => {
2223 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
2224 state.deferred = false;
2225 }
2226 let store = &self.store;
2232 self.load_warnings.retain(|w| match w {
2233 crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
2234 store.get(resolved_id).is_none_or(|e| e.stub)
2235 }
2236 _ => true,
2237 });
2238 }
2239 Err(e) => {
2240 let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
2245 continue;
2246 };
2247 let removed = self.mounts.remove(idx);
2248 self.schemas_remove(&name);
2249 self.quarantined.push(crate::engine::QuarantinedMem {
2250 mount: removed.mount,
2251 reason_code: e.code().to_string(),
2252 reason_message: e.to_string(),
2253 });
2254 self.mem_router = std::sync::Arc::new(
2255 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2256 );
2257 self.invalidate_communities();
2258 self.invalidate_search_indexes();
2264 }
2265 }
2266 }
2267 }
2268
2269 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2270 if self.quarantine_reason(mem).is_some() {
2293 return self.reattach_quarantined_mem(mem);
2294 }
2295 let mut sink: Vec<WarningHint> = Vec::new();
2296 let result = self.reload_one_mem_inner(mem, &mut sink)?;
2297 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2298 self.load_warnings
2299 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2300 Ok(result)
2301 }
2302
2303 fn set_schema_on_quarantined(
2313 &mut self,
2314 mem: &str,
2315 target: &memstead_schema::SchemaRef,
2316 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2317 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2318 if self.resolve_schema_by_ref(target).is_none() {
2320 let consulted: Vec<_> = self
2321 .workspace_schemas
2322 .iter()
2323 .chain(self.builtin_schemas.iter())
2324 .cloned()
2325 .collect();
2326 return Err(EngineError::SchemaNotFound {
2327 mem: mem.to_string(),
2328 pin: target.as_display(),
2329 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2330 &target.name,
2331 &target.version,
2332 &consulted,
2333 ),
2334 install_hint: None,
2335 }
2336 .with_schema_install_probe(self.workspace_root()));
2337 }
2338 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2339 return Err(self.unknown_mem_error(mem));
2340 };
2341 self.quarantined[q_idx].mount.schema = Some(target.clone());
2345 self.quarantined[q_idx].mount.migration_target = None;
2346 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2347 let _ = bump_backend_schema_pin(backend.as_ref(), target);
2348 }
2349 self.persist_state()?;
2350 let _ = self.reattach_quarantined_mem(mem);
2355 Ok(SetSchemaOutcome {
2356 mem: mem.to_string(),
2357 schema_pin: target.as_display(),
2358 migration_target: None,
2359 outcome: SetSchemaResult::Switched,
2360 findings: Vec::new(),
2361 })
2362 }
2363
2364 fn reattach_quarantined_mem(
2372 &mut self,
2373 mem: &str,
2374 ) -> Result<crate::ops::ReloadResult, EngineError> {
2375 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2376 return Err(self.unknown_mem_error(mem));
2377 };
2378 let mount = self.quarantined[q_idx].mount.clone();
2379
2380 let requarantine = |this: &mut Self, e: &EngineError| {
2381 this.quarantined[q_idx].reason_code = e.code().to_string();
2382 this.quarantined[q_idx].reason_message = e.to_string();
2383 };
2384
2385 let backend = match (self.backend_factory)(&mount) {
2386 Ok(b) => b,
2387 Err(e) => {
2388 let err = EngineError::Mem(e.to_string());
2389 requarantine(self, &err);
2390 return Err(self.unknown_mem_error(mem));
2391 }
2392 };
2393
2394 let last_known_head = backend.current_head().ok().flatten();
2396 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2397 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2398 memstead_schema::config::parse_mem_config(&value).ok()
2399 });
2400 let archive_provenance = backend
2401 .read_archive_provenance()
2402 .ok()
2403 .flatten()
2404 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2405 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2406 let effective_pin = mount
2407 .migration_target
2408 .clone()
2409 .or(config_pin)
2410 .or(mount.schema.clone());
2411 let Some(effective_pin) = effective_pin else {
2412 let err = EngineError::MemConfigIncomplete {
2413 mem: mem.to_string(),
2414 missing_fields: vec!["schema".to_string()],
2415 };
2416 requarantine(self, &err);
2417 return Err(self.unknown_mem_error(mem));
2418 };
2419 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2420 .workspace_schemas
2421 .iter()
2422 .chain(self.builtin_schemas.iter())
2423 .cloned()
2424 .collect();
2425 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2426 Ok(s) => s,
2427 Err(sources) => {
2428 let err = EngineError::SchemaNotFound {
2429 mem: mem.to_string(),
2430 pin: effective_pin.as_display(),
2431 sources,
2432 install_hint: None,
2433 }
2434 .with_schema_install_probe(self.workspace_root());
2435 requarantine(self, &err);
2436 return Err(self.unknown_mem_error(mem));
2437 }
2438 };
2439
2440 self.quarantined.remove(q_idx);
2444 self.schemas_insert(mem.to_string(), schema);
2445 self.mounts.push(crate::engine::MountedBackend {
2446 mount,
2447 backend,
2448 last_known_head,
2449 mem_config,
2450 archive_provenance,
2451 deferred: false,
2454 });
2455 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2456 &self.mounts,
2457 ));
2458 let mut sink: Vec<WarningHint> = Vec::new();
2459 match self.reload_one_mem_inner(mem, &mut sink) {
2460 Ok(result) => {
2461 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2462 self.load_warnings
2463 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2464 self.invalidate_communities();
2465 Ok(result)
2466 }
2467 Err(e) => {
2468 let mount_idx = self.mounts.len() - 1;
2469 let mounted = self.mounts.remove(mount_idx);
2470 self.schemas_remove(mem);
2471 self.mem_router = std::sync::Arc::new(
2472 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2473 );
2474 self.quarantined.push(crate::engine::QuarantinedMem {
2475 mount: mounted.mount,
2476 reason_code: e.code().to_string(),
2477 reason_message: e.to_string(),
2478 });
2479 self.invalidate_communities();
2483 self.invalidate_search_indexes();
2484 Err(e)
2485 }
2486 }
2487 }
2488
2489 fn reload_one_mem_inner(
2495 &mut self,
2496 mem: &str,
2497 warnings_sink: &mut Vec<WarningHint>,
2498 ) -> Result<crate::ops::ReloadResult, EngineError> {
2499 let mount_idx = self
2502 .mounts
2503 .iter()
2504 .position(|m| m.mount.mem == mem)
2505 .ok_or_else(|| self.unknown_mem_error(mem))?;
2506 let schema = self
2507 .schemas
2508 .get(mem)
2509 .cloned()
2510 .ok_or_else(|| self.unknown_mem_error(mem))?;
2511
2512 let pre: HashMap<EntityId, String> = self
2514 .store
2515 .all_entities()
2516 .filter(|e| !e.stub && e.mem == mem)
2517 .map(|e| (e.id.clone(), e.content_hash.clone()))
2518 .collect();
2519 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2520
2521 let backend = self.mounts[mount_idx].backend.as_ref();
2524 let (entries, read_errors) = collect_source_entries(backend)?;
2525 let unbacked = super::boot::unbacked_mount_warning(
2530 &self.mounts[mount_idx].mount,
2531 backend,
2532 Some(entries.len()),
2533 );
2534 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2535
2536 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2540 let known_suffixes: Vec<String> = mem_names
2541 .iter()
2542 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2543 .collect();
2544
2545 self.store.remove_entities_by_mem(mem);
2547 if let Some(w) = unbacked {
2548 warnings_sink.push(w);
2549 }
2550 let fallback = engine_fallback_type();
2551 push_entities_into_store(
2552 &mut self.store,
2553 load_result.entities,
2554 fallback.as_ref(),
2555 Some(crate::entity::store_builder::LoadCollector {
2556 warnings: warnings_sink,
2557 known_suffixes: &known_suffixes,
2558 mem_names: &mem_names,
2559 }),
2560 );
2561 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2568 .mounts
2569 .iter()
2570 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2571 .collect();
2572 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2580 crate::entity::store_builder::validate_loaded_relations(
2581 &mut self.store,
2582 &self.schemas,
2583 &mount_caps,
2584 warnings_sink,
2585 );
2586 crate::entity::store_builder::remap_alias_target_edge_sources(
2587 &mut self.store,
2588 &self.schemas,
2589 );
2590 if let crate::workspace::MountStorage::Folder { path } =
2600 &self.mounts[mount_idx].mount.storage
2601 {
2602 let root = path.clone();
2603 self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2604 self.load_errors
2608 .extend(load_result.errors.into_iter().map(|(p, m)| {
2609 let abs = if p.is_relative() { root.join(&p) } else { p };
2610 (abs, m)
2611 }));
2612 } else {
2613 self.load_errors.extend(load_result.errors);
2614 }
2615
2616 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2624 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2625 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2626 {
2627 self.mounts[mount_idx].mem_config = Some(cfg);
2628 }
2629
2630 let mut added: Vec<EntityId> = Vec::new();
2632 let mut changed: Vec<EntityId> = Vec::new();
2633 for entity in self.store.all_entities() {
2634 if entity.stub || entity.mem != mem {
2635 continue;
2636 }
2637 match pre.get(&entity.id) {
2638 None => added.push(entity.id.clone()),
2639 Some(prev_hash) if prev_hash != &entity.content_hash => {
2640 changed.push(entity.id.clone());
2641 }
2642 Some(_) => {}
2643 }
2644 }
2645 let post_ids: std::collections::HashSet<EntityId> = self
2646 .store
2647 .all_entities()
2648 .filter(|e| !e.stub && e.mem == mem)
2649 .map(|e| e.id.clone())
2650 .collect();
2651 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2652 added.sort_by(|a, b| a.0.cmp(&b.0));
2653 changed.sort_by(|a, b| a.0.cmp(&b.0));
2654 removed.sort_by(|a, b| a.0.cmp(&b.0));
2655
2656 self.invalidate_communities();
2657 self.invalidate_search_indexes();
2658
2659 Ok(crate::ops::ReloadResult {
2660 added,
2661 changed,
2662 removed,
2663 })
2664 }
2665
2666 pub fn reload_one_mem_report(
2694 &mut self,
2695 mem: &str,
2696 ) -> Result<crate::ops::ReloadReport, EngineError> {
2697 let tracks_head = self
2708 .mounts
2709 .iter()
2710 .find(|m| m.mount.mem == mem)
2711 .and_then(|m| m.backend.current_head().ok().flatten())
2712 .is_some();
2713 let head_before = if tracks_head {
2714 self.mounts
2715 .iter()
2716 .find(|m| m.mount.mem == mem)
2717 .and_then(|m| m.last_known_head.clone())
2718 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2719 } else {
2720 crate::ops::EMPTY_TREE_SHA.to_string()
2721 };
2722
2723 let result = self.reload_one_mem(mem)?;
2724
2725 let head_after_raw = self
2732 .mounts
2733 .iter()
2734 .find(|m| m.mount.mem == mem)
2735 .and_then(|m| m.backend.current_head().ok().flatten());
2736 if let Some(new_head) = head_after_raw.clone()
2737 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2738 {
2739 m.last_known_head = Some(new_head);
2740 }
2741 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2742
2743 let entities_loaded = self
2744 .store
2745 .all_entities()
2746 .filter(|e| !e.stub && e.mem == mem)
2747 .count();
2748
2749 let mut changed_entity_ids: Vec<EntityId> = result
2754 .added
2755 .into_iter()
2756 .chain(result.changed)
2757 .chain(result.removed)
2758 .collect();
2759 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2760
2761 Ok(crate::ops::ReloadReport {
2762 mem: mem.to_string(),
2763 head_before,
2764 head_after,
2765 entities_loaded,
2766 changed_entity_ids,
2767 })
2768 }
2769
2770 pub fn reload_each_writable_mem_reports(
2810 &mut self,
2811 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2812 self.refresh_workspace_settings_if_possible();
2813 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2814 let mut out = Vec::with_capacity(names.len());
2815 for name in names {
2816 let report = self.reload_one_mem_report(&name)?;
2817 out.push(report);
2818 }
2819 Ok(out)
2820 }
2821
2822 fn refresh_workspace_settings_if_possible(&mut self) {
2834 let Some(root) = self.workspace_root.clone() else {
2835 return;
2836 };
2837 let store = crate::workspace_store::FileWorkspaceStore::new();
2838 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2839 Ok(w) => w,
2840 Err(_) => return,
2841 };
2842 self.set_settings(workspace.settings);
2843 }
2844
2845 pub fn reload_each_writable_mem(
2861 &mut self,
2862 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2863 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2864 let mut sink = std::mem::take(&mut self.load_warnings);
2870 sink.clear();
2871 let mut out = Vec::with_capacity(names.len());
2872 let mut loop_err = None;
2873 for name in names {
2874 match self.reload_one_mem_inner(&name, &mut sink) {
2875 Ok(result) => out.push((name, result)),
2876 Err(e) => {
2877 loop_err = Some(e);
2878 break;
2879 }
2880 }
2881 }
2882 self.load_warnings = sink;
2883 if let Some(e) = loop_err {
2884 return Err(e);
2885 }
2886 Ok(out)
2887 }
2888}
2889
2890fn changed_config_fields(
2909 cached: &memstead_schema::config::MemConfig,
2910 stored_bytes: &[u8],
2911) -> Vec<String> {
2912 let (Ok(a), Ok(b)) = (
2913 serde_json::to_value(cached),
2914 serde_json::from_slice::<serde_json::Value>(stored_bytes),
2915 ) else {
2916 return Vec::new();
2917 };
2918 let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
2919 return Vec::new();
2920 };
2921 let mut keys: std::collections::BTreeSet<&String> = a.keys().collect();
2922 keys.extend(b.keys());
2923 keys.into_iter()
2924 .filter(|k| a.get(*k) != b.get(*k))
2925 .map(|k| k.to_string())
2926 .collect()
2927}
2928
2929pub fn bump_backend_schema_pin(
2930 backend: &dyn crate::backend::MemBackend,
2931 target: &memstead_schema::SchemaRef,
2932) -> Result<Option<serde_json::Value>, EngineError> {
2933 let Some(bytes) = backend
2934 .read_mem_config()
2935 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2936 else {
2937 return Ok(None);
2938 };
2939 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2940 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2941 value["schema"] = serde_json::Value::String(target.as_display());
2942 let new_bytes = serde_json::to_vec_pretty(&value)
2943 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2944 backend
2945 .write_mem_config(&new_bytes)
2946 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2947 Ok(Some(value))
2948}
2949
2950fn merge_mount_rosters(
2964 baseline: &[crate::workspace::Mount],
2965 ours: &[crate::workspace::Mount],
2966 on_disk: Vec<crate::workspace::Mount>,
2967) -> Vec<crate::workspace::Mount> {
2968 use std::collections::{HashMap, HashSet};
2969
2970 let ours_names: HashSet<&str> = ours.iter().map(|m| m.mem.as_str()).collect();
2971 let removed_by_us: HashSet<&str> = baseline
2972 .iter()
2973 .map(|m| m.mem.as_str())
2974 .filter(|n| !ours_names.contains(n))
2975 .collect();
2976 let baseline_by_name: HashMap<&str, &crate::workspace::Mount> =
2977 baseline.iter().map(|m| (m.mem.as_str(), m)).collect();
2978
2979 let mut merged: Vec<crate::workspace::Mount> = on_disk
2980 .into_iter()
2981 .filter(|m| !removed_by_us.contains(m.mem.as_str()))
2982 .collect();
2983
2984 for mount in ours {
2985 let untouched_by_us = baseline_by_name
2986 .get(mount.mem.as_str())
2987 .is_some_and(|b| *b == mount);
2988 match merged.iter_mut().find(|d| d.mem == mount.mem) {
2989 Some(slot) => {
2990 if !untouched_by_us {
2991 *slot = mount.clone();
2992 }
2993 }
2994 None => merged.push(mount.clone()),
2995 }
2996 }
2997 merged
2998}
2999
3000#[cfg(test)]
3001mod tests {
3002
3003 use tempfile::TempDir;
3004
3005 use crate::backend::{BackendError, MemBackend};
3006 use crate::engine::test_helpers::*;
3007 use crate::engine::{Engine, EngineError};
3008 use crate::mem::MemOrigin;
3009 use crate::ops::WarningHint;
3010 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
3011
3012 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
3013 let manifest = format!(
3014 r#"name: {manifest_name}
3015version: 1.0.0
3016description: Install-gate test schema
3017when_to_use: Tests
3018types:
3019 - sample
3020relationships:
3021 mode: strict
3022 definitions:
3023 - name: PART_OF
3024 description: hier
3025 default_weight: 3.0
3026 - name: _default
3027 description: fallback
3028 default_weight: 1.0
3029community:
3030 resolution: 1.0
3031 seed: 42
3032"#
3033 );
3034 let type_yaml = format!(
3035 r#"name: sample
3036description: t
3037when_to_use: tests
3038sections:
3039 - key: body
3040 heading: {heading}
3041 required: true
3042 search_weight: 10.0
3043 catch_all: true
3044 write_rules: []
3045metadata_fields: []
3046title_weight: 100.0
3047text_fields:
3048 - body
3049hierarchy_relationship: PART_OF
3050no_self_loop_relationships: []
3051updatable_fields:
3052 - title
3053 - body
3054health_required_fields:
3055 - body
3056staleness_threshold_days: 90
3057write_rules: []
3058"#
3059 );
3060 vec![
3061 ("schema.yaml".to_string(), manifest.into_bytes()),
3062 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3063 ]
3064 }
3065
3066 #[test]
3070 fn install_gate_refuses_non_roundtrip_heading() {
3071 let ok =
3072 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
3073 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
3074
3075 let err = Engine::validate_schema_package(
3076 "gate",
3077 "1.0.0",
3078 &schema_package_files("Body Text", "gate"),
3079 )
3080 .expect_err("non-deriving heading must refuse install");
3081 match &err {
3082 EngineError::SchemaPackageInvalid { name, message, .. } => {
3083 assert_eq!(name, "gate");
3084 assert!(
3085 message.contains("'body'") && message.contains("'Body Text'"),
3086 "message names the offending tuple: {message}"
3087 );
3088 }
3089 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3090 }
3091 }
3092
3093 fn exemplar_package_files(
3100 section_key: &str,
3101 status_value: &str,
3102 rel_type: &str,
3103 ) -> Vec<(String, Vec<u8>)> {
3104 let manifest = r#"name: gate
3105version: 1.0.0
3106description: exemplar gate fixture
3107when_to_use: tests
3108types:
3109 - sample
3110 - other
3111relationships:
3112 mode: strict
3113 definitions:
3114 - name: PART_OF
3115 description: hier
3116 default_weight: 3.0
3117 - name: REFINES
3118 description: pinned
3119 default_weight: 1.0
3120 source_types: [other]
3121 - name: _default
3122 description: fallback
3123 default_weight: 1.0
3124community:
3125 resolution: 1.0
3126 seed: 42
3127"#
3128 .to_string();
3129 let type_yaml = format!(
3130 r#"name: sample
3131description: t
3132when_to_use: tests
3133sections:
3134 - key: body
3135 heading: Body
3136 required: true
3137 search_weight: 10.0
3138 catch_all: true
3139 write_rules: []
3140metadata_fields:
3141 - key: status
3142 description: workflow state
3143 field_type: string
3144 enum_values: [draft, final]
3145title_weight: 100.0
3146text_fields:
3147 - body
3148hierarchy_relationship: PART_OF
3149no_self_loop_relationships: []
3150updatable_fields:
3151 - title
3152 - body
3153health_required_fields:
3154 - body
3155staleness_threshold_days: 90
3156write_rules: []
3157exemplar:
3158 title: A Conforming Sample
3159 metadata:
3160 status: "{status_value}"
3161 sections:
3162 {section_key}: "One canonical body paragraph."
3163 relations:
3164 - to: parent-placeholder
3165 type: {rel_type}
3166"#
3167 );
3168 let other_yaml = r#"name: other
3169description: shape-pin partner
3170when_to_use: tests
3171sections:
3172 - key: body
3173 heading: Body
3174 required: true
3175 search_weight: 10.0
3176 catch_all: true
3177 write_rules: []
3178metadata_fields: []
3179title_weight: 100.0
3180text_fields:
3181 - body
3182hierarchy_relationship: PART_OF
3183no_self_loop_relationships: []
3184updatable_fields:
3185 - title
3186 - body
3187health_required_fields:
3188 - body
3189staleness_threshold_days: 90
3190write_rules: []
3191"#
3192 .to_string();
3193 vec![
3194 ("schema.yaml".to_string(), manifest.into_bytes()),
3195 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3196 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
3197 ]
3198 }
3199
3200 #[test]
3207 fn install_gate_validates_exemplars_through_the_real_create_path() {
3208 let ok = Engine::validate_schema_package(
3210 "gate",
3211 "1.0.0",
3212 &exemplar_package_files("body", "draft", "PART_OF"),
3213 );
3214 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
3215
3216 let err = Engine::validate_schema_package(
3218 "gate",
3219 "1.0.0",
3220 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
3221 )
3222 .expect_err("wrong section key must refuse");
3223 match &err {
3224 EngineError::SchemaPackageInvalid { message, .. } => {
3225 assert!(
3226 message.contains("'sample'") && message.contains("exemplar"),
3227 "names type and calls out the exemplar: {message}"
3228 );
3229 assert!(
3230 message.contains("UNKNOWN_SECTION")
3231 || message.contains("MISSING_REQUIRED_SECTION"),
3232 "carries the typed defect code: {message}"
3233 );
3234 }
3235 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3236 }
3237
3238 let err = Engine::validate_schema_package(
3240 "gate",
3241 "1.0.0",
3242 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
3243 )
3244 .expect_err("illegal enum value must refuse");
3245 assert!(
3246 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3247 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
3248 "got {err:?}"
3249 );
3250
3251 let err = Engine::validate_schema_package(
3254 "gate",
3255 "1.0.0",
3256 &exemplar_package_files("body", "draft", "REFINES"),
3257 )
3258 .expect_err("relationship shape violation must refuse");
3259 assert!(
3260 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3261 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
3262 "got {err:?}"
3263 );
3264 }
3265
3266 #[test]
3271 fn worked_example_package_exemplars_validate() {
3272 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3273 .join("../memstead-schema/examples/minimal");
3274 let schema = std::sync::Arc::new(
3275 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
3276 );
3277 assert!(
3278 schema.types.values().all(|td| td.exemplar.is_some()),
3279 "every worked-example type models the exemplar practice"
3280 );
3281 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
3282 }
3283
3284 #[test]
3291 fn builtin_exemplars_validate_through_the_install_gate() {
3292 let schemas = memstead_schema::builtins::load_builtin_schemas()
3293 .expect("built-in schemas always load");
3294 for schema in &schemas {
3296 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
3297 let (name, version) = schema.id();
3298 panic!("built-in {name}@{version}: {defect}");
3299 }
3300 }
3301 let mut newest: std::collections::HashMap<
3303 String,
3304 &std::sync::Arc<memstead_schema::Schema>,
3305 > = std::collections::HashMap::new();
3306 for schema in &schemas {
3307 let name = schema.manifest.name.clone();
3308 match newest.get(&name) {
3309 Some(cur) if cur.version >= schema.version => {}
3310 _ => {
3311 newest.insert(name, schema);
3312 }
3313 }
3314 }
3315 for (name, schema) in &newest {
3316 for (type_name, td) in &schema.types {
3317 assert!(
3318 td.exemplar.is_some(),
3319 "built-in {name}@{} type '{type_name}' has no exemplar — the \
3320 reference schemas model the practice completely",
3321 schema.version
3322 );
3323 }
3324 }
3325 }
3326
3327 #[test]
3332 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
3333 let mut files = exemplar_package_files("body", "draft", "PART_OF");
3334 let patched = String::from_utf8(files[1].1.clone())
3335 .unwrap()
3336 .replace("to: parent-placeholder", "to: other--real-entity");
3337 files[1].1 = patched.into_bytes();
3338 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3339 .expect_err("mem-prefixed exemplar target must refuse");
3340 assert!(
3341 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3342 if message.contains("bare") && message.contains("'sample'")),
3343 "got {err:?}"
3344 );
3345 }
3346
3347 #[test]
3351 fn install_gate_refuses_manifest_identity_mismatch() {
3352 let err = Engine::validate_schema_package(
3353 "gate",
3354 "1.0.0",
3355 &schema_package_files("Body", "other"),
3356 )
3357 .expect_err("identity mismatch must refuse install");
3358 assert!(
3359 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3360 if message.contains("other@1.0.0")),
3361 "got {err:?}"
3362 );
3363 }
3364
3365 #[test]
3366 fn reload_each_writable_mem_repopulates_load_warnings() {
3367 let tmp = TempDir::new().unwrap();
3371 let mem_dir = tmp.path().to_path_buf();
3372 let writer = FilesystemMemWriter::new(mem_dir.clone());
3373 let mut mount = folder_mount("specs", mem_dir.clone());
3376 mount.schema = Some("default@1.3.0".parse().unwrap());
3377 let mut engine =
3378 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3379 assert!(
3382 engine
3383 .load_warnings()
3384 .iter()
3385 .all(|w| w.code() == "MOUNT_UNBACKED"),
3386 "clean boot has no warnings beyond the empty-mount one: {:?}",
3387 engine.load_warnings()
3388 );
3389
3390 let body =
3392 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3393 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3394
3395 engine.reload_each_writable_mem().unwrap();
3396 let warnings = engine.load_warnings();
3397 assert!(
3398 warnings
3399 .iter()
3400 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3401 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3402 );
3403 }
3404
3405 #[test]
3412 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3413 let tmp = TempDir::new().unwrap();
3414 let mem_dir = tmp.path().to_path_buf();
3415 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3417 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3418 let writer = FilesystemMemWriter::new(mem_dir.clone());
3419 let mut engine = Engine::from_mounts(vec![(
3420 folder_mount("specs", mem_dir.clone()),
3421 Box::new(writer) as Box<dyn MemBackend>,
3422 )])
3423 .unwrap();
3424 assert!(
3426 !engine
3427 .load_warnings()
3428 .iter()
3429 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3430 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3431 engine.load_warnings()
3432 );
3433
3434 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";
3436 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3437
3438 engine.reload_each_writable_mem().unwrap();
3439
3440 let invalid: Vec<_> = engine
3441 .load_warnings()
3442 .iter()
3443 .filter_map(|w| match w {
3444 WarningHint::ParsedRelationInvalid {
3445 rel_type,
3446 reason,
3447 origin,
3448 ..
3449 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3450 _ => None,
3451 })
3452 .collect();
3453 assert_eq!(
3454 invalid.len(),
3455 1,
3456 "reload must surface the parse-time drift, got: {invalid:?}",
3457 );
3458 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3459 assert_eq!(invalid[0].1, "unknown_rel_type");
3460 assert_eq!(invalid[0].2, "writable");
3461 }
3462
3463 #[test]
3464 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3465 let tmp = TempDir::new().unwrap();
3472 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3473 let a_dir = tmp.path().join("a");
3474 std::fs::create_dir_all(&a_dir).unwrap();
3475 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3476 let b_dir = tmp.path().join("b");
3477 std::fs::create_dir_all(&b_dir).unwrap();
3478 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3479 let mut engine = Engine::from_mounts(vec![
3480 (
3481 folder_mount("alpha", a_dir.clone()),
3482 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3483 ),
3484 (
3485 folder_mount("beta", b_dir.clone()),
3486 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3487 ),
3488 ])
3489 .unwrap();
3490 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3491 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3492 assert!(
3493 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3494 "boot must populate one warning per mem: {pre:?}"
3495 );
3496
3497 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3499 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3500 engine.reload_one_mem("alpha").unwrap();
3501
3502 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3503 assert!(
3504 !post.contains(&"alpha".to_string()),
3505 "reload must drop the healed mem's stale warning: {post:?}"
3506 );
3507 assert!(
3508 post.contains(&"beta".to_string()),
3509 "reload of alpha must not clear beta's slice: {post:?}"
3510 );
3511 }
3512
3513 #[test]
3514 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3515 let tmp = TempDir::new().unwrap();
3520 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3521 let a_dir = tmp.path().join("a");
3522 std::fs::create_dir_all(&a_dir).unwrap();
3523 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3524 let b_dir = tmp.path().join("b");
3525 std::fs::create_dir_all(&b_dir).unwrap();
3526 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3527 let mut engine = Engine::from_mounts(vec![
3528 (
3529 folder_mount("alpha", a_dir.clone()),
3530 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3531 ),
3532 (
3533 folder_mount("beta", b_dir.clone()),
3534 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3535 ),
3536 ])
3537 .unwrap();
3538 assert!(
3539 engine
3540 .load_warnings()
3541 .iter()
3542 .any(|w| w.source_mem() == Some("alpha")),
3543 "boot must carry alpha-sourced warnings"
3544 );
3545
3546 engine.unregister_writable_mem("alpha").unwrap();
3547
3548 let post = engine.load_warnings();
3549 assert!(
3550 !post.iter().any(|w| w.source_mem() == Some("alpha")),
3551 "delete must purge the removed mem's warnings: {post:?}"
3552 );
3553 assert!(
3554 post.iter().any(|w| w.source_mem() == Some("beta")),
3555 "delete of alpha must keep beta's warnings: {post:?}"
3556 );
3557 }
3558
3559 #[test]
3560 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3561 let tmp = TempDir::new().unwrap();
3567 let a_dir = tmp.path().join("a");
3568 std::fs::create_dir_all(&a_dir).unwrap();
3569 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";
3570 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3571 let b_dir = tmp.path().join("b");
3572 std::fs::create_dir_all(&b_dir).unwrap();
3573 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3574 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3575 let mut engine = Engine::from_mounts(vec![
3576 (
3577 folder_mount("alpha", a_dir.clone()),
3578 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3579 ),
3580 (
3581 folder_mount("beta", b_dir.clone()),
3582 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3583 ),
3584 ])
3585 .unwrap();
3586 let alpha_sourced = |engine: &Engine| {
3587 engine
3588 .load_warnings()
3589 .iter()
3590 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3591 };
3592 assert!(
3593 alpha_sourced(&engine),
3594 "boot must flag alpha's invalid row: {:?}",
3595 engine.load_warnings()
3596 );
3597
3598 engine.unregister_writable_mem("beta").unwrap();
3599
3600 assert!(
3601 alpha_sourced(&engine),
3602 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3603 engine.load_warnings()
3604 );
3605 }
3606
3607 #[test]
3615 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3616 let tmp = TempDir::new().unwrap();
3617 let mem_dir = tmp.path().to_path_buf();
3618 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3619 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3620 let writer = FilesystemMemWriter::new(mem_dir.clone());
3621 let mut engine = Engine::from_mounts(vec![(
3622 folder_mount("specs", mem_dir.clone()),
3623 Box::new(writer) as Box<dyn MemBackend>,
3624 )])
3625 .unwrap();
3626 assert!(
3627 !engine.load_warnings().is_empty(),
3628 "boot must populate load_warnings"
3629 );
3630
3631 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3634 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3635 engine.reload_each_writable_mem_reports().unwrap();
3636 assert!(
3637 engine.load_warnings().is_empty(),
3638 "reports sweep must drop healed warnings: {:?}",
3639 engine.load_warnings()
3640 );
3641
3642 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3644 engine.reload_each_writable_mem_reports().unwrap();
3645 assert!(
3646 engine
3647 .load_warnings()
3648 .iter()
3649 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3650 "reports sweep must surface fresh drift: {:?}",
3651 engine.load_warnings()
3652 );
3653 }
3654
3655 #[test]
3663 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3664 let tmp = TempDir::new().unwrap();
3665 let a_dir = tmp.path().join("a");
3666 let b_dir = tmp.path().join("b");
3667 std::fs::create_dir_all(&a_dir).unwrap();
3668 std::fs::create_dir_all(&b_dir).unwrap();
3669 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3670 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3671 let mut engine = Engine::from_mounts(vec![
3672 (
3673 folder_mount("specs", a_dir),
3674 Box::new(a_writer) as Box<dyn MemBackend>,
3675 ),
3676 (
3677 folder_mount("memos", b_dir),
3678 Box::new(b_writer) as Box<dyn MemBackend>,
3679 ),
3680 ])
3681 .unwrap();
3682
3683 let mut settings = crate::workspace::WorkspaceSettings::default();
3685 settings.cross_mem_links.insert(
3686 "specs".to_string(),
3687 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3688 );
3689 engine.set_settings(settings);
3690
3691 let (actor, client) = cli_actor();
3692 let source = engine
3693 .create_entity(
3694 empty_create_args("specs", "Source"),
3695 actor,
3696 Some(&client),
3697 None,
3698 )
3699 .unwrap();
3700 let target = engine
3701 .create_entity(
3702 empty_create_args("memos", "Target"),
3703 actor,
3704 Some(&client),
3705 None,
3706 )
3707 .unwrap();
3708 engine
3709 .relate_entity(
3710 crate::engine::RelateEntityArgs {
3711 source: source.id.clone(),
3712 expected_hash: Some(source.content_hash.clone()),
3713 rel_type: "USES".to_string(),
3714 target: target.id.clone(),
3715 remove: false,
3716 description: None,
3717 dry_run: false,
3718 },
3719 actor,
3720 Some(&client),
3721 None,
3722 )
3723 .unwrap();
3724
3725 let has_edge = |e: &Engine| {
3727 let out = e
3728 .store()
3729 .outgoing(&source.id)
3730 .iter()
3731 .any(|edge| edge.target == target.id);
3732 let inc = e
3733 .store()
3734 .incoming(&target.id)
3735 .iter()
3736 .any(|edge| edge.from == source.id);
3737 (out, inc)
3738 };
3739
3740 assert_eq!(
3741 has_edge(&engine),
3742 (true, true),
3743 "edge must be indexed in both directions after relate",
3744 );
3745
3746 engine.reload_one_mem("memos").unwrap();
3748 assert_eq!(
3749 has_edge(&engine),
3750 (true, true),
3751 "cross-mem edge into B must survive a per-mem reload of B",
3752 );
3753
3754 engine.reload_each_writable_mem().unwrap();
3757 assert_eq!(
3758 has_edge(&engine),
3759 (true, true),
3760 "per-mem and workspace reload converge on the same edge",
3761 );
3762
3763 assert!(
3766 engine
3767 .store()
3768 .get(&source.id)
3769 .unwrap()
3770 .relationships
3771 .iter()
3772 .any(|r| r.target == target.id),
3773 "source record must retain the relationship throughout",
3774 );
3775 }
3776
3777 #[test]
3783 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3784 let tmp = TempDir::new().unwrap();
3785 let a_dir = tmp.path().join("a");
3786 let b_dir = tmp.path().join("b");
3787 std::fs::create_dir_all(&a_dir).unwrap();
3788 std::fs::create_dir_all(&b_dir).unwrap();
3789 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3790 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3791 let mut engine = Engine::from_mounts(vec![
3792 (
3793 folder_mount("specs", a_dir),
3794 Box::new(a_writer) as Box<dyn MemBackend>,
3795 ),
3796 (
3797 folder_mount("memos", b_dir),
3798 Box::new(b_writer) as Box<dyn MemBackend>,
3799 ),
3800 ])
3801 .unwrap();
3802 let mut settings = crate::workspace::WorkspaceSettings::default();
3803 settings.cross_mem_links.insert(
3804 "specs".to_string(),
3805 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3806 );
3807 engine.set_settings(settings);
3808
3809 let (actor, client) = cli_actor();
3810 let source = engine
3811 .create_entity(
3812 empty_create_args("specs", "Source"),
3813 actor,
3814 Some(&client),
3815 None,
3816 )
3817 .unwrap();
3818 let target = engine
3819 .create_entity(
3820 empty_create_args("memos", "Target"),
3821 actor,
3822 Some(&client),
3823 None,
3824 )
3825 .unwrap();
3826 engine
3827 .relate_entity(
3828 crate::engine::RelateEntityArgs {
3829 source: source.id.clone(),
3830 expected_hash: Some(source.content_hash.clone()),
3831 rel_type: "USES".to_string(),
3832 target: target.id.clone(),
3833 remove: false,
3834 description: None,
3835 dry_run: false,
3836 },
3837 actor,
3838 Some(&client),
3839 None,
3840 )
3841 .unwrap();
3842
3843 engine.reload_one_mem("specs").unwrap();
3844
3845 let out = engine
3846 .store()
3847 .outgoing(&source.id)
3848 .iter()
3849 .any(|edge| edge.target == target.id);
3850 let inc = engine
3851 .store()
3852 .incoming(&target.id)
3853 .iter()
3854 .any(|edge| edge.from == source.id);
3855 assert!(
3856 out && inc,
3857 "outgoing cross-mem edge must survive a source-mem reload"
3858 );
3859 }
3860
3861 #[test]
3862 fn workspace_root_setter_round_trips() {
3863 let tmp = TempDir::new().unwrap();
3864 let mem_dir = tmp.path().to_path_buf();
3865 let writer = FilesystemMemWriter::new(mem_dir.clone());
3866 let mut engine = Engine::from_mounts(vec![(
3867 folder_mount("specs", mem_dir),
3868 Box::new(writer) as Box<dyn MemBackend>,
3869 )])
3870 .unwrap();
3871 let root = tmp.path().to_path_buf();
3872 engine.set_workspace_root(root.clone());
3873 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3874 }
3875
3876 #[test]
3877 fn export_mem_folder_backend_produces_archive() {
3878 let tmp = TempDir::new().unwrap();
3882 let mem_dir = tmp.path().join("specs");
3883 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3884 let config_body = r#"{
3885 "format": 1,
3886 "schema": "default@1.0.0",
3887 "version": "1.0.0"
3888 }"#;
3889 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3890
3891 let writer = FilesystemMemWriter::new(mem_dir.clone());
3892 let engine = Engine::from_mounts(vec![(
3893 folder_mount("specs", mem_dir.clone()),
3894 Box::new(writer) as Box<dyn MemBackend>,
3895 )])
3896 .unwrap();
3897
3898 let archive_path = tmp.path().join("specs.mem");
3899 let result = engine.export_mem("specs", &archive_path).unwrap();
3900 assert!(archive_path.exists(), "archive must exist on disk");
3901 assert!(result.size_bytes > 0);
3902 assert_eq!(result.entity_count, 0);
3905 }
3906
3907 #[test]
3908 fn export_mem_unknown_mem_returns_unknown_mem() {
3909 let tmp = TempDir::new().unwrap();
3910 let mem_dir = tmp.path().to_path_buf();
3911 let writer = FilesystemMemWriter::new(mem_dir.clone());
3912 let engine = Engine::from_mounts(vec![(
3913 folder_mount("specs", mem_dir),
3914 Box::new(writer) as Box<dyn MemBackend>,
3915 )])
3916 .unwrap();
3917 let output = tmp.path().join("out.mem");
3918 let err = engine.export_mem("missing", &output).unwrap_err();
3919 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3920 }
3921
3922 #[test]
3923 fn export_mem_missing_config_returns_invalid_input() {
3924 let tmp = TempDir::new().unwrap();
3928 let mem_dir = tmp.path().to_path_buf();
3929 let writer = FilesystemMemWriter::new(mem_dir.clone());
3930 let engine = Engine::from_mounts(vec![(
3931 folder_mount("specs", mem_dir),
3932 Box::new(writer) as Box<dyn MemBackend>,
3933 )])
3934 .unwrap();
3935 let output = tmp.path().join("out.mem");
3936 let err = engine.export_mem("specs", &output).unwrap_err();
3937 assert!(matches!(err, EngineError::InvalidInput(_)));
3938 }
3939
3940 #[test]
3941 fn export_mem_archive_backend_returns_sealed() {
3942 let tmp = TempDir::new().unwrap();
3945 let archive_path = build_archive(
3946 tmp.path(),
3947 "ext",
3948 &[(
3949 ".memstead/config.json",
3950 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3951 )],
3952 );
3953 let engine = Engine::from_mounts(vec![(
3954 archive_mount("ext", archive_path.clone()),
3955 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3956 )])
3957 .unwrap();
3958 let output = tmp.path().join("out.mem");
3959 let err = engine.export_mem("ext", &output).unwrap_err();
3960 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3961 }
3962
3963 #[test]
3964 fn export_markdown_writes_unchanged_files_zero_writes() {
3965 let tmp = TempDir::new().unwrap();
3970 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3971 let result = engine.export_markdown(None, None).unwrap();
3972 assert_eq!(
3973 result.written, 0,
3974 "freshly-created entity's file already matches generated markdown"
3975 );
3976 assert_eq!(
3977 result.unchanged, 1,
3978 "the one seeded entity counts as unchanged"
3979 );
3980 assert!(
3981 result.skipped_mounts.is_empty(),
3982 "folder-only workspace has no skipped mounts"
3983 );
3984 }
3985
3986 #[test]
3987 fn export_markdown_skips_non_folder_mounts() {
3988 let tmp = TempDir::new().unwrap();
3992 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3993 let engine = Engine::from_mounts(vec![(
3994 archive_mount("ext", archive_path.clone()),
3995 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3996 )])
3997 .unwrap();
3998 let result = engine.export_markdown(None, None).unwrap();
3999 assert_eq!(result.written, 0);
4000 assert_eq!(result.unchanged, 0);
4001 assert_eq!(
4002 result.skipped_mounts.len(),
4003 1,
4004 "archive mount is in the skipped list"
4005 );
4006 let entry = &result.skipped_mounts[0];
4007 assert_eq!(entry.mem, "ext");
4008 assert_eq!(entry.active_backend, "archive");
4009 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
4010 }
4011
4012 #[test]
4013 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
4014 let tmp = TempDir::new().unwrap();
4018 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4019 let engine = Engine::from_mounts(vec![(
4020 archive_mount("ext", archive_path.clone()),
4021 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4022 )])
4023 .unwrap();
4024 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
4025 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
4026 let details = err.details();
4027 assert_eq!(details["mem"], "ext");
4028 assert_eq!(details["active_backend"], "archive");
4029 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
4030 }
4031
4032 #[test]
4033 fn register_writable_mem_adds_mount_and_router_entry() {
4034 let tmp = TempDir::new().unwrap();
4037 let mem_a = tmp.path().join("a");
4038 std::fs::create_dir_all(&mem_a).unwrap();
4039 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4040
4041 let mut engine = Engine::from_mounts(vec![(
4042 folder_mount("alpha", mem_a),
4043 Box::new(writer_a) as Box<dyn MemBackend>,
4044 )])
4045 .unwrap();
4046 assert!(engine.mem_router().is_writable("alpha"));
4047
4048 let mem_b = tmp.path().join("b");
4049 std::fs::create_dir_all(&mem_b).unwrap();
4050 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4051
4052 engine
4053 .register_writable_mem(
4054 folder_mount("beta", mem_b.clone()),
4055 Box::new(writer_b) as Box<dyn MemBackend>,
4056 MemOrigin::ExplicitToml,
4057 )
4058 .unwrap();
4059
4060 assert!(engine.mem_router().is_writable("alpha"));
4062 assert!(engine.mem_router().is_writable("beta"));
4063 assert!(engine.mem_router().is_visible("beta"));
4064
4065 assert!(engine.mount("beta").is_some());
4067 assert!(engine.schemas().contains_key("beta"));
4068
4069 assert_eq!(
4071 engine.mem_router().dir_for_mem("beta"),
4072 Some(mem_b.as_path()),
4073 );
4074 }
4075
4076 #[test]
4082 fn register_writable_mem_resolves_schema_from_mem_config() {
4083 let tmp = TempDir::new().unwrap();
4084 let mem_a = tmp.path().join("a");
4085 std::fs::create_dir_all(&mem_a).unwrap();
4086 let mut engine = Engine::from_mounts(vec![(
4087 folder_mount("alpha", mem_a.clone()),
4088 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
4089 )])
4090 .unwrap();
4091
4092 let mem_b = tmp.path().join("b");
4093 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
4094 std::fs::write(
4095 mem_b.join(".memstead").join("config.json"),
4096 r#"{"schema":"software@0.1.0"}"#,
4097 )
4098 .unwrap();
4099 let mount_b = crate::workspace::Mount {
4100 mem: "beta".to_string(),
4101 schema: Some(memstead_schema::SchemaRef::new(
4102 "totally-not-a-schema",
4103 semver::Version::new(9, 9, 9),
4104 )),
4105 storage: crate::workspace::MountStorage::Folder {
4106 path: mem_b.clone(),
4107 },
4108 capability: crate::workspace::MountCapability::Write,
4109 lifecycle: crate::workspace::MountLifecycle::Eager,
4110 cross_linkable: true,
4111 migration_target: None,
4112 };
4113 engine
4114 .register_writable_mem(
4115 mount_b,
4116 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
4117 MemOrigin::ExplicitToml,
4118 )
4119 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
4120
4121 assert!(engine.schemas().contains_key("beta"));
4122 let surfaced = engine.load_warnings().iter().any(|w| {
4123 matches!(
4124 w,
4125 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
4126 if mem == "beta"
4127 && config_pin == "software@0.1.0"
4128 && mount_pin == "totally-not-a-schema@9.9.9"
4129 )
4130 });
4131 assert!(
4132 surfaced,
4133 "SchemaPinMismatch must surface for beta: {:?}",
4134 engine.load_warnings(),
4135 );
4136 }
4137
4138 #[test]
4139 fn register_writable_mem_rejects_existing_name() {
4140 let tmp = TempDir::new().unwrap();
4143 let mem_a = tmp.path().join("a");
4144 std::fs::create_dir_all(&mem_a).unwrap();
4145 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4146
4147 let mut engine = Engine::from_mounts(vec![(
4148 folder_mount("alpha", mem_a),
4149 Box::new(writer_a) as Box<dyn MemBackend>,
4150 )])
4151 .unwrap();
4152 let mount_count_pre = engine.mounts().len();
4153
4154 let mem_collide = tmp.path().join("alpha-2");
4155 std::fs::create_dir_all(&mem_collide).unwrap();
4156 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
4157
4158 let err = engine
4159 .register_writable_mem(
4160 folder_mount("alpha", mem_collide),
4161 Box::new(writer_collide) as Box<dyn MemBackend>,
4162 MemOrigin::ExplicitToml,
4163 )
4164 .unwrap_err();
4165 match err {
4166 EngineError::MemNameCollision {
4167 name,
4168 source_origin,
4169 } => {
4170 assert_eq!(name, "alpha");
4171 assert!(
4176 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
4177 );
4178 }
4179 other => panic!("expected MemNameCollision, got {other:?}"),
4180 }
4181
4182 assert_eq!(engine.mounts().len(), mount_count_pre);
4184 }
4185
4186 #[test]
4187 fn register_writable_mem_loads_entities_into_store() {
4188 let tmp = TempDir::new().unwrap();
4191 let mem_a = tmp.path().join("a");
4192 std::fs::create_dir_all(&mem_a).unwrap();
4193 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4194
4195 let mut engine = Engine::from_mounts(vec![(
4196 folder_mount("alpha", mem_a),
4197 Box::new(writer_a) as Box<dyn MemBackend>,
4198 )])
4199 .unwrap();
4200 let pre_count = engine.store().all_entities().count();
4201
4202 let mem_b = tmp.path().join("b");
4204 std::fs::create_dir_all(&mem_b).unwrap();
4205 std::fs::write(
4206 mem_b.join("b1.md"),
4207 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4208 )
4209 .unwrap();
4210 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4211
4212 engine
4213 .register_writable_mem(
4214 folder_mount("beta", mem_b),
4215 Box::new(writer_b) as Box<dyn MemBackend>,
4216 MemOrigin::ExplicitToml,
4217 )
4218 .unwrap();
4219
4220 let post_count = engine.store().all_entities().count();
4221 assert!(post_count > pre_count, "register must load entities");
4222 let beta_count = engine
4223 .store()
4224 .all_entities()
4225 .filter(|e| e.mem == "beta")
4226 .count();
4227 assert_eq!(beta_count, 1);
4228 }
4229
4230 #[test]
4231 fn register_then_unregister_round_trips() {
4232 let tmp = TempDir::new().unwrap();
4236 let mem_a = tmp.path().join("a");
4237 std::fs::create_dir_all(&mem_a).unwrap();
4238 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4239
4240 let mut engine = Engine::from_mounts(vec![(
4241 folder_mount("alpha", mem_a),
4242 Box::new(writer_a) as Box<dyn MemBackend>,
4243 )])
4244 .unwrap();
4245 let pre_mounts = engine.mounts().len();
4246
4247 let mem_b = tmp.path().join("b");
4248 std::fs::create_dir_all(&mem_b).unwrap();
4249 let writer_b = FilesystemMemWriter::new(mem_b);
4250
4251 engine
4252 .register_writable_mem(
4253 folder_mount("beta", tmp.path().join("b")),
4254 Box::new(writer_b) as Box<dyn MemBackend>,
4255 MemOrigin::ExplicitToml,
4256 )
4257 .unwrap();
4258 assert_eq!(engine.mounts().len(), pre_mounts + 1);
4259
4260 let removed = engine.unregister_writable_mem("beta").unwrap();
4261 assert!(removed.is_some());
4262 assert_eq!(engine.mounts().len(), pre_mounts);
4263 assert!(!engine.mem_router().is_writable("beta"));
4264 }
4265
4266 #[test]
4267 fn unregister_writable_mem_returns_false_for_unknown_name() {
4268 let tmp = TempDir::new().unwrap();
4272 let mem_dir = tmp.path().to_path_buf();
4273 let writer = FilesystemMemWriter::new(mem_dir.clone());
4274 let mut engine = Engine::from_mounts(vec![(
4275 folder_mount("specs", mem_dir),
4276 Box::new(writer) as Box<dyn MemBackend>,
4277 )])
4278 .unwrap();
4279 let removed = engine.unregister_writable_mem("missing").unwrap();
4280 assert!(removed.is_none(), "unknown mem returns Ok(None)");
4281 assert!(engine.mem_router().is_writable("specs"));
4283 }
4284
4285 #[test]
4286 fn unregister_writable_mem_drops_mount_and_router_entry() {
4287 let tmp = TempDir::new().unwrap();
4292 let mem_a = tmp.path().join("a");
4293 std::fs::create_dir_all(&mem_a).unwrap();
4294 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4295 let mem_b = tmp.path().join("b");
4296 std::fs::create_dir_all(&mem_b).unwrap();
4297 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4298
4299 let mut engine = Engine::from_mounts(vec![
4300 (
4301 folder_mount("alpha", mem_a),
4302 Box::new(writer_a) as Box<dyn MemBackend>,
4303 ),
4304 (
4305 folder_mount("beta", mem_b),
4306 Box::new(writer_b) as Box<dyn MemBackend>,
4307 ),
4308 ])
4309 .unwrap();
4310
4311 let removed = engine.unregister_writable_mem("alpha").unwrap();
4312 assert!(removed.is_some());
4313
4314 assert!(!engine.mem_router().is_writable("alpha"));
4316 assert!(!engine.mem_router().is_visible("alpha"));
4317 assert!(engine.mount("alpha").is_none());
4318
4319 assert!(engine.mem_router().is_writable("beta"));
4321 assert!(engine.mount("beta").is_some());
4322 }
4323
4324 #[test]
4325 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
4326 let tmp = TempDir::new().unwrap();
4330 let mem_a = tmp.path().join("a");
4331 std::fs::create_dir_all(&mem_a).unwrap();
4332 std::fs::write(
4333 mem_a.join("a1.md"),
4334 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
4335 )
4336 .unwrap();
4337 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4338
4339 let mem_b = tmp.path().join("b");
4340 std::fs::create_dir_all(&mem_b).unwrap();
4341 std::fs::write(
4342 mem_b.join("b1.md"),
4343 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4344 )
4345 .unwrap();
4346 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4347
4348 let mut engine = Engine::from_mounts(vec![
4349 (
4350 folder_mount("alpha", mem_a),
4351 Box::new(writer_a) as Box<dyn MemBackend>,
4352 ),
4353 (
4354 folder_mount("beta", mem_b),
4355 Box::new(writer_b) as Box<dyn MemBackend>,
4356 ),
4357 ])
4358 .unwrap();
4359
4360 let pre_total = engine.store().all_entities().count();
4361 assert!(pre_total >= 2, "both mems must load entities");
4362
4363 engine.unregister_writable_mem("alpha").unwrap();
4364
4365 let alpha_remaining = engine
4367 .store()
4368 .all_entities()
4369 .filter(|e| e.mem == "alpha")
4370 .count();
4371 assert_eq!(alpha_remaining, 0);
4372
4373 let beta_remaining = engine
4375 .store()
4376 .all_entities()
4377 .filter(|e| e.mem == "beta")
4378 .count();
4379 assert!(beta_remaining > 0, "beta entities must survive");
4380 }
4381 #[test]
4382 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4383 let tmp = TempDir::new().unwrap();
4384 let mut engine = build_demo_engine(&tmp);
4385 let result = engine
4386 .reload_one_mem("specs")
4387 .expect("reload on stable disk must succeed");
4388 assert!(result.added.is_empty(), "added: {:?}", result.added);
4389 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4390 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4391 }
4392
4393 #[test]
4394 fn reload_one_mem_picks_up_external_addition() {
4395 let tmp = TempDir::new().unwrap();
4396 let mut engine = build_demo_engine(&tmp);
4397 std::fs::write(
4400 tmp.path().join("external.md"),
4401 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4402 )
4403 .unwrap();
4404 let result = engine.reload_one_mem("specs").unwrap();
4405 assert_eq!(
4406 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4407 vec!["specs--external"]
4408 );
4409 assert!(result.changed.is_empty());
4410 assert!(result.removed.is_empty());
4411 assert!(
4413 engine
4414 .get_entity(&crate::EntityId::new("specs", "external"))
4415 .is_some()
4416 );
4417 }
4418
4419 #[test]
4420 fn reload_one_mem_picks_up_external_removal() {
4421 let tmp = TempDir::new().unwrap();
4422 let mut engine = build_demo_engine(&tmp);
4423 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4426 let result = engine.reload_one_mem("specs").unwrap();
4427 assert!(result.added.is_empty());
4428 assert!(result.changed.is_empty());
4429 assert_eq!(
4430 result
4431 .removed
4432 .iter()
4433 .map(|i| i.as_ref())
4434 .collect::<Vec<_>>(),
4435 vec!["specs--lonely-three"]
4436 );
4437 }
4438
4439 #[test]
4440 fn reload_one_mem_picks_up_external_change() {
4441 let tmp = TempDir::new().unwrap();
4442 let mut engine = build_demo_engine(&tmp);
4443 std::fs::write(
4446 tmp.path().join("source-one.md"),
4447 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4448 )
4449 .unwrap();
4450 let result = engine.reload_one_mem("specs").unwrap();
4451 assert!(result.added.is_empty());
4452 assert_eq!(
4453 result
4454 .changed
4455 .iter()
4456 .map(|i| i.as_ref())
4457 .collect::<Vec<_>>(),
4458 vec!["specs--source-one"]
4459 );
4460 assert!(result.removed.is_empty());
4461 }
4462
4463 #[test]
4464 fn reload_one_mem_rejects_unknown_mem() {
4465 let tmp = TempDir::new().unwrap();
4466 let mut engine = build_demo_engine(&tmp);
4467 let err = engine.reload_one_mem("nope").unwrap_err();
4468 match err {
4469 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4470 other => panic!("expected UnknownMem, got {other:?}"),
4471 }
4472 }
4473
4474 #[test]
4475 fn reload_each_writable_mem_returns_one_entry_per_mount() {
4476 let tmp = TempDir::new().unwrap();
4477 let mut engine = build_demo_engine(&tmp);
4478 let reports = engine
4479 .reload_each_writable_mem()
4480 .expect("batch reload on stable disk must succeed");
4481 assert_eq!(reports.len(), 1);
4482 assert_eq!(reports[0].0, "specs");
4483 assert!(reports[0].1.added.is_empty());
4484 assert!(reports[0].1.changed.is_empty());
4485 assert!(reports[0].1.removed.is_empty());
4486 }
4487
4488 #[test]
4491 fn settings_default_to_empty_on_fresh_engine() {
4492 let tmp = TempDir::new().unwrap();
4493 let engine = build_demo_engine(&tmp);
4494 let s = engine.settings();
4495 assert!(s.mem_create_rules.is_empty());
4496 assert!(s.mem_delete_rules.is_empty());
4497 assert!(s.cross_mem_links.is_empty());
4498 }
4499
4500 #[test]
4501 fn set_settings_replaces_workspace_policy() {
4502 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4503 let tmp = TempDir::new().unwrap();
4504 let mut engine = build_demo_engine(&tmp);
4505 let mut settings = WorkspaceSettings::default();
4506 settings.mem_create_rules.push(CreateRuleSetting {
4507 pattern: "exec-*".to_string(),
4508 schemas: vec!["default@1.0.0".to_string()],
4509 default_cross_links: None,
4510 });
4511 settings.mem_delete_rules.push(DeleteRuleSetting {
4512 pattern: "exec-*".to_string(),
4513 });
4514 engine.set_settings(settings);
4515 assert_eq!(engine.settings().mem_create_rules.len(), 1);
4516 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4517 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4518 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4519 }
4520
4521 #[test]
4524 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4525 let tmp = TempDir::new().unwrap();
4526 let mut engine = build_demo_engine(&tmp);
4527 std::fs::write(
4529 tmp.path().join("new-via-disk.md"),
4530 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4531 )
4532 .unwrap();
4533 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4534 std::fs::write(
4535 tmp.path().join("source-one.md"),
4536 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4537 )
4538 .unwrap();
4539
4540 let reports = engine.reload_each_writable_mem().unwrap();
4541 assert_eq!(reports.len(), 1);
4542 let (mem, result) = &reports[0];
4543 assert_eq!(mem, "specs");
4544 assert_eq!(
4545 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4546 vec!["specs--new-via-disk"]
4547 );
4548 assert_eq!(
4549 result
4550 .removed
4551 .iter()
4552 .map(|i| i.as_ref())
4553 .collect::<Vec<_>>(),
4554 vec!["specs--lonely-three"]
4555 );
4556 assert_eq!(
4557 result
4558 .changed
4559 .iter()
4560 .map(|i| i.as_ref())
4561 .collect::<Vec<_>>(),
4562 vec!["specs--source-one"]
4563 );
4564 }
4565
4566 #[test]
4569 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4570 let tmp = TempDir::new().unwrap();
4578 let mut engine = build_demo_engine(&tmp);
4579 let report = engine.reload_one_mem_report("specs").unwrap();
4580 assert_eq!(report.mem, "specs");
4581 assert_eq!(
4582 report.head_before, report.head_after,
4583 "unchanged disk → stable cursor"
4584 );
4585 assert!(
4586 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4587 "folder heads carry the changelog-ts cursor, got {}",
4588 report.head_after
4589 );
4590 assert_eq!(report.entities_loaded, 3);
4593 assert!(report.changed_entity_ids.is_empty());
4595 }
4596
4597 #[test]
4598 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4599 let tmp = TempDir::new().unwrap();
4604 let mut engine = build_demo_engine(&tmp);
4605 std::fs::write(
4606 tmp.path().join("new-via-disk.md"),
4607 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4608 )
4609 .unwrap();
4610 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4611 std::fs::write(
4612 tmp.path().join("source-one.md"),
4613 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4614 )
4615 .unwrap();
4616
4617 let report = engine.reload_one_mem_report("specs").unwrap();
4618 assert_eq!(report.mem, "specs");
4619 let ids: Vec<&str> = report
4620 .changed_entity_ids
4621 .iter()
4622 .map(|id| id.as_ref())
4623 .collect();
4624 assert_eq!(
4626 ids,
4627 vec![
4628 "specs--lonely-three",
4629 "specs--new-via-disk",
4630 "specs--source-one",
4631 ]
4632 );
4633 }
4634
4635 #[test]
4636 fn reload_one_mem_report_rejects_unknown_mem() {
4637 let tmp = TempDir::new().unwrap();
4638 let mut engine = build_demo_engine(&tmp);
4639 let err = engine.reload_one_mem_report("missing").unwrap_err();
4640 assert!(matches!(err, EngineError::UnknownMem(_)));
4641 }
4642
4643 #[test]
4644 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4645 let tmp = TempDir::new().unwrap();
4646 let mut engine = build_demo_engine(&tmp);
4647 let reports = engine.reload_each_writable_mem_reports().unwrap();
4648 assert_eq!(reports.len(), 1);
4649 assert_eq!(reports[0].mem, "specs");
4650 assert_eq!(reports[0].entities_loaded, 3);
4651 }
4652
4653 #[test]
4660 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4661 let tmp = TempDir::new().unwrap();
4662
4663 let memstead_dir = tmp.path().join(".memstead");
4666 std::fs::create_dir_all(&memstead_dir).unwrap();
4667 let workspace_toml = memstead_dir.join("workspace.toml");
4668 std::fs::write(
4669 &workspace_toml,
4670 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4671 )
4672 .unwrap();
4673 let mounts_json = memstead_dir.join("state").join("mounts.json");
4674 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4675 let mem_dir = tmp.path().join("specs");
4676 std::fs::create_dir_all(&mem_dir).unwrap();
4677 let mounts_body = format!(
4678 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4679 mem_dir.display(),
4680 );
4681 std::fs::write(&mounts_json, mounts_body).unwrap();
4682
4683 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4684 assert!(
4685 engine.settings().mem_create_rules.is_empty(),
4686 "boot-time settings carry no create rules"
4687 );
4688
4689 std::fs::write(
4691 &workspace_toml,
4692 "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",
4693 )
4694 .unwrap();
4695
4696 engine.reload_each_writable_mem_reports().unwrap();
4697
4698 let rules = &engine.settings().mem_create_rules;
4699 assert_eq!(
4700 rules.len(),
4701 1,
4702 "workspace-wide reload must refresh the policy"
4703 );
4704 assert_eq!(rules[0].pattern, "exec-*");
4705 }
4706
4707 const MIG_TYPE_TAIL: &str = r#"sections:
4712 - key: body
4713 heading: Body
4714 required: true
4715 search_weight: 10.0
4716 catch_all: true
4717 write_rules: []
4718title_weight: 100.0
4719text_fields:
4720 - body
4721hierarchy_relationship: _default
4722no_self_loop_relationships: []
4723updatable_fields: []
4724health_required_fields: []
4725staleness_threshold_days: 90
4726write_rules: []
4727"#;
4728
4729 fn mig_manifest(name: &str, version: &str) -> String {
4734 format!(
4735 r#"name: {name}
4736version: {version}
4737description: migration test schema
4738when_to_use: tests
4739types:
4740 - doc
4741relationships:
4742 mode: strict
4743 definitions:
4744 - name: USES
4745 description: link
4746 default_weight: 1.0
4747 - name: _default
4748 description: fallback
4749 default_weight: 1.0
4750community:
4751 resolution: 1.0
4752 seed: 42
4753"#
4754 )
4755 }
4756
4757 fn mig_type_yaml(with_status: bool) -> String {
4758 let metadata = if with_status {
4759 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4760 } else {
4761 "metadata_fields: []\n"
4762 };
4763 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4764 }
4765
4766 fn write_mig_schema(
4767 root: &std::path::Path,
4768 dir: &str,
4769 name: &str,
4770 version: &str,
4771 with_status: bool,
4772 ) {
4773 let d = root.join(dir);
4774 std::fs::create_dir_all(d.join("types")).unwrap();
4775 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4776 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4777 }
4778
4779 #[test]
4796 fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4797 for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4798 run_mutation_sequence(seed);
4799 }
4800 }
4801
4802 struct Xorshift(u64);
4803 impl Xorshift {
4804 fn next(&mut self) -> u64 {
4805 let mut x = self.0;
4806 x ^= x << 13;
4807 x ^= x >> 7;
4808 x ^= x << 17;
4809 self.0 = x;
4810 x
4811 }
4812 fn pick(&mut self, n: usize) -> usize {
4813 (self.next() % n as u64) as usize
4814 }
4815 }
4816
4817 fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4818 let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4821 let live = engine.search_indexes();
4822 let mut live_mems: Vec<&String> = live.keys().collect();
4823 let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4824 live_mems.sort();
4825 fresh_mems.sort();
4826 assert_eq!(
4827 live_mems, fresh_mems,
4828 "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4829 );
4830 for (mem, idx) in live {
4831 let mut got = idx.stored_ids().unwrap();
4832 let mut want = fresh[mem].stored_ids().unwrap();
4833 got.sort();
4834 want.sort();
4835 assert_eq!(
4836 got, want,
4837 "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4838 );
4839 }
4840 let schema = engine
4843 .schemas
4844 .iter()
4845 .min_by(|a, b| a.0.cmp(b.0))
4846 .map(|(_, s)| s.clone())
4847 .expect("schema present");
4848 let weights_schema = schema.clone();
4849 let fresh_partition = crate::graph::community::detect_communities(
4850 engine.store(),
4851 schema.manifest.community.resolution,
4852 schema.manifest.community.seed,
4853 move |rel_type| {
4854 weights_schema
4855 .manifest
4856 .relationships
4857 .definitions
4858 .iter()
4859 .find(|d| d.name == rel_type)
4860 .map(|d| d.default_weight as f64)
4861 .unwrap_or(1.0)
4862 },
4863 );
4864 assert_eq!(
4865 engine.communities().entity_cluster_map,
4866 fresh_partition.entity_cluster_map,
4867 "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4868 );
4869 }
4870
4871 fn run_mutation_sequence(seed: u64) {
4872 use indexmap::IndexMap;
4873
4874 let (_tmp, mut engine) = migration_engine();
4875 let mut rng = Xorshift(seed);
4876 let mut live: Vec<crate::EntityId> = vec![
4877 crate::EntityId::new("specs", "one"),
4878 crate::EntityId::new("specs", "two"),
4879 ];
4880 let mut counter = 0usize;
4881 let mut kinds_hit: std::collections::HashSet<&'static str> =
4882 std::collections::HashSet::new();
4883
4884 const KINDS: [&str; 7] = [
4885 "create",
4886 "update",
4887 "relate",
4888 "delete",
4889 "rename",
4890 "batch_applied",
4891 "batch_refused",
4892 ];
4893
4894 let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4895 anchors: Vec::new(),
4896 anchors_unset: Vec::new(),
4897 id,
4898 expected_hash: None,
4899 sections: IndexMap::new(),
4900 append_sections: IndexMap::new(),
4901 patch_sections: IndexMap::new(),
4902 sections_unset: Vec::new(),
4903 metadata: IndexMap::new(),
4904 metadata_unset: Vec::new(),
4905 declare_relations: Vec::new(),
4906 dry_run: false,
4907 relations_unset: Vec::new(),
4908 };
4909
4910 for op_i in 0..30usize {
4911 let kind = *KINDS
4914 .get(op_i)
4915 .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4916 match kind {
4917 "create" => {
4918 counter += 1;
4919 let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4920 args.entity_type = "doc".to_string();
4921 args.sections = IndexMap::from_iter([(
4922 "body".to_string(),
4923 format!("generated body {counter}"),
4924 )]);
4925 let out = engine
4926 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4927 .expect("generated create is conformant");
4928 live.push(out.id);
4929 kinds_hit.insert("create");
4930 }
4931 "update" => {
4932 let id = live[rng.pick(live.len())].clone();
4933 let mut args = bare_update(id);
4934 args.append_sections
4935 .insert("body".to_string(), format!("appended at op {op_i}"));
4936 engine
4937 .update_entity(args, crate::vcs::Actor::Cli, None, None)
4938 .expect("append update is conformant");
4939 kinds_hit.insert("update");
4940 }
4941 "relate" => {
4942 if live.len() >= 2 {
4943 let a = rng.pick(live.len());
4944 let mut b = rng.pick(live.len());
4945 if a == b {
4946 b = (b + 1) % live.len();
4947 }
4948 engine
4949 .relate_entity(
4950 crate::engine::RelateEntityArgs {
4951 source: live[a].clone(),
4952 expected_hash: None,
4953 rel_type: "USES".to_string(),
4954 target: live[b].clone(),
4955 remove: false,
4956 description: None,
4957 dry_run: false,
4958 },
4959 crate::vcs::Actor::Cli,
4960 None,
4961 None,
4962 )
4963 .expect("USES relate is legal under mig-a");
4964 kinds_hit.insert("relate");
4965 }
4966 }
4967 "delete" => {
4968 if live.len() > 2
4971 && let Some(pos) = (0..live.len()).find(|&i| {
4972 engine.store().incoming(&live[i]).is_empty()
4973 && engine
4974 .store()
4975 .get(&live[i])
4976 .is_some_and(|e| e.relationships.is_empty())
4977 })
4978 {
4979 let id = live.remove(pos);
4980 engine
4981 .delete_entity(
4982 crate::engine::DeleteEntityArgs {
4983 id: id.clone(),
4984 expected_hash: None,
4985 },
4986 crate::vcs::Actor::Cli,
4987 None,
4988 None,
4989 )
4990 .expect("reference-free delete lands");
4991 kinds_hit.insert("delete");
4992 }
4993 }
4994 "rename" => {
4995 counter += 1;
4996 let pos = rng.pick(live.len());
4997 let old = live[pos].clone();
4998 let out = engine
4999 .rename_entity(
5000 crate::engine::RenameEntityArgs {
5001 id: old,
5002 new_title: format!("Renamed {counter}"),
5003 expected_hash: None,
5004 },
5005 crate::vcs::Actor::Cli,
5006 None,
5007 None,
5008 )
5009 .expect("fresh-slug rename lands");
5010 live[pos] = out.new_id;
5011 kinds_hit.insert("rename");
5012 }
5013 "batch_applied" => {
5014 let id_a = live[rng.pick(live.len())].clone();
5015 let mut a = bare_update(id_a);
5016 a.append_sections
5017 .insert("body".to_string(), format!("batch line {op_i}"));
5018 let result = engine
5019 .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
5020 .expect("batch envelope");
5021 assert!(result.applied, "single-entry append batch applies");
5022 kinds_hit.insert("batch_applied");
5023 }
5024 "batch_refused" => {
5025 let id_a = live[rng.pick(live.len())].clone();
5026 let mut a = bare_update(id_a);
5027 a.append_sections
5028 .insert("body".to_string(), "doomed".to_string());
5029 let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
5030 let result = engine
5031 .batch_update(
5032 vec![(a, None), (missing, None)],
5033 crate::vcs::Actor::Cli,
5034 None,
5035 false,
5036 )
5037 .expect("refused batch returns a report-all envelope");
5038 assert!(!result.applied, "the missing target refuses the batch");
5039 kinds_hit.insert("batch_refused");
5040 }
5041 _ => unreachable!(),
5042 }
5043
5044 if op_i == 14 {
5045 engine
5049 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5050 .expect("integral switch");
5051 kinds_hit.insert("schema_switch");
5052 }
5053 if op_i == 19 {
5054 engine.reload_one_mem("specs").expect("reload lands");
5055 kinds_hit.insert("reload");
5056 }
5057
5058 if op_i % 10 == 9 {
5059 assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
5060 }
5061 }
5062
5063 assert_derived_oracles(&engine, seed, "sequence end");
5064
5065 for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
5066 assert!(
5067 kinds_hit.contains(kind),
5068 "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
5069 );
5070 }
5071 }
5072
5073 fn migration_engine() -> (tempfile::TempDir, Engine) {
5074 let tmp = tempfile::TempDir::new().unwrap();
5075 let schemas_dir = tmp.path().join("schemas");
5076 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5077 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5078 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
5079 let mem_dir = tmp.path().join("mem");
5080 std::fs::create_dir_all(&mem_dir).unwrap();
5081 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5082 let mut mount = folder_mount("specs", mem_dir);
5083 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5084 let mut engine = Engine::from_mounts_with_schemas_dir(
5085 vec![(
5086 mount,
5087 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5088 )],
5089 Some(&schemas_dir),
5090 )
5091 .unwrap();
5092 for title in ["One", "Two"] {
5093 let mut args = empty_create_args("specs", title);
5094 args.entity_type = "doc".to_string();
5095 args.sections =
5096 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
5097 engine
5098 .create_entity(args, crate::vcs::Actor::Cli, None, None)
5099 .expect("conformant create under mig-a");
5100 }
5101 (tmp, engine)
5102 }
5103
5104 fn sref(s: &str) -> memstead_schema::SchemaRef {
5105 s.parse().unwrap()
5106 }
5107
5108 #[test]
5114 fn set_schema_repairs_a_mount_expectation_ahead_of_the_served_pin() {
5115 let (_tmp, mut engine) = migration_engine();
5116 let idx = engine
5119 .mounts
5120 .iter()
5121 .position(|m| m.mount.mem == "specs")
5122 .unwrap();
5123 engine.mounts[idx].mount.schema = Some(sref("mig-b@0.1.0"));
5124 assert_eq!(engine.schemas.get("specs").unwrap().id().0, "mig-a");
5125
5126 let out = engine
5127 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5128 .unwrap();
5129 assert_eq!(
5133 out.outcome,
5134 crate::engine::SetSchemaResult::MigrationStarted,
5135 "a served pin behind the target enters the switch path, never a noop: {out:?}"
5136 );
5137 assert!(!out.findings.is_empty());
5138 assert_eq!(
5139 engine.schemas.get("specs").unwrap().id().0,
5140 "mig-b",
5141 "writes now validate against the target"
5142 );
5143
5144 let (_tmp2, mut clean) = migration_engine();
5147 let again = clean.set_mem_schema("specs", &sref("mig-a@0.1.0")).unwrap();
5148 assert_eq!(again.outcome, crate::engine::SetSchemaResult::Noop);
5149 }
5150
5151 #[test]
5152 fn set_schema_noop_on_current_pin() {
5153 let (_tmp, mut engine) = migration_engine();
5154 let out = engine
5155 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
5156 .unwrap();
5157 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
5158 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5159 assert_eq!(out.migration_target, None);
5160 assert!(out.findings.is_empty());
5161 }
5162
5163 #[test]
5172 fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
5173 let (_tmp, mut engine) = migration_engine();
5174
5175 let _ = engine.communities();
5176 let _ = engine.search_indexes();
5177 assert!(engine.community_memo.get().is_some());
5178 assert!(engine.search_indexes_memo.get().is_some());
5179 let store_gen_before = engine.store().generation();
5180
5181 engine
5182 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5183 .unwrap();
5184
5185 assert_eq!(
5186 engine.store().generation(),
5187 store_gen_before,
5188 "a schema switch mutates no store content"
5189 );
5190 assert!(
5191 engine.community_memo.get().is_none(),
5192 "the community memo must clear on a schema switch (weights derive from the schema)"
5193 );
5194 assert!(
5195 engine.search_indexes_memo.get().is_none(),
5196 "the search memo must clear on a schema switch (the field set derives from the schema)"
5197 );
5198 }
5199
5200 #[test]
5201 fn set_schema_switches_immediately_when_integral() {
5202 let (_tmp, mut engine) = migration_engine();
5205 let out = engine
5206 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5207 .unwrap();
5208 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5209 assert_eq!(out.schema_pin, "mig-a@0.2.0");
5210 assert_eq!(out.migration_target, None);
5211 assert!(out.findings.is_empty());
5212 assert_eq!(
5213 engine.schema_pin("specs").unwrap().as_display(),
5214 "mig-a@0.2.0"
5215 );
5216 assert!(engine.migration_target("specs").is_none());
5217 }
5218
5219 #[test]
5225 fn set_schema_switch_persists_pin_into_backend_config() {
5226 let tmp = tempfile::TempDir::new().unwrap();
5227 let schemas_dir = tmp.path().join("schemas");
5228 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5229 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5230 let mem_dir = tmp.path().join("mem");
5231 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
5232 std::fs::write(
5234 mem_dir.join(".memstead").join("config.json"),
5235 br#"{"schema":"mig-a@0.1.0"}"#,
5236 )
5237 .unwrap();
5238 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5239 let mut mount = folder_mount("specs", mem_dir.clone());
5240 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5241 let mut engine = Engine::from_mounts_with_schemas_dir(
5242 vec![(
5243 mount,
5244 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5245 )],
5246 Some(&schemas_dir),
5247 )
5248 .unwrap();
5249
5250 let out = engine
5251 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5252 .unwrap();
5253 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5254
5255 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
5258 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
5259 assert_eq!(
5260 cfg["schema"], "mig-a@0.2.0",
5261 "atomic switch must update the authoritative backend config"
5262 );
5263 }
5264
5265 #[test]
5266 fn set_schema_unknown_target_refuses_schema_not_found() {
5267 let (_tmp, mut engine) = migration_engine();
5268 let err = engine
5269 .set_mem_schema("specs", &sref("nope@9.9.9"))
5270 .unwrap_err();
5271 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
5272 assert!(engine.migration_target("specs").is_none());
5274 }
5275
5276 #[test]
5277 fn set_schema_migration_lifecycle_end_to_end() {
5278 let (_tmp, mut engine) = migration_engine();
5279 let target = sref("mig-b@0.1.0");
5280
5281 let out = engine.set_mem_schema("specs", &target).unwrap();
5283 assert_eq!(
5284 out.outcome,
5285 crate::engine::SetSchemaResult::MigrationStarted
5286 );
5287 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5288 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
5289 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
5290 assert!(
5291 out.findings
5292 .iter()
5293 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
5294 );
5295
5296 let one = crate::entity::EntityId::new("specs", "one");
5298 assert!(engine.store().get(&one).is_some());
5299
5300 let out = engine.set_mem_schema("specs", &target).unwrap();
5302 assert_eq!(
5303 out.outcome,
5304 crate::engine::SetSchemaResult::MigrationPending
5305 );
5306 assert_eq!(out.findings.len(), 2);
5307
5308 let mut bad = crate::engine::UpdateEntityArgs {
5312 anchors: Vec::new(),
5313 id: one.clone(),
5314 expected_hash: None,
5315 sections: indexmap::IndexMap::new(),
5316 append_sections: indexmap::IndexMap::new(),
5317 patch_sections: indexmap::IndexMap::new(),
5318 sections_unset: Vec::new(),
5319 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
5320 metadata_unset: Vec::new(),
5321 declare_relations: Vec::new(),
5322 dry_run: false,
5323 relations_unset: Vec::new(),
5324 anchors_unset: Vec::new(),
5325 };
5326 let err = engine
5327 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
5328 .unwrap_err();
5329 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
5330 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
5331 engine
5332 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
5333 .expect("repair write validated against the migration target");
5334
5335 let out = engine.set_mem_schema("specs", &target).unwrap();
5337 assert_eq!(
5338 out.outcome,
5339 crate::engine::SetSchemaResult::MigrationPending
5340 );
5341 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
5342
5343 let two = crate::entity::EntityId::new("specs", "two");
5345 let repair = crate::engine::UpdateEntityArgs {
5346 anchors: Vec::new(),
5347 id: two.clone(),
5348 expected_hash: None,
5349 sections: indexmap::IndexMap::new(),
5350 append_sections: indexmap::IndexMap::new(),
5351 patch_sections: indexmap::IndexMap::new(),
5352 sections_unset: Vec::new(),
5353 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
5354 metadata_unset: Vec::new(),
5355 declare_relations: Vec::new(),
5356 dry_run: false,
5357 relations_unset: Vec::new(),
5358 anchors_unset: Vec::new(),
5359 };
5360 engine
5361 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
5362 .unwrap();
5363 let out = engine.set_mem_schema("specs", &target).unwrap();
5364 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5365 assert_eq!(out.schema_pin, "mig-b@0.1.0");
5366 assert_eq!(out.migration_target, None);
5367 assert!(out.findings.is_empty());
5368 assert_eq!(
5369 engine.schema_pin("specs").unwrap().as_display(),
5370 "mig-b@0.1.0"
5371 );
5372 assert!(engine.migration_target("specs").is_none());
5373 }
5374
5375 #[test]
5380 fn relations_unset_works_during_migration_without_mode_flag() {
5381 let (_tmp, mut engine) = migration_engine();
5382 let one = crate::entity::EntityId::new("specs", "one");
5383 let two = crate::entity::EntityId::new("specs", "two");
5384 engine
5385 .relate_entity(
5386 crate::engine::RelateEntityArgs {
5387 source: one.clone(),
5388 expected_hash: None,
5389 rel_type: "USES".to_string(),
5390 target: two.clone(),
5391 remove: false,
5392 description: None,
5393 dry_run: false,
5394 },
5395 crate::vcs::Actor::Cli,
5396 None,
5397 None,
5398 )
5399 .unwrap();
5400 let shut = engine
5402 .update_entity(
5403 crate::engine::UpdateEntityArgs {
5404 anchors: Vec::new(),
5405 id: one.clone(),
5406 expected_hash: None,
5407 sections: indexmap::IndexMap::new(),
5408 append_sections: indexmap::IndexMap::new(),
5409 patch_sections: indexmap::IndexMap::new(),
5410 sections_unset: Vec::new(),
5411 metadata: indexmap::IndexMap::new(),
5412 metadata_unset: Vec::new(),
5413 declare_relations: Vec::new(),
5414 dry_run: false,
5415 relations_unset: vec![crate::ops::RelationUnsetArg {
5416 rel_type: "USES".to_string(),
5417 target: two.clone(),
5418 }],
5419 anchors_unset: Vec::new(),
5420 },
5421 crate::vcs::Actor::Cli,
5422 None,
5423 None,
5424 )
5425 .unwrap_err();
5426 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5427
5428 engine
5432 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5433 .unwrap();
5434 engine
5435 .update_entity(
5436 crate::engine::UpdateEntityArgs {
5437 anchors: Vec::new(),
5438 id: one.clone(),
5439 expected_hash: None,
5440 sections: indexmap::IndexMap::new(),
5441 append_sections: indexmap::IndexMap::new(),
5442 patch_sections: indexmap::IndexMap::new(),
5443 sections_unset: Vec::new(),
5444 metadata: indexmap::IndexMap::from_iter([(
5445 "status".to_string(),
5446 "open".to_string(),
5447 )]),
5448 metadata_unset: Vec::new(),
5449 declare_relations: Vec::new(),
5450 dry_run: false,
5451 relations_unset: vec![crate::ops::RelationUnsetArg {
5452 rel_type: "USES".to_string(),
5453 target: two.clone(),
5454 }],
5455 anchors_unset: Vec::new(),
5456 },
5457 crate::vcs::Actor::Cli,
5458 None,
5459 None,
5460 )
5461 .expect("repair-shaped update lands during migration without a flag");
5462 let entity = engine.store().get(&one).unwrap();
5463 assert!(entity.relationships.is_empty());
5464 }
5465
5466 #[test]
5471 fn boot_resumes_dual_pin_validation_against_target() {
5472 let (tmp, engine) = migration_engine();
5473 drop(engine);
5474 let schemas_dir = tmp.path().join("schemas");
5475 let mem_dir = tmp.path().join("mem");
5476 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5477 let mut mount = folder_mount("specs", mem_dir);
5478 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5479 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5480 let engine = Engine::from_mounts_with_schemas_dir(
5481 vec![(
5482 mount,
5483 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5484 )],
5485 Some(&schemas_dir),
5486 )
5487 .unwrap();
5488 let (name, version) = {
5490 let s = engine.schema_for("specs").unwrap();
5491 let (n, v) = s.id();
5492 (n.to_string(), v.to_string())
5493 };
5494 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5495 assert_eq!(
5498 engine.schema_pin("specs").unwrap().as_display(),
5499 "mig-a@0.1.0"
5500 );
5501 assert_eq!(
5502 engine.migration_target("specs").unwrap().as_display(),
5503 "mig-b@0.1.0"
5504 );
5505 }
5506
5507 #[test]
5517 fn every_lifecycle_setter_refuses_on_read_only_mount() {
5518 let tmp = TempDir::new().unwrap();
5519 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5520 let mut engine = Engine::from_mounts(vec![(
5521 archive_mount("ext", archive_path.clone()),
5522 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5523 )])
5524 .unwrap();
5525
5526 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5527 let attempts: Vec<(&str, EngineError)> = vec![
5528 (
5529 "set_mem_schema",
5530 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5531 ),
5532 (
5533 "set_mem_version",
5534 engine
5535 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5536 .unwrap_err(),
5537 ),
5538 (
5539 "set_mem_description",
5540 engine
5541 .set_mem_description("ext", Some("x".into()), None)
5542 .unwrap_err(),
5543 ),
5544 (
5545 "set_mem_title",
5546 engine
5547 .set_mem_title("ext", Some("x".into()), None)
5548 .unwrap_err(),
5549 ),
5550 (
5551 "set_mem_subject",
5552 engine.set_mem_subject("ext", None, None).unwrap_err(),
5553 ),
5554 (
5555 "set_mem_internal",
5556 engine.set_mem_internal("ext", true, None).unwrap_err(),
5557 ),
5558 (
5559 "set_mem_sync_state",
5560 engine
5561 .set_mem_sync_state("ext", "k", "t", None)
5562 .unwrap_err(),
5563 ),
5564 ];
5565 for (setter, err) in attempts {
5566 match err {
5567 EngineError::ReadOnlyMount(v) => {
5568 assert_eq!(v, "ext", "{setter} must name the refused mem")
5569 }
5570 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5571 }
5572 }
5573 }
5574
5575 #[test]
5581 fn no_config_setter_reverts_a_siblings_write() {
5582 type Setter = fn(&mut Engine) -> Result<(), EngineError>;
5583 let setters: Vec<(&str, Setter)> = vec![
5584 ("version", |e| {
5585 e.set_mem_version("specs", semver::Version::new(9, 0, 0), None)
5586 .map(|_| ())
5587 }),
5588 ("description", |e| {
5589 e.set_mem_description("specs", Some("mine".into()), None)
5590 .map(|_| ())
5591 }),
5592 ("title", |e| {
5593 e.set_mem_title("specs", Some("Mine".into()), None)
5594 .map(|_| ())
5595 }),
5596 ("internal", |e| {
5597 e.set_mem_internal("specs", true, None).map(|_| ())
5598 }),
5599 ("sync_state", |e| {
5600 e.set_mem_sync_state("specs", "src/facet", "tok", None)
5601 .map(|_| ())
5602 }),
5603 ("review_mark", |e| {
5607 e.set_review_mark("specs", None, None).map(|_| ())
5608 }),
5609 ];
5610
5611 for (name, set) in setters {
5612 let tmp = TempDir::new().unwrap();
5613 let mem_dir = tmp.path().to_path_buf();
5614 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5615 std::fs::create_dir_all(&meta).unwrap();
5616 let path = meta.join("config.json");
5617 std::fs::write(
5618 &path,
5619 br#"{"schema": "default@1.0.0", "version": "0.1.0"}"#.as_slice(),
5620 )
5621 .unwrap();
5622
5623 let writer = FilesystemMemWriter::new(mem_dir.clone());
5624 let mut engine = Engine::from_mounts(vec![(
5625 folder_mount("specs", mem_dir.clone()),
5626 Box::new(writer) as Box<dyn MemBackend>,
5627 )])
5628 .unwrap();
5629 engine
5632 .create_entity(
5633 crate::engine::test_helpers::empty_create_args("specs", "Seed"),
5634 crate::vcs::Actor::Cli,
5635 None,
5636 None,
5637 )
5638 .unwrap();
5639
5640 let mut sibling: memstead_schema::MemConfig =
5642 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5643 sibling
5644 .extra
5645 .insert("siblingMark".into(), serde_json::json!("kept"));
5646 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5647
5648 set(&mut engine).unwrap_or_else(|e| panic!("{name} setter failed: {e}"));
5649
5650 let after: memstead_schema::MemConfig =
5651 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5652 assert_eq!(
5653 after.extra.get("siblingMark"),
5654 Some(&serde_json::json!("kept")),
5655 "the {name} setter reverted a field it never set"
5656 );
5657 }
5658 }
5659
5660 #[test]
5663 fn intervention_is_reported_on_the_response_and_only_when_real() {
5664 let tmp = TempDir::new().unwrap();
5665 let mem_dir = tmp.path().to_path_buf();
5666 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5667 std::fs::create_dir_all(&meta).unwrap();
5668 let path = meta.join("config.json");
5669 std::fs::write(&path, br#"{"schema": "default@1.0.0"}"#.as_slice()).unwrap();
5670 let writer = FilesystemMemWriter::new(mem_dir.clone());
5671 let mut engine = Engine::from_mounts(vec![(
5672 folder_mount("specs", mem_dir.clone()),
5673 Box::new(writer) as Box<dyn MemBackend>,
5674 )])
5675 .unwrap();
5676
5677 let quiet = engine
5680 .set_mem_description("specs", Some("first".into()), None)
5681 .unwrap();
5682 assert!(
5683 !quiet
5684 .warnings
5685 .iter()
5686 .any(|w| w.code() == "CONFIG_WRITE_INTERVENED"),
5687 "single-writer workspace must stay silent: {:?}",
5688 quiet.warnings
5689 );
5690
5691 let mut sibling: memstead_schema::MemConfig =
5693 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5694 sibling.title = Some("theirs".into());
5695 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5696
5697 let loud = engine
5698 .set_mem_description("specs", Some("second".into()), None)
5699 .unwrap();
5700 let hint = loud
5701 .warnings
5702 .iter()
5703 .find(|w| w.code() == "CONFIG_WRITE_INTERVENED")
5704 .expect("intervention must be reported on the response");
5705 assert!(
5706 format!("{hint}").contains("title"),
5707 "the report names what they changed: {hint}"
5708 );
5709 let after: memstead_schema::MemConfig =
5711 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5712 assert_eq!(after.title.as_deref(), Some("theirs"));
5713 assert_eq!(after.description.as_deref(), Some("second"));
5714 }
5715}