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(crate) fn ref_schema_source_for(
1662 &self,
1663 config: &memstead_schema::MemConfig,
1664 ) -> Option<Vec<memstead_schema::SchemaSourceFile>> {
1665 let ops = self.git_branch_ops.as_ref()?;
1666 let root = self.workspace_root.as_deref()?;
1667 let pin = config.schema.as_ref()?;
1668 (ops.collect_ref_schema_source)(root, pin).ok().flatten()
1669 }
1670
1671 pub fn export_mem(
1672 &self,
1673 mem_name: &str,
1674 output_path: &std::path::Path,
1675 ) -> Result<crate::ops::MemExportResult, EngineError> {
1676 let mount = self
1677 .mounts
1678 .iter()
1679 .find(|m| m.mount.mem == mem_name)
1680 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1681 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1682 EngineError::InvalidInput(format!(
1683 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1684 ))
1685 })?;
1686 if config.version.is_none() {
1694 return Err(EngineError::MemConfigIncomplete {
1695 mem: mem_name.to_string(),
1696 missing_fields: vec!["version".to_string()],
1697 });
1698 }
1699 let fenced: Vec<String> = self
1705 .store
1706 .all_entities()
1707 .filter(|e| e.mem == mem_name && !e.stub)
1708 .filter(|e| {
1709 e.sections
1710 .values()
1711 .any(|v| crate::markdown::closing_fence_if_unterminated(v.trim()).is_some())
1712 })
1713 .map(|e| e.id.to_string())
1714 .collect();
1715 let workspace_root = self.workspace_root.as_deref();
1716 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1720 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1721 let exported = match &mount.mount.storage {
1722 MountStorage::Folder { path } => crate::ops::export::export_mem(
1723 path,
1724 config,
1725 output_path,
1726 workspace_root,
1727 workspace_schemas_dir,
1728 self.ref_schema_source_for(config),
1729 )
1730 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1731 MountStorage::GitBranch { gitdir, branch } => {
1732 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1733 EngineError::Backend(BackendError::Other(
1734 "git-branch export hook not installed (full flavour not loaded)"
1735 .to_string(),
1736 ))
1737 })?;
1738 let provenance_bytes = mount
1742 .backend
1743 .read_provenance(None)
1744 .ok()
1745 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
1746 .and_then(|prov| prov.to_archive_bytes().ok());
1747 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1750 (hook.export)(
1751 gitdir,
1752 branch,
1753 mem_name,
1754 config,
1755 output_path,
1756 workspace_root,
1757 workspace_schemas_dir,
1758 provenance_bytes.as_deref(),
1759 anchors_bytes.as_deref(),
1760 )
1761 .map_err(EngineError::Backend)
1762 }
1763 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1764 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1769 "export not yet supported for in-memory backend".to_string(),
1770 ))),
1771 };
1772 exported.map(|mut r| {
1773 r.unterminated_fence_entities = fenced;
1774 r
1775 })
1776 }
1777
1778 pub fn record_pipeline_edit_provenance(
1802 &self,
1803 mem: &str,
1804 kind: &str,
1805 edits: &[(String, Option<Vec<u8>>)],
1806 note: Option<&str>,
1807 verb: &str,
1808 ) -> Result<(), crate::backend::BackendError> {
1809 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1810 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1811 None => Ok(()),
1812 }
1813 }
1814
1815 pub fn set_mem_version(
1816 &mut self,
1817 mem_name: &str,
1818 new_version: semver::Version,
1819 note: Option<&str>,
1820 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1821 let mount_idx = self
1824 .mounts
1825 .iter()
1826 .position(|m| m.mount.mem == mem_name)
1827 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1828 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1829 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1830 }
1831
1832 let mut warnings = self.reload_if_stale(Some(mem_name));
1840 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1845 warnings.push(w);
1846 }
1847
1848 let old_version = self.mounts[mount_idx]
1852 .mem_config
1853 .as_ref()
1854 .and_then(|c| c.version.clone());
1855 let target = new_version.clone();
1856 let (_, intervened) = self.write_mem_config_merged(
1857 mount_idx,
1858 mem_name,
1859 note,
1860 &move |c: &mut memstead_schema::config::MemConfig| {
1861 c.version = Some(target.clone());
1862 },
1863 )?;
1864 if !intervened.is_empty() {
1865 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1866 mem: mem_name.to_string(),
1867 fields: intervened,
1868 });
1869 }
1870
1871 Ok(crate::ops::SetMemVersionOutcome {
1872 mem: mem_name.to_string(),
1873 old_version,
1874 new_version,
1875 warnings,
1876 })
1877 }
1878
1879 pub fn set_mem_description(
1886 &mut self,
1887 mem_name: &str,
1888 new_description: Option<String>,
1889 note: Option<&str>,
1890 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1891 let mount_idx = self
1892 .mounts
1893 .iter()
1894 .position(|m| m.mount.mem == mem_name)
1895 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1896 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1897 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1898 }
1899
1900 let mut warnings = self.reload_if_stale(Some(mem_name));
1901 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1902 warnings.push(w);
1903 }
1904
1905 let old_description = self.mounts[mount_idx]
1906 .mem_config
1907 .as_ref()
1908 .and_then(|c| c.description.clone());
1909 let target = new_description.clone();
1910 let (_, intervened) = self.write_mem_config_merged(
1911 mount_idx,
1912 mem_name,
1913 note,
1914 &move |c: &mut memstead_schema::config::MemConfig| {
1915 c.description = target.clone();
1916 },
1917 )?;
1918 if !intervened.is_empty() {
1919 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1920 mem: mem_name.to_string(),
1921 fields: intervened,
1922 });
1923 }
1924
1925 Ok(crate::ops::SetMemDescriptionOutcome {
1926 mem: mem_name.to_string(),
1927 old_description,
1928 new_description,
1929 warnings,
1930 })
1931 }
1932
1933 pub fn set_mem_title(
1938 &mut self,
1939 mem_name: &str,
1940 new_title: Option<String>,
1941 note: Option<&str>,
1942 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1943 let mount_idx = self
1944 .mounts
1945 .iter()
1946 .position(|m| m.mount.mem == mem_name)
1947 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1948 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1949 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1950 }
1951
1952 let mut warnings = self.reload_if_stale(Some(mem_name));
1953 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1954 warnings.push(w);
1955 }
1956
1957 let old_title = self.mounts[mount_idx]
1958 .mem_config
1959 .as_ref()
1960 .and_then(|c| c.title.clone());
1961 let target = new_title.clone();
1962 let (_, intervened) = self.write_mem_config_merged(
1963 mount_idx,
1964 mem_name,
1965 note,
1966 &move |c: &mut memstead_schema::config::MemConfig| {
1967 c.title = target.clone();
1968 },
1969 )?;
1970 if !intervened.is_empty() {
1971 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1972 mem: mem_name.to_string(),
1973 fields: intervened,
1974 });
1975 }
1976
1977 Ok(crate::ops::SetMemTitleOutcome {
1978 mem: mem_name.to_string(),
1979 old_title,
1980 new_title,
1981 warnings,
1982 })
1983 }
1984
1985 pub fn set_mem_subject(
1989 &mut self,
1990 mem_name: &str,
1991 new_subject: Option<memstead_schema::MemSubject>,
1992 note: Option<&str>,
1993 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1994 let mount_idx = self
1995 .mounts
1996 .iter()
1997 .position(|m| m.mount.mem == mem_name)
1998 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1999 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2000 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2001 }
2002
2003 let mut warnings = self.reload_if_stale(Some(mem_name));
2004 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
2005 warnings.push(w);
2006 }
2007
2008 let old_subject = self.mounts[mount_idx]
2009 .mem_config
2010 .as_ref()
2011 .and_then(|c| c.subject.clone());
2012 let target = new_subject.clone();
2013 let (_, intervened) = self.write_mem_config_merged(
2014 mount_idx,
2015 mem_name,
2016 note,
2017 &move |c: &mut memstead_schema::config::MemConfig| {
2018 c.subject = target.clone();
2019 },
2020 )?;
2021 if !intervened.is_empty() {
2022 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2023 mem: mem_name.to_string(),
2024 fields: intervened,
2025 });
2026 }
2027
2028 Ok(crate::ops::SetMemSubjectOutcome {
2029 mem: mem_name.to_string(),
2030 old_subject,
2031 new_subject,
2032 warnings,
2033 })
2034 }
2035
2036 pub fn set_mem_internal(
2048 &mut self,
2049 mem_name: &str,
2050 internal: bool,
2051 note: Option<&str>,
2052 ) -> Result<crate::ops::SetMemInternalOutcome, EngineError> {
2053 let mount_idx = self
2054 .mounts
2055 .iter()
2056 .position(|m| m.mount.mem == mem_name)
2057 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2058 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2059 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2060 }
2061
2062 let _ = self.reload_if_stale(Some(mem_name));
2063
2064 let (_, intervened) = self.write_mem_config_merged(
2065 mount_idx,
2066 mem_name,
2067 note,
2068 &move |c: &mut memstead_schema::config::MemConfig| {
2069 if internal {
2070 c.extra
2071 .insert("internal".to_string(), serde_json::Value::Bool(true));
2072 } else {
2073 c.extra.remove("internal");
2074 }
2075 },
2076 )?;
2077 let mut warnings = Vec::new();
2082 if !intervened.is_empty() {
2083 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2084 mem: mem_name.to_string(),
2085 fields: intervened,
2086 });
2087 }
2088
2089 Ok(crate::ops::SetMemInternalOutcome {
2090 mem: mem_name.to_string(),
2091 internal,
2092 warnings,
2093 })
2094 }
2095
2096 pub fn set_mem_sync_state(
2121 &mut self,
2122 mem_name: &str,
2123 key: &str,
2124 token: &str,
2125 note: Option<&str>,
2126 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
2127 let mount_idx = self
2130 .mounts
2131 .iter()
2132 .position(|m| m.mount.mem == mem_name)
2133 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2134 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2135 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2136 }
2137
2138 let mut warnings = self.reload_if_stale(Some(mem_name));
2142 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
2143 warnings.push(w);
2144 }
2145
2146 let seen: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
2153 let key_owned = key.to_string();
2154 let token_owned = token.to_string();
2155 let (_, intervened) = self.write_mem_config_merged(
2156 mount_idx,
2157 mem_name,
2158 note,
2159 &|c: &mut memstead_schema::config::MemConfig| {
2160 *seen.borrow_mut() = if token_owned.is_empty() {
2161 c.sync_state.remove(&key_owned)
2162 } else {
2163 c.sync_state.insert(key_owned.clone(), token_owned.clone())
2164 };
2165 },
2166 )?;
2167 let previous = seen.into_inner();
2168 let removed = token.is_empty() && previous.is_some();
2171 if !intervened.is_empty() {
2172 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2173 mem: mem_name.to_string(),
2174 fields: intervened,
2175 });
2176 }
2177
2178 Ok(crate::ops::SetMemSyncStateOutcome {
2179 mem: mem_name.to_string(),
2180 key: key.to_string(),
2181 previous,
2182 removed,
2183 warnings,
2184 })
2185 }
2186
2187 pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
2234 let pending: Vec<String> = self
2235 .mounts
2236 .iter()
2237 .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
2238 .map(|m| m.mount.mem.clone())
2239 .collect();
2240 for name in pending {
2241 match self.reload_one_mem(&name) {
2242 Ok(_) => {
2243 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
2244 state.deferred = false;
2245 }
2246 let store = &self.store;
2252 self.load_warnings.retain(|w| match w {
2253 crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
2254 store.get(resolved_id).is_none_or(|e| e.stub)
2255 }
2256 _ => true,
2257 });
2258 }
2259 Err(e) => {
2260 let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
2265 continue;
2266 };
2267 let removed = self.mounts.remove(idx);
2268 self.schemas_remove(&name);
2269 self.quarantined.push(crate::engine::QuarantinedMem {
2270 mount: removed.mount,
2271 reason_code: e.code().to_string(),
2272 reason_message: e.to_string(),
2273 });
2274 self.mem_router = std::sync::Arc::new(
2275 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2276 );
2277 self.invalidate_communities();
2278 self.invalidate_search_indexes();
2284 }
2285 }
2286 }
2287 }
2288
2289 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2290 if self.quarantine_reason(mem).is_some() {
2313 return self.reattach_quarantined_mem(mem);
2314 }
2315 let mut sink: Vec<WarningHint> = Vec::new();
2316 let result = self.reload_one_mem_inner(mem, &mut sink)?;
2317 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2318 self.load_warnings
2319 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2320 Ok(result)
2321 }
2322
2323 fn set_schema_on_quarantined(
2333 &mut self,
2334 mem: &str,
2335 target: &memstead_schema::SchemaRef,
2336 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2337 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2338 if self.resolve_schema_by_ref(target).is_none() {
2340 let consulted: Vec<_> = self
2341 .workspace_schemas
2342 .iter()
2343 .chain(self.builtin_schemas.iter())
2344 .cloned()
2345 .collect();
2346 return Err(EngineError::SchemaNotFound {
2347 mem: mem.to_string(),
2348 pin: target.as_display(),
2349 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2350 &target.name,
2351 &target.version,
2352 &consulted,
2353 ),
2354 install_hint: None,
2355 }
2356 .with_schema_install_probe(self.workspace_root()));
2357 }
2358 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2359 return Err(self.unknown_mem_error(mem));
2360 };
2361 self.quarantined[q_idx].mount.schema = Some(target.clone());
2365 self.quarantined[q_idx].mount.migration_target = None;
2366 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2367 let _ = bump_backend_schema_pin(backend.as_ref(), target);
2368 }
2369 self.persist_state()?;
2370 let _ = self.reattach_quarantined_mem(mem);
2375 Ok(SetSchemaOutcome {
2376 mem: mem.to_string(),
2377 schema_pin: target.as_display(),
2378 migration_target: None,
2379 outcome: SetSchemaResult::Switched,
2380 findings: Vec::new(),
2381 })
2382 }
2383
2384 fn reattach_quarantined_mem(
2392 &mut self,
2393 mem: &str,
2394 ) -> Result<crate::ops::ReloadResult, EngineError> {
2395 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2396 return Err(self.unknown_mem_error(mem));
2397 };
2398 let mount = self.quarantined[q_idx].mount.clone();
2399
2400 let requarantine = |this: &mut Self, e: &EngineError| {
2401 this.quarantined[q_idx].reason_code = e.code().to_string();
2402 this.quarantined[q_idx].reason_message = e.to_string();
2403 };
2404
2405 let backend = match (self.backend_factory)(&mount) {
2406 Ok(b) => b,
2407 Err(e) => {
2408 let err = EngineError::Mem(e.to_string());
2409 requarantine(self, &err);
2410 return Err(self.unknown_mem_error(mem));
2411 }
2412 };
2413
2414 let last_known_head = backend.current_head().ok().flatten();
2416 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2417 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2418 memstead_schema::config::parse_mem_config(&value).ok()
2419 });
2420 let archive_provenance = backend
2421 .read_archive_provenance()
2422 .ok()
2423 .flatten()
2424 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2425 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2426 let effective_pin = mount
2427 .migration_target
2428 .clone()
2429 .or(config_pin)
2430 .or(mount.schema.clone());
2431 let Some(effective_pin) = effective_pin else {
2432 let err = EngineError::MemConfigIncomplete {
2433 mem: mem.to_string(),
2434 missing_fields: vec!["schema".to_string()],
2435 };
2436 requarantine(self, &err);
2437 return Err(self.unknown_mem_error(mem));
2438 };
2439 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2440 .workspace_schemas
2441 .iter()
2442 .chain(self.builtin_schemas.iter())
2443 .cloned()
2444 .collect();
2445 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2446 Ok(s) => s,
2447 Err(sources) => {
2448 let err = EngineError::SchemaNotFound {
2449 mem: mem.to_string(),
2450 pin: effective_pin.as_display(),
2451 sources,
2452 install_hint: None,
2453 }
2454 .with_schema_install_probe(self.workspace_root());
2455 requarantine(self, &err);
2456 return Err(self.unknown_mem_error(mem));
2457 }
2458 };
2459
2460 self.quarantined.remove(q_idx);
2464 self.schemas_insert(mem.to_string(), schema);
2465 self.mounts.push(crate::engine::MountedBackend {
2466 mount,
2467 backend,
2468 last_known_head,
2469 mem_config,
2470 archive_provenance,
2471 deferred: false,
2474 });
2475 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2476 &self.mounts,
2477 ));
2478 let mut sink: Vec<WarningHint> = Vec::new();
2479 match self.reload_one_mem_inner(mem, &mut sink) {
2480 Ok(result) => {
2481 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2482 self.load_warnings
2483 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2484 self.invalidate_communities();
2485 Ok(result)
2486 }
2487 Err(e) => {
2488 let mount_idx = self.mounts.len() - 1;
2489 let mounted = self.mounts.remove(mount_idx);
2490 self.schemas_remove(mem);
2491 self.mem_router = std::sync::Arc::new(
2492 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2493 );
2494 self.quarantined.push(crate::engine::QuarantinedMem {
2495 mount: mounted.mount,
2496 reason_code: e.code().to_string(),
2497 reason_message: e.to_string(),
2498 });
2499 self.invalidate_communities();
2503 self.invalidate_search_indexes();
2504 Err(e)
2505 }
2506 }
2507 }
2508
2509 fn reload_one_mem_inner(
2515 &mut self,
2516 mem: &str,
2517 warnings_sink: &mut Vec<WarningHint>,
2518 ) -> Result<crate::ops::ReloadResult, EngineError> {
2519 let mount_idx = self
2522 .mounts
2523 .iter()
2524 .position(|m| m.mount.mem == mem)
2525 .ok_or_else(|| self.unknown_mem_error(mem))?;
2526 let schema = self
2527 .schemas
2528 .get(mem)
2529 .cloned()
2530 .ok_or_else(|| self.unknown_mem_error(mem))?;
2531
2532 let pre: HashMap<EntityId, String> = self
2534 .store
2535 .all_entities()
2536 .filter(|e| !e.stub && e.mem == mem)
2537 .map(|e| (e.id.clone(), e.content_hash.clone()))
2538 .collect();
2539 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2540
2541 let backend = self.mounts[mount_idx].backend.as_ref();
2544 let (entries, read_errors) = collect_source_entries(backend)?;
2545 let unbacked = super::boot::unbacked_mount_warning(
2550 &self.mounts[mount_idx].mount,
2551 backend,
2552 Some(entries.len()),
2553 );
2554 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2555
2556 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2560 let known_suffixes: Vec<String> = mem_names
2561 .iter()
2562 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2563 .collect();
2564
2565 self.store.remove_entities_by_mem(mem);
2567 if let Some(w) = unbacked {
2568 warnings_sink.push(w);
2569 }
2570 let fallback = engine_fallback_type();
2571 push_entities_into_store(
2572 &mut self.store,
2573 load_result.entities,
2574 fallback.as_ref(),
2575 Some(crate::entity::store_builder::LoadCollector {
2576 warnings: warnings_sink,
2577 known_suffixes: &known_suffixes,
2578 mem_names: &mem_names,
2579 }),
2580 );
2581 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2588 .mounts
2589 .iter()
2590 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2591 .collect();
2592 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2600 crate::entity::store_builder::validate_loaded_relations(
2601 &mut self.store,
2602 &self.schemas,
2603 &mount_caps,
2604 warnings_sink,
2605 );
2606 crate::entity::store_builder::remap_alias_target_edge_sources(
2607 &mut self.store,
2608 &self.schemas,
2609 );
2610 if let crate::workspace::MountStorage::Folder { path } =
2620 &self.mounts[mount_idx].mount.storage
2621 {
2622 let root = path.clone();
2623 self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2624 self.load_errors
2628 .extend(load_result.errors.into_iter().map(|(p, m)| {
2629 let abs = if p.is_relative() { root.join(&p) } else { p };
2630 (abs, m)
2631 }));
2632 } else {
2633 self.load_errors.extend(load_result.errors);
2634 }
2635
2636 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2644 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2645 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2646 {
2647 self.mounts[mount_idx].mem_config = Some(cfg);
2648 }
2649
2650 let mut added: Vec<EntityId> = Vec::new();
2652 let mut changed: Vec<EntityId> = Vec::new();
2653 for entity in self.store.all_entities() {
2654 if entity.stub || entity.mem != mem {
2655 continue;
2656 }
2657 match pre.get(&entity.id) {
2658 None => added.push(entity.id.clone()),
2659 Some(prev_hash) if prev_hash != &entity.content_hash => {
2660 changed.push(entity.id.clone());
2661 }
2662 Some(_) => {}
2663 }
2664 }
2665 let post_ids: std::collections::HashSet<EntityId> = self
2666 .store
2667 .all_entities()
2668 .filter(|e| !e.stub && e.mem == mem)
2669 .map(|e| e.id.clone())
2670 .collect();
2671 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2672 added.sort_by(|a, b| a.0.cmp(&b.0));
2673 changed.sort_by(|a, b| a.0.cmp(&b.0));
2674 removed.sort_by(|a, b| a.0.cmp(&b.0));
2675
2676 self.invalidate_communities();
2677 self.invalidate_search_indexes();
2678
2679 Ok(crate::ops::ReloadResult {
2680 added,
2681 changed,
2682 removed,
2683 })
2684 }
2685
2686 pub fn reload_one_mem_report(
2714 &mut self,
2715 mem: &str,
2716 ) -> Result<crate::ops::ReloadReport, EngineError> {
2717 let tracks_head = self
2728 .mounts
2729 .iter()
2730 .find(|m| m.mount.mem == mem)
2731 .and_then(|m| m.backend.current_head().ok().flatten())
2732 .is_some();
2733 let head_before = if tracks_head {
2734 self.mounts
2735 .iter()
2736 .find(|m| m.mount.mem == mem)
2737 .and_then(|m| m.last_known_head.clone())
2738 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2739 } else {
2740 crate::ops::EMPTY_TREE_SHA.to_string()
2741 };
2742
2743 let result = self.reload_one_mem(mem)?;
2744
2745 let head_after_raw = self
2752 .mounts
2753 .iter()
2754 .find(|m| m.mount.mem == mem)
2755 .and_then(|m| m.backend.current_head().ok().flatten());
2756 if let Some(new_head) = head_after_raw.clone()
2757 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2758 {
2759 m.last_known_head = Some(new_head);
2760 }
2761 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2762
2763 let entities_loaded = self
2764 .store
2765 .all_entities()
2766 .filter(|e| !e.stub && e.mem == mem)
2767 .count();
2768
2769 let mut changed_entity_ids: Vec<EntityId> = result
2774 .added
2775 .into_iter()
2776 .chain(result.changed)
2777 .chain(result.removed)
2778 .collect();
2779 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2780
2781 Ok(crate::ops::ReloadReport {
2782 mem: mem.to_string(),
2783 head_before,
2784 head_after,
2785 entities_loaded,
2786 changed_entity_ids,
2787 })
2788 }
2789
2790 pub fn reload_each_writable_mem_reports(
2830 &mut self,
2831 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2832 self.refresh_workspace_settings_if_possible();
2833 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2834 let mut out = Vec::with_capacity(names.len());
2835 for name in names {
2836 let report = self.reload_one_mem_report(&name)?;
2837 out.push(report);
2838 }
2839 Ok(out)
2840 }
2841
2842 fn refresh_workspace_settings_if_possible(&mut self) {
2854 let Some(root) = self.workspace_root.clone() else {
2855 return;
2856 };
2857 let store = crate::workspace_store::FileWorkspaceStore::new();
2858 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2859 Ok(w) => w,
2860 Err(_) => return,
2861 };
2862 self.set_settings(workspace.settings);
2863 }
2864
2865 pub fn reload_each_writable_mem(
2881 &mut self,
2882 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2883 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2884 let mut sink = std::mem::take(&mut self.load_warnings);
2890 sink.clear();
2891 let mut out = Vec::with_capacity(names.len());
2892 let mut loop_err = None;
2893 for name in names {
2894 match self.reload_one_mem_inner(&name, &mut sink) {
2895 Ok(result) => out.push((name, result)),
2896 Err(e) => {
2897 loop_err = Some(e);
2898 break;
2899 }
2900 }
2901 }
2902 self.load_warnings = sink;
2903 if let Some(e) = loop_err {
2904 return Err(e);
2905 }
2906 Ok(out)
2907 }
2908}
2909
2910fn changed_config_fields(
2929 cached: &memstead_schema::config::MemConfig,
2930 stored_bytes: &[u8],
2931) -> Vec<String> {
2932 let (Ok(a), Ok(b)) = (
2933 serde_json::to_value(cached),
2934 serde_json::from_slice::<serde_json::Value>(stored_bytes),
2935 ) else {
2936 return Vec::new();
2937 };
2938 let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
2939 return Vec::new();
2940 };
2941 let mut keys: std::collections::BTreeSet<&String> = a.keys().collect();
2942 keys.extend(b.keys());
2943 keys.into_iter()
2944 .filter(|k| a.get(*k) != b.get(*k))
2945 .map(|k| k.to_string())
2946 .collect()
2947}
2948
2949pub fn bump_backend_schema_pin(
2950 backend: &dyn crate::backend::MemBackend,
2951 target: &memstead_schema::SchemaRef,
2952) -> Result<Option<serde_json::Value>, EngineError> {
2953 let Some(bytes) = backend
2954 .read_mem_config()
2955 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2956 else {
2957 return Ok(None);
2958 };
2959 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2960 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2961 value["schema"] = serde_json::Value::String(target.as_display());
2962 let new_bytes = serde_json::to_vec_pretty(&value)
2963 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2964 backend
2965 .write_mem_config(&new_bytes)
2966 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2967 Ok(Some(value))
2968}
2969
2970fn merge_mount_rosters(
2984 baseline: &[crate::workspace::Mount],
2985 ours: &[crate::workspace::Mount],
2986 on_disk: Vec<crate::workspace::Mount>,
2987) -> Vec<crate::workspace::Mount> {
2988 use std::collections::{HashMap, HashSet};
2989
2990 let ours_names: HashSet<&str> = ours.iter().map(|m| m.mem.as_str()).collect();
2991 let removed_by_us: HashSet<&str> = baseline
2992 .iter()
2993 .map(|m| m.mem.as_str())
2994 .filter(|n| !ours_names.contains(n))
2995 .collect();
2996 let baseline_by_name: HashMap<&str, &crate::workspace::Mount> =
2997 baseline.iter().map(|m| (m.mem.as_str(), m)).collect();
2998
2999 let mut merged: Vec<crate::workspace::Mount> = on_disk
3000 .into_iter()
3001 .filter(|m| !removed_by_us.contains(m.mem.as_str()))
3002 .collect();
3003
3004 for mount in ours {
3005 let untouched_by_us = baseline_by_name
3006 .get(mount.mem.as_str())
3007 .is_some_and(|b| *b == mount);
3008 match merged.iter_mut().find(|d| d.mem == mount.mem) {
3009 Some(slot) => {
3010 if !untouched_by_us {
3011 *slot = mount.clone();
3012 }
3013 }
3014 None => merged.push(mount.clone()),
3015 }
3016 }
3017 merged
3018}
3019
3020#[cfg(test)]
3021mod tests {
3022
3023 use tempfile::TempDir;
3024
3025 use crate::backend::{BackendError, MemBackend};
3026 use crate::engine::test_helpers::*;
3027 use crate::engine::{Engine, EngineError};
3028 use crate::mem::MemOrigin;
3029 use crate::ops::WarningHint;
3030 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
3031
3032 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
3033 let manifest = format!(
3034 r#"name: {manifest_name}
3035version: 1.0.0
3036description: Install-gate test schema
3037when_to_use: Tests
3038types:
3039 - sample
3040relationships:
3041 mode: strict
3042 definitions:
3043 - name: PART_OF
3044 description: hier
3045 default_weight: 3.0
3046 - name: _default
3047 description: fallback
3048 default_weight: 1.0
3049community:
3050 resolution: 1.0
3051 seed: 42
3052"#
3053 );
3054 let type_yaml = format!(
3055 r#"name: sample
3056description: t
3057when_to_use: tests
3058sections:
3059 - key: body
3060 heading: {heading}
3061 required: true
3062 search_weight: 10.0
3063 catch_all: true
3064 write_rules: []
3065metadata_fields: []
3066title_weight: 100.0
3067text_fields:
3068 - body
3069hierarchy_relationship: PART_OF
3070no_self_loop_relationships: []
3071updatable_fields:
3072 - title
3073 - body
3074health_required_fields:
3075 - body
3076staleness_threshold_days: 90
3077write_rules: []
3078"#
3079 );
3080 vec![
3081 ("schema.yaml".to_string(), manifest.into_bytes()),
3082 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3083 ]
3084 }
3085
3086 #[test]
3090 fn install_gate_refuses_non_roundtrip_heading() {
3091 let ok =
3092 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
3093 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
3094
3095 let err = Engine::validate_schema_package(
3096 "gate",
3097 "1.0.0",
3098 &schema_package_files("Body Text", "gate"),
3099 )
3100 .expect_err("non-deriving heading must refuse install");
3101 match &err {
3102 EngineError::SchemaPackageInvalid { name, message, .. } => {
3103 assert_eq!(name, "gate");
3104 assert!(
3105 message.contains("'body'") && message.contains("'Body Text'"),
3106 "message names the offending tuple: {message}"
3107 );
3108 }
3109 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3110 }
3111 }
3112
3113 fn exemplar_package_files(
3120 section_key: &str,
3121 status_value: &str,
3122 rel_type: &str,
3123 ) -> Vec<(String, Vec<u8>)> {
3124 let manifest = r#"name: gate
3125version: 1.0.0
3126description: exemplar gate fixture
3127when_to_use: tests
3128types:
3129 - sample
3130 - other
3131relationships:
3132 mode: strict
3133 definitions:
3134 - name: PART_OF
3135 description: hier
3136 default_weight: 3.0
3137 - name: REFINES
3138 description: pinned
3139 default_weight: 1.0
3140 source_types: [other]
3141 - name: _default
3142 description: fallback
3143 default_weight: 1.0
3144community:
3145 resolution: 1.0
3146 seed: 42
3147"#
3148 .to_string();
3149 let type_yaml = format!(
3150 r#"name: sample
3151description: t
3152when_to_use: tests
3153sections:
3154 - key: body
3155 heading: Body
3156 required: true
3157 search_weight: 10.0
3158 catch_all: true
3159 write_rules: []
3160metadata_fields:
3161 - key: status
3162 description: workflow state
3163 field_type: string
3164 enum_values: [draft, final]
3165title_weight: 100.0
3166text_fields:
3167 - body
3168hierarchy_relationship: PART_OF
3169no_self_loop_relationships: []
3170updatable_fields:
3171 - title
3172 - body
3173health_required_fields:
3174 - body
3175staleness_threshold_days: 90
3176write_rules: []
3177exemplar:
3178 title: A Conforming Sample
3179 metadata:
3180 status: "{status_value}"
3181 sections:
3182 {section_key}: "One canonical body paragraph."
3183 relations:
3184 - to: parent-placeholder
3185 type: {rel_type}
3186"#
3187 );
3188 let other_yaml = r#"name: other
3189description: shape-pin partner
3190when_to_use: tests
3191sections:
3192 - key: body
3193 heading: Body
3194 required: true
3195 search_weight: 10.0
3196 catch_all: true
3197 write_rules: []
3198metadata_fields: []
3199title_weight: 100.0
3200text_fields:
3201 - body
3202hierarchy_relationship: PART_OF
3203no_self_loop_relationships: []
3204updatable_fields:
3205 - title
3206 - body
3207health_required_fields:
3208 - body
3209staleness_threshold_days: 90
3210write_rules: []
3211"#
3212 .to_string();
3213 vec![
3214 ("schema.yaml".to_string(), manifest.into_bytes()),
3215 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3216 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
3217 ]
3218 }
3219
3220 #[test]
3227 fn install_gate_validates_exemplars_through_the_real_create_path() {
3228 let ok = Engine::validate_schema_package(
3230 "gate",
3231 "1.0.0",
3232 &exemplar_package_files("body", "draft", "PART_OF"),
3233 );
3234 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
3235
3236 let err = Engine::validate_schema_package(
3238 "gate",
3239 "1.0.0",
3240 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
3241 )
3242 .expect_err("wrong section key must refuse");
3243 match &err {
3244 EngineError::SchemaPackageInvalid { message, .. } => {
3245 assert!(
3246 message.contains("'sample'") && message.contains("exemplar"),
3247 "names type and calls out the exemplar: {message}"
3248 );
3249 assert!(
3250 message.contains("UNKNOWN_SECTION")
3251 || message.contains("MISSING_REQUIRED_SECTION"),
3252 "carries the typed defect code: {message}"
3253 );
3254 }
3255 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3256 }
3257
3258 let err = Engine::validate_schema_package(
3260 "gate",
3261 "1.0.0",
3262 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
3263 )
3264 .expect_err("illegal enum value must refuse");
3265 assert!(
3266 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3267 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
3268 "got {err:?}"
3269 );
3270
3271 let err = Engine::validate_schema_package(
3274 "gate",
3275 "1.0.0",
3276 &exemplar_package_files("body", "draft", "REFINES"),
3277 )
3278 .expect_err("relationship shape violation must refuse");
3279 assert!(
3280 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3281 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
3282 "got {err:?}"
3283 );
3284 }
3285
3286 #[test]
3291 fn worked_example_package_exemplars_validate() {
3292 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3293 .join("../memstead-schema/examples/minimal");
3294 let schema = std::sync::Arc::new(
3295 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
3296 );
3297 assert!(
3298 schema.types.values().all(|td| td.exemplar.is_some()),
3299 "every worked-example type models the exemplar practice"
3300 );
3301 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
3302 }
3303
3304 #[test]
3311 fn builtin_exemplars_validate_through_the_install_gate() {
3312 let schemas = memstead_schema::builtins::load_builtin_schemas()
3313 .expect("built-in schemas always load");
3314 for schema in &schemas {
3316 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
3317 let (name, version) = schema.id();
3318 panic!("built-in {name}@{version}: {defect}");
3319 }
3320 }
3321 let mut newest: std::collections::HashMap<
3323 String,
3324 &std::sync::Arc<memstead_schema::Schema>,
3325 > = std::collections::HashMap::new();
3326 for schema in &schemas {
3327 let name = schema.manifest.name.clone();
3328 match newest.get(&name) {
3329 Some(cur) if cur.version >= schema.version => {}
3330 _ => {
3331 newest.insert(name, schema);
3332 }
3333 }
3334 }
3335 for (name, schema) in &newest {
3336 for (type_name, td) in &schema.types {
3337 assert!(
3338 td.exemplar.is_some(),
3339 "built-in {name}@{} type '{type_name}' has no exemplar — the \
3340 reference schemas model the practice completely",
3341 schema.version
3342 );
3343 }
3344 }
3345 }
3346
3347 #[test]
3352 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
3353 let mut files = exemplar_package_files("body", "draft", "PART_OF");
3354 let patched = String::from_utf8(files[1].1.clone())
3355 .unwrap()
3356 .replace("to: parent-placeholder", "to: other--real-entity");
3357 files[1].1 = patched.into_bytes();
3358 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3359 .expect_err("mem-prefixed exemplar target must refuse");
3360 assert!(
3361 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3362 if message.contains("bare") && message.contains("'sample'")),
3363 "got {err:?}"
3364 );
3365 }
3366
3367 #[test]
3371 fn install_gate_refuses_manifest_identity_mismatch() {
3372 let err = Engine::validate_schema_package(
3373 "gate",
3374 "1.0.0",
3375 &schema_package_files("Body", "other"),
3376 )
3377 .expect_err("identity mismatch must refuse install");
3378 assert!(
3379 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3380 if message.contains("other@1.0.0")),
3381 "got {err:?}"
3382 );
3383 }
3384
3385 #[test]
3386 fn reload_each_writable_mem_repopulates_load_warnings() {
3387 let tmp = TempDir::new().unwrap();
3391 let mem_dir = tmp.path().to_path_buf();
3392 let writer = FilesystemMemWriter::new(mem_dir.clone());
3393 let mut mount = folder_mount("specs", mem_dir.clone());
3396 mount.schema = Some("default@1.3.0".parse().unwrap());
3397 let mut engine =
3398 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3399 assert!(
3402 engine
3403 .load_warnings()
3404 .iter()
3405 .all(|w| w.code() == "MOUNT_UNBACKED"),
3406 "clean boot has no warnings beyond the empty-mount one: {:?}",
3407 engine.load_warnings()
3408 );
3409
3410 let body =
3412 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3413 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3414
3415 engine.reload_each_writable_mem().unwrap();
3416 let warnings = engine.load_warnings();
3417 assert!(
3418 warnings
3419 .iter()
3420 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3421 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3422 );
3423 }
3424
3425 #[test]
3432 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3433 let tmp = TempDir::new().unwrap();
3434 let mem_dir = tmp.path().to_path_buf();
3435 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3437 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3438 let writer = FilesystemMemWriter::new(mem_dir.clone());
3439 let mut engine = Engine::from_mounts(vec![(
3440 folder_mount("specs", mem_dir.clone()),
3441 Box::new(writer) as Box<dyn MemBackend>,
3442 )])
3443 .unwrap();
3444 assert!(
3446 !engine
3447 .load_warnings()
3448 .iter()
3449 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3450 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3451 engine.load_warnings()
3452 );
3453
3454 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";
3456 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3457
3458 engine.reload_each_writable_mem().unwrap();
3459
3460 let invalid: Vec<_> = engine
3461 .load_warnings()
3462 .iter()
3463 .filter_map(|w| match w {
3464 WarningHint::ParsedRelationInvalid {
3465 rel_type,
3466 reason,
3467 origin,
3468 ..
3469 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3470 _ => None,
3471 })
3472 .collect();
3473 assert_eq!(
3474 invalid.len(),
3475 1,
3476 "reload must surface the parse-time drift, got: {invalid:?}",
3477 );
3478 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3479 assert_eq!(invalid[0].1, "unknown_rel_type");
3480 assert_eq!(invalid[0].2, "writable");
3481 }
3482
3483 #[test]
3484 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3485 let tmp = TempDir::new().unwrap();
3492 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3493 let a_dir = tmp.path().join("a");
3494 std::fs::create_dir_all(&a_dir).unwrap();
3495 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3496 let b_dir = tmp.path().join("b");
3497 std::fs::create_dir_all(&b_dir).unwrap();
3498 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3499 let mut engine = Engine::from_mounts(vec![
3500 (
3501 folder_mount("alpha", a_dir.clone()),
3502 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3503 ),
3504 (
3505 folder_mount("beta", b_dir.clone()),
3506 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3507 ),
3508 ])
3509 .unwrap();
3510 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3511 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3512 assert!(
3513 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3514 "boot must populate one warning per mem: {pre:?}"
3515 );
3516
3517 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3519 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3520 engine.reload_one_mem("alpha").unwrap();
3521
3522 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3523 assert!(
3524 !post.contains(&"alpha".to_string()),
3525 "reload must drop the healed mem's stale warning: {post:?}"
3526 );
3527 assert!(
3528 post.contains(&"beta".to_string()),
3529 "reload of alpha must not clear beta's slice: {post:?}"
3530 );
3531 }
3532
3533 #[test]
3534 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3535 let tmp = TempDir::new().unwrap();
3540 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3541 let a_dir = tmp.path().join("a");
3542 std::fs::create_dir_all(&a_dir).unwrap();
3543 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3544 let b_dir = tmp.path().join("b");
3545 std::fs::create_dir_all(&b_dir).unwrap();
3546 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3547 let mut engine = Engine::from_mounts(vec![
3548 (
3549 folder_mount("alpha", a_dir.clone()),
3550 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3551 ),
3552 (
3553 folder_mount("beta", b_dir.clone()),
3554 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3555 ),
3556 ])
3557 .unwrap();
3558 assert!(
3559 engine
3560 .load_warnings()
3561 .iter()
3562 .any(|w| w.source_mem() == Some("alpha")),
3563 "boot must carry alpha-sourced warnings"
3564 );
3565
3566 engine.unregister_writable_mem("alpha").unwrap();
3567
3568 let post = engine.load_warnings();
3569 assert!(
3570 !post.iter().any(|w| w.source_mem() == Some("alpha")),
3571 "delete must purge the removed mem's warnings: {post:?}"
3572 );
3573 assert!(
3574 post.iter().any(|w| w.source_mem() == Some("beta")),
3575 "delete of alpha must keep beta's warnings: {post:?}"
3576 );
3577 }
3578
3579 #[test]
3580 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3581 let tmp = TempDir::new().unwrap();
3587 let a_dir = tmp.path().join("a");
3588 std::fs::create_dir_all(&a_dir).unwrap();
3589 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";
3590 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3591 let b_dir = tmp.path().join("b");
3592 std::fs::create_dir_all(&b_dir).unwrap();
3593 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3594 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3595 let mut engine = Engine::from_mounts(vec![
3596 (
3597 folder_mount("alpha", a_dir.clone()),
3598 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3599 ),
3600 (
3601 folder_mount("beta", b_dir.clone()),
3602 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3603 ),
3604 ])
3605 .unwrap();
3606 let alpha_sourced = |engine: &Engine| {
3607 engine
3608 .load_warnings()
3609 .iter()
3610 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3611 };
3612 assert!(
3613 alpha_sourced(&engine),
3614 "boot must flag alpha's invalid row: {:?}",
3615 engine.load_warnings()
3616 );
3617
3618 engine.unregister_writable_mem("beta").unwrap();
3619
3620 assert!(
3621 alpha_sourced(&engine),
3622 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3623 engine.load_warnings()
3624 );
3625 }
3626
3627 #[test]
3635 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3636 let tmp = TempDir::new().unwrap();
3637 let mem_dir = tmp.path().to_path_buf();
3638 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3639 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3640 let writer = FilesystemMemWriter::new(mem_dir.clone());
3641 let mut engine = Engine::from_mounts(vec![(
3642 folder_mount("specs", mem_dir.clone()),
3643 Box::new(writer) as Box<dyn MemBackend>,
3644 )])
3645 .unwrap();
3646 assert!(
3647 !engine.load_warnings().is_empty(),
3648 "boot must populate load_warnings"
3649 );
3650
3651 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3654 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3655 engine.reload_each_writable_mem_reports().unwrap();
3656 assert!(
3657 engine.load_warnings().is_empty(),
3658 "reports sweep must drop healed warnings: {:?}",
3659 engine.load_warnings()
3660 );
3661
3662 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3664 engine.reload_each_writable_mem_reports().unwrap();
3665 assert!(
3666 engine
3667 .load_warnings()
3668 .iter()
3669 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3670 "reports sweep must surface fresh drift: {:?}",
3671 engine.load_warnings()
3672 );
3673 }
3674
3675 #[test]
3683 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3684 let tmp = TempDir::new().unwrap();
3685 let a_dir = tmp.path().join("a");
3686 let b_dir = tmp.path().join("b");
3687 std::fs::create_dir_all(&a_dir).unwrap();
3688 std::fs::create_dir_all(&b_dir).unwrap();
3689 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3690 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3691 let mut engine = Engine::from_mounts(vec![
3692 (
3693 folder_mount("specs", a_dir),
3694 Box::new(a_writer) as Box<dyn MemBackend>,
3695 ),
3696 (
3697 folder_mount("memos", b_dir),
3698 Box::new(b_writer) as Box<dyn MemBackend>,
3699 ),
3700 ])
3701 .unwrap();
3702
3703 let mut settings = crate::workspace::WorkspaceSettings::default();
3705 settings.cross_mem_links.insert(
3706 "specs".to_string(),
3707 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3708 );
3709 engine.set_settings(settings);
3710
3711 let (actor, client) = cli_actor();
3712 let source = engine
3713 .create_entity(
3714 empty_create_args("specs", "Source"),
3715 actor,
3716 Some(&client),
3717 None,
3718 )
3719 .unwrap();
3720 let target = engine
3721 .create_entity(
3722 empty_create_args("memos", "Target"),
3723 actor,
3724 Some(&client),
3725 None,
3726 )
3727 .unwrap();
3728 engine
3729 .relate_entity(
3730 crate::engine::RelateEntityArgs {
3731 source: source.id.clone(),
3732 expected_hash: Some(source.content_hash.clone()),
3733 rel_type: "USES".to_string(),
3734 target: target.id.clone(),
3735 remove: false,
3736 description: None,
3737 dry_run: false,
3738 },
3739 actor,
3740 Some(&client),
3741 None,
3742 )
3743 .unwrap();
3744
3745 let has_edge = |e: &Engine| {
3747 let out = e
3748 .store()
3749 .outgoing(&source.id)
3750 .iter()
3751 .any(|edge| edge.target == target.id);
3752 let inc = e
3753 .store()
3754 .incoming(&target.id)
3755 .iter()
3756 .any(|edge| edge.from == source.id);
3757 (out, inc)
3758 };
3759
3760 assert_eq!(
3761 has_edge(&engine),
3762 (true, true),
3763 "edge must be indexed in both directions after relate",
3764 );
3765
3766 engine.reload_one_mem("memos").unwrap();
3768 assert_eq!(
3769 has_edge(&engine),
3770 (true, true),
3771 "cross-mem edge into B must survive a per-mem reload of B",
3772 );
3773
3774 engine.reload_each_writable_mem().unwrap();
3777 assert_eq!(
3778 has_edge(&engine),
3779 (true, true),
3780 "per-mem and workspace reload converge on the same edge",
3781 );
3782
3783 assert!(
3786 engine
3787 .store()
3788 .get(&source.id)
3789 .unwrap()
3790 .relationships
3791 .iter()
3792 .any(|r| r.target == target.id),
3793 "source record must retain the relationship throughout",
3794 );
3795 }
3796
3797 #[test]
3803 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3804 let tmp = TempDir::new().unwrap();
3805 let a_dir = tmp.path().join("a");
3806 let b_dir = tmp.path().join("b");
3807 std::fs::create_dir_all(&a_dir).unwrap();
3808 std::fs::create_dir_all(&b_dir).unwrap();
3809 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3810 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3811 let mut engine = Engine::from_mounts(vec![
3812 (
3813 folder_mount("specs", a_dir),
3814 Box::new(a_writer) as Box<dyn MemBackend>,
3815 ),
3816 (
3817 folder_mount("memos", b_dir),
3818 Box::new(b_writer) as Box<dyn MemBackend>,
3819 ),
3820 ])
3821 .unwrap();
3822 let mut settings = crate::workspace::WorkspaceSettings::default();
3823 settings.cross_mem_links.insert(
3824 "specs".to_string(),
3825 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3826 );
3827 engine.set_settings(settings);
3828
3829 let (actor, client) = cli_actor();
3830 let source = engine
3831 .create_entity(
3832 empty_create_args("specs", "Source"),
3833 actor,
3834 Some(&client),
3835 None,
3836 )
3837 .unwrap();
3838 let target = engine
3839 .create_entity(
3840 empty_create_args("memos", "Target"),
3841 actor,
3842 Some(&client),
3843 None,
3844 )
3845 .unwrap();
3846 engine
3847 .relate_entity(
3848 crate::engine::RelateEntityArgs {
3849 source: source.id.clone(),
3850 expected_hash: Some(source.content_hash.clone()),
3851 rel_type: "USES".to_string(),
3852 target: target.id.clone(),
3853 remove: false,
3854 description: None,
3855 dry_run: false,
3856 },
3857 actor,
3858 Some(&client),
3859 None,
3860 )
3861 .unwrap();
3862
3863 engine.reload_one_mem("specs").unwrap();
3864
3865 let out = engine
3866 .store()
3867 .outgoing(&source.id)
3868 .iter()
3869 .any(|edge| edge.target == target.id);
3870 let inc = engine
3871 .store()
3872 .incoming(&target.id)
3873 .iter()
3874 .any(|edge| edge.from == source.id);
3875 assert!(
3876 out && inc,
3877 "outgoing cross-mem edge must survive a source-mem reload"
3878 );
3879 }
3880
3881 #[test]
3882 fn workspace_root_setter_round_trips() {
3883 let tmp = TempDir::new().unwrap();
3884 let mem_dir = tmp.path().to_path_buf();
3885 let writer = FilesystemMemWriter::new(mem_dir.clone());
3886 let mut engine = Engine::from_mounts(vec![(
3887 folder_mount("specs", mem_dir),
3888 Box::new(writer) as Box<dyn MemBackend>,
3889 )])
3890 .unwrap();
3891 let root = tmp.path().to_path_buf();
3892 engine.set_workspace_root(root.clone());
3893 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3894 }
3895
3896 #[test]
3897 fn export_mem_folder_backend_produces_archive() {
3898 let tmp = TempDir::new().unwrap();
3902 let mem_dir = tmp.path().join("specs");
3903 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3904 let config_body = r#"{
3905 "format": 1,
3906 "schema": "default@1.0.0",
3907 "version": "1.0.0"
3908 }"#;
3909 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3910
3911 let writer = FilesystemMemWriter::new(mem_dir.clone());
3912 let engine = Engine::from_mounts(vec![(
3913 folder_mount("specs", mem_dir.clone()),
3914 Box::new(writer) as Box<dyn MemBackend>,
3915 )])
3916 .unwrap();
3917
3918 let archive_path = tmp.path().join("specs.mem");
3919 let result = engine.export_mem("specs", &archive_path).unwrap();
3920 assert!(archive_path.exists(), "archive must exist on disk");
3921 assert!(result.size_bytes > 0);
3922 assert_eq!(result.entity_count, 0);
3925 }
3926
3927 #[test]
3928 fn export_mem_unknown_mem_returns_unknown_mem() {
3929 let tmp = TempDir::new().unwrap();
3930 let mem_dir = tmp.path().to_path_buf();
3931 let writer = FilesystemMemWriter::new(mem_dir.clone());
3932 let engine = Engine::from_mounts(vec![(
3933 folder_mount("specs", mem_dir),
3934 Box::new(writer) as Box<dyn MemBackend>,
3935 )])
3936 .unwrap();
3937 let output = tmp.path().join("out.mem");
3938 let err = engine.export_mem("missing", &output).unwrap_err();
3939 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3940 }
3941
3942 #[test]
3943 fn export_mem_missing_config_returns_invalid_input() {
3944 let tmp = TempDir::new().unwrap();
3948 let mem_dir = tmp.path().to_path_buf();
3949 let writer = FilesystemMemWriter::new(mem_dir.clone());
3950 let engine = Engine::from_mounts(vec![(
3951 folder_mount("specs", mem_dir),
3952 Box::new(writer) as Box<dyn MemBackend>,
3953 )])
3954 .unwrap();
3955 let output = tmp.path().join("out.mem");
3956 let err = engine.export_mem("specs", &output).unwrap_err();
3957 assert!(matches!(err, EngineError::InvalidInput(_)));
3958 }
3959
3960 #[test]
3961 fn export_mem_archive_backend_returns_sealed() {
3962 let tmp = TempDir::new().unwrap();
3965 let archive_path = build_archive(
3966 tmp.path(),
3967 "ext",
3968 &[(
3969 ".memstead/config.json",
3970 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3971 )],
3972 );
3973 let engine = Engine::from_mounts(vec![(
3974 archive_mount("ext", archive_path.clone()),
3975 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3976 )])
3977 .unwrap();
3978 let output = tmp.path().join("out.mem");
3979 let err = engine.export_mem("ext", &output).unwrap_err();
3980 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3981 }
3982
3983 #[test]
3984 fn export_markdown_writes_unchanged_files_zero_writes() {
3985 let tmp = TempDir::new().unwrap();
3990 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3991 let result = engine.export_markdown(None, None).unwrap();
3992 assert_eq!(
3993 result.written, 0,
3994 "freshly-created entity's file already matches generated markdown"
3995 );
3996 assert_eq!(
3997 result.unchanged, 1,
3998 "the one seeded entity counts as unchanged"
3999 );
4000 assert!(
4001 result.skipped_mounts.is_empty(),
4002 "folder-only workspace has no skipped mounts"
4003 );
4004 }
4005
4006 #[test]
4007 fn export_markdown_skips_non_folder_mounts() {
4008 let tmp = TempDir::new().unwrap();
4012 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4013 let engine = Engine::from_mounts(vec![(
4014 archive_mount("ext", archive_path.clone()),
4015 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4016 )])
4017 .unwrap();
4018 let result = engine.export_markdown(None, None).unwrap();
4019 assert_eq!(result.written, 0);
4020 assert_eq!(result.unchanged, 0);
4021 assert_eq!(
4022 result.skipped_mounts.len(),
4023 1,
4024 "archive mount is in the skipped list"
4025 );
4026 let entry = &result.skipped_mounts[0];
4027 assert_eq!(entry.mem, "ext");
4028 assert_eq!(entry.active_backend, "archive");
4029 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
4030 }
4031
4032 #[test]
4033 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
4034 let tmp = TempDir::new().unwrap();
4038 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4039 let engine = Engine::from_mounts(vec![(
4040 archive_mount("ext", archive_path.clone()),
4041 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4042 )])
4043 .unwrap();
4044 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
4045 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
4046 let details = err.details();
4047 assert_eq!(details["mem"], "ext");
4048 assert_eq!(details["active_backend"], "archive");
4049 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
4050 }
4051
4052 #[test]
4053 fn register_writable_mem_adds_mount_and_router_entry() {
4054 let tmp = TempDir::new().unwrap();
4057 let mem_a = tmp.path().join("a");
4058 std::fs::create_dir_all(&mem_a).unwrap();
4059 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4060
4061 let mut engine = Engine::from_mounts(vec![(
4062 folder_mount("alpha", mem_a),
4063 Box::new(writer_a) as Box<dyn MemBackend>,
4064 )])
4065 .unwrap();
4066 assert!(engine.mem_router().is_writable("alpha"));
4067
4068 let mem_b = tmp.path().join("b");
4069 std::fs::create_dir_all(&mem_b).unwrap();
4070 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4071
4072 engine
4073 .register_writable_mem(
4074 folder_mount("beta", mem_b.clone()),
4075 Box::new(writer_b) as Box<dyn MemBackend>,
4076 MemOrigin::ExplicitToml,
4077 )
4078 .unwrap();
4079
4080 assert!(engine.mem_router().is_writable("alpha"));
4082 assert!(engine.mem_router().is_writable("beta"));
4083 assert!(engine.mem_router().is_visible("beta"));
4084
4085 assert!(engine.mount("beta").is_some());
4087 assert!(engine.schemas().contains_key("beta"));
4088
4089 assert_eq!(
4091 engine.mem_router().dir_for_mem("beta"),
4092 Some(mem_b.as_path()),
4093 );
4094 }
4095
4096 #[test]
4102 fn register_writable_mem_resolves_schema_from_mem_config() {
4103 let tmp = TempDir::new().unwrap();
4104 let mem_a = tmp.path().join("a");
4105 std::fs::create_dir_all(&mem_a).unwrap();
4106 let mut engine = Engine::from_mounts(vec![(
4107 folder_mount("alpha", mem_a.clone()),
4108 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
4109 )])
4110 .unwrap();
4111
4112 let mem_b = tmp.path().join("b");
4113 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
4114 std::fs::write(
4115 mem_b.join(".memstead").join("config.json"),
4116 r#"{"schema":"software@0.1.0"}"#,
4117 )
4118 .unwrap();
4119 let mount_b = crate::workspace::Mount {
4120 mem: "beta".to_string(),
4121 schema: Some(memstead_schema::SchemaRef::new(
4122 "totally-not-a-schema",
4123 semver::Version::new(9, 9, 9),
4124 )),
4125 storage: crate::workspace::MountStorage::Folder {
4126 path: mem_b.clone(),
4127 },
4128 capability: crate::workspace::MountCapability::Write,
4129 lifecycle: crate::workspace::MountLifecycle::Eager,
4130 cross_linkable: true,
4131 migration_target: None,
4132 };
4133 engine
4134 .register_writable_mem(
4135 mount_b,
4136 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
4137 MemOrigin::ExplicitToml,
4138 )
4139 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
4140
4141 assert!(engine.schemas().contains_key("beta"));
4142 let surfaced = engine.load_warnings().iter().any(|w| {
4143 matches!(
4144 w,
4145 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
4146 if mem == "beta"
4147 && config_pin == "software@0.1.0"
4148 && mount_pin == "totally-not-a-schema@9.9.9"
4149 )
4150 });
4151 assert!(
4152 surfaced,
4153 "SchemaPinMismatch must surface for beta: {:?}",
4154 engine.load_warnings(),
4155 );
4156 }
4157
4158 #[test]
4159 fn register_writable_mem_rejects_existing_name() {
4160 let tmp = TempDir::new().unwrap();
4163 let mem_a = tmp.path().join("a");
4164 std::fs::create_dir_all(&mem_a).unwrap();
4165 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4166
4167 let mut engine = Engine::from_mounts(vec![(
4168 folder_mount("alpha", mem_a),
4169 Box::new(writer_a) as Box<dyn MemBackend>,
4170 )])
4171 .unwrap();
4172 let mount_count_pre = engine.mounts().len();
4173
4174 let mem_collide = tmp.path().join("alpha-2");
4175 std::fs::create_dir_all(&mem_collide).unwrap();
4176 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
4177
4178 let err = engine
4179 .register_writable_mem(
4180 folder_mount("alpha", mem_collide),
4181 Box::new(writer_collide) as Box<dyn MemBackend>,
4182 MemOrigin::ExplicitToml,
4183 )
4184 .unwrap_err();
4185 match err {
4186 EngineError::MemNameCollision {
4187 name,
4188 source_origin,
4189 } => {
4190 assert_eq!(name, "alpha");
4191 assert!(
4196 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
4197 );
4198 }
4199 other => panic!("expected MemNameCollision, got {other:?}"),
4200 }
4201
4202 assert_eq!(engine.mounts().len(), mount_count_pre);
4204 }
4205
4206 #[test]
4207 fn register_writable_mem_loads_entities_into_store() {
4208 let tmp = TempDir::new().unwrap();
4211 let mem_a = tmp.path().join("a");
4212 std::fs::create_dir_all(&mem_a).unwrap();
4213 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4214
4215 let mut engine = Engine::from_mounts(vec![(
4216 folder_mount("alpha", mem_a),
4217 Box::new(writer_a) as Box<dyn MemBackend>,
4218 )])
4219 .unwrap();
4220 let pre_count = engine.store().all_entities().count();
4221
4222 let mem_b = tmp.path().join("b");
4224 std::fs::create_dir_all(&mem_b).unwrap();
4225 std::fs::write(
4226 mem_b.join("b1.md"),
4227 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4228 )
4229 .unwrap();
4230 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4231
4232 engine
4233 .register_writable_mem(
4234 folder_mount("beta", mem_b),
4235 Box::new(writer_b) as Box<dyn MemBackend>,
4236 MemOrigin::ExplicitToml,
4237 )
4238 .unwrap();
4239
4240 let post_count = engine.store().all_entities().count();
4241 assert!(post_count > pre_count, "register must load entities");
4242 let beta_count = engine
4243 .store()
4244 .all_entities()
4245 .filter(|e| e.mem == "beta")
4246 .count();
4247 assert_eq!(beta_count, 1);
4248 }
4249
4250 #[test]
4251 fn register_then_unregister_round_trips() {
4252 let tmp = TempDir::new().unwrap();
4256 let mem_a = tmp.path().join("a");
4257 std::fs::create_dir_all(&mem_a).unwrap();
4258 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4259
4260 let mut engine = Engine::from_mounts(vec![(
4261 folder_mount("alpha", mem_a),
4262 Box::new(writer_a) as Box<dyn MemBackend>,
4263 )])
4264 .unwrap();
4265 let pre_mounts = engine.mounts().len();
4266
4267 let mem_b = tmp.path().join("b");
4268 std::fs::create_dir_all(&mem_b).unwrap();
4269 let writer_b = FilesystemMemWriter::new(mem_b);
4270
4271 engine
4272 .register_writable_mem(
4273 folder_mount("beta", tmp.path().join("b")),
4274 Box::new(writer_b) as Box<dyn MemBackend>,
4275 MemOrigin::ExplicitToml,
4276 )
4277 .unwrap();
4278 assert_eq!(engine.mounts().len(), pre_mounts + 1);
4279
4280 let removed = engine.unregister_writable_mem("beta").unwrap();
4281 assert!(removed.is_some());
4282 assert_eq!(engine.mounts().len(), pre_mounts);
4283 assert!(!engine.mem_router().is_writable("beta"));
4284 }
4285
4286 #[test]
4287 fn unregister_writable_mem_returns_false_for_unknown_name() {
4288 let tmp = TempDir::new().unwrap();
4292 let mem_dir = tmp.path().to_path_buf();
4293 let writer = FilesystemMemWriter::new(mem_dir.clone());
4294 let mut engine = Engine::from_mounts(vec![(
4295 folder_mount("specs", mem_dir),
4296 Box::new(writer) as Box<dyn MemBackend>,
4297 )])
4298 .unwrap();
4299 let removed = engine.unregister_writable_mem("missing").unwrap();
4300 assert!(removed.is_none(), "unknown mem returns Ok(None)");
4301 assert!(engine.mem_router().is_writable("specs"));
4303 }
4304
4305 #[test]
4306 fn unregister_writable_mem_drops_mount_and_router_entry() {
4307 let tmp = TempDir::new().unwrap();
4312 let mem_a = tmp.path().join("a");
4313 std::fs::create_dir_all(&mem_a).unwrap();
4314 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4315 let mem_b = tmp.path().join("b");
4316 std::fs::create_dir_all(&mem_b).unwrap();
4317 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4318
4319 let mut engine = Engine::from_mounts(vec![
4320 (
4321 folder_mount("alpha", mem_a),
4322 Box::new(writer_a) as Box<dyn MemBackend>,
4323 ),
4324 (
4325 folder_mount("beta", mem_b),
4326 Box::new(writer_b) as Box<dyn MemBackend>,
4327 ),
4328 ])
4329 .unwrap();
4330
4331 let removed = engine.unregister_writable_mem("alpha").unwrap();
4332 assert!(removed.is_some());
4333
4334 assert!(!engine.mem_router().is_writable("alpha"));
4336 assert!(!engine.mem_router().is_visible("alpha"));
4337 assert!(engine.mount("alpha").is_none());
4338
4339 assert!(engine.mem_router().is_writable("beta"));
4341 assert!(engine.mount("beta").is_some());
4342 }
4343
4344 #[test]
4345 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
4346 let tmp = TempDir::new().unwrap();
4350 let mem_a = tmp.path().join("a");
4351 std::fs::create_dir_all(&mem_a).unwrap();
4352 std::fs::write(
4353 mem_a.join("a1.md"),
4354 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
4355 )
4356 .unwrap();
4357 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4358
4359 let mem_b = tmp.path().join("b");
4360 std::fs::create_dir_all(&mem_b).unwrap();
4361 std::fs::write(
4362 mem_b.join("b1.md"),
4363 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4364 )
4365 .unwrap();
4366 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4367
4368 let mut engine = Engine::from_mounts(vec![
4369 (
4370 folder_mount("alpha", mem_a),
4371 Box::new(writer_a) as Box<dyn MemBackend>,
4372 ),
4373 (
4374 folder_mount("beta", mem_b),
4375 Box::new(writer_b) as Box<dyn MemBackend>,
4376 ),
4377 ])
4378 .unwrap();
4379
4380 let pre_total = engine.store().all_entities().count();
4381 assert!(pre_total >= 2, "both mems must load entities");
4382
4383 engine.unregister_writable_mem("alpha").unwrap();
4384
4385 let alpha_remaining = engine
4387 .store()
4388 .all_entities()
4389 .filter(|e| e.mem == "alpha")
4390 .count();
4391 assert_eq!(alpha_remaining, 0);
4392
4393 let beta_remaining = engine
4395 .store()
4396 .all_entities()
4397 .filter(|e| e.mem == "beta")
4398 .count();
4399 assert!(beta_remaining > 0, "beta entities must survive");
4400 }
4401 #[test]
4402 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4403 let tmp = TempDir::new().unwrap();
4404 let mut engine = build_demo_engine(&tmp);
4405 let result = engine
4406 .reload_one_mem("specs")
4407 .expect("reload on stable disk must succeed");
4408 assert!(result.added.is_empty(), "added: {:?}", result.added);
4409 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4410 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4411 }
4412
4413 #[test]
4414 fn reload_one_mem_picks_up_external_addition() {
4415 let tmp = TempDir::new().unwrap();
4416 let mut engine = build_demo_engine(&tmp);
4417 std::fs::write(
4420 tmp.path().join("external.md"),
4421 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4422 )
4423 .unwrap();
4424 let result = engine.reload_one_mem("specs").unwrap();
4425 assert_eq!(
4426 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4427 vec!["specs--external"]
4428 );
4429 assert!(result.changed.is_empty());
4430 assert!(result.removed.is_empty());
4431 assert!(
4433 engine
4434 .get_entity(&crate::EntityId::new("specs", "external"))
4435 .is_some()
4436 );
4437 }
4438
4439 #[test]
4440 fn reload_one_mem_picks_up_external_removal() {
4441 let tmp = TempDir::new().unwrap();
4442 let mut engine = build_demo_engine(&tmp);
4443 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4446 let result = engine.reload_one_mem("specs").unwrap();
4447 assert!(result.added.is_empty());
4448 assert!(result.changed.is_empty());
4449 assert_eq!(
4450 result
4451 .removed
4452 .iter()
4453 .map(|i| i.as_ref())
4454 .collect::<Vec<_>>(),
4455 vec!["specs--lonely-three"]
4456 );
4457 }
4458
4459 #[test]
4460 fn reload_one_mem_picks_up_external_change() {
4461 let tmp = TempDir::new().unwrap();
4462 let mut engine = build_demo_engine(&tmp);
4463 std::fs::write(
4466 tmp.path().join("source-one.md"),
4467 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4468 )
4469 .unwrap();
4470 let result = engine.reload_one_mem("specs").unwrap();
4471 assert!(result.added.is_empty());
4472 assert_eq!(
4473 result
4474 .changed
4475 .iter()
4476 .map(|i| i.as_ref())
4477 .collect::<Vec<_>>(),
4478 vec!["specs--source-one"]
4479 );
4480 assert!(result.removed.is_empty());
4481 }
4482
4483 #[test]
4484 fn reload_one_mem_rejects_unknown_mem() {
4485 let tmp = TempDir::new().unwrap();
4486 let mut engine = build_demo_engine(&tmp);
4487 let err = engine.reload_one_mem("nope").unwrap_err();
4488 match err {
4489 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4490 other => panic!("expected UnknownMem, got {other:?}"),
4491 }
4492 }
4493
4494 #[test]
4495 fn reload_each_writable_mem_returns_one_entry_per_mount() {
4496 let tmp = TempDir::new().unwrap();
4497 let mut engine = build_demo_engine(&tmp);
4498 let reports = engine
4499 .reload_each_writable_mem()
4500 .expect("batch reload on stable disk must succeed");
4501 assert_eq!(reports.len(), 1);
4502 assert_eq!(reports[0].0, "specs");
4503 assert!(reports[0].1.added.is_empty());
4504 assert!(reports[0].1.changed.is_empty());
4505 assert!(reports[0].1.removed.is_empty());
4506 }
4507
4508 #[test]
4511 fn settings_default_to_empty_on_fresh_engine() {
4512 let tmp = TempDir::new().unwrap();
4513 let engine = build_demo_engine(&tmp);
4514 let s = engine.settings();
4515 assert!(s.mem_create_rules.is_empty());
4516 assert!(s.mem_delete_rules.is_empty());
4517 assert!(s.cross_mem_links.is_empty());
4518 }
4519
4520 #[test]
4521 fn set_settings_replaces_workspace_policy() {
4522 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4523 let tmp = TempDir::new().unwrap();
4524 let mut engine = build_demo_engine(&tmp);
4525 let mut settings = WorkspaceSettings::default();
4526 settings.mem_create_rules.push(CreateRuleSetting {
4527 pattern: "exec-*".to_string(),
4528 schemas: vec!["default@1.0.0".to_string()],
4529 default_cross_links: None,
4530 });
4531 settings.mem_delete_rules.push(DeleteRuleSetting {
4532 pattern: "exec-*".to_string(),
4533 });
4534 engine.set_settings(settings);
4535 assert_eq!(engine.settings().mem_create_rules.len(), 1);
4536 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4537 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4538 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4539 }
4540
4541 #[test]
4544 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4545 let tmp = TempDir::new().unwrap();
4546 let mut engine = build_demo_engine(&tmp);
4547 std::fs::write(
4549 tmp.path().join("new-via-disk.md"),
4550 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4551 )
4552 .unwrap();
4553 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4554 std::fs::write(
4555 tmp.path().join("source-one.md"),
4556 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4557 )
4558 .unwrap();
4559
4560 let reports = engine.reload_each_writable_mem().unwrap();
4561 assert_eq!(reports.len(), 1);
4562 let (mem, result) = &reports[0];
4563 assert_eq!(mem, "specs");
4564 assert_eq!(
4565 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4566 vec!["specs--new-via-disk"]
4567 );
4568 assert_eq!(
4569 result
4570 .removed
4571 .iter()
4572 .map(|i| i.as_ref())
4573 .collect::<Vec<_>>(),
4574 vec!["specs--lonely-three"]
4575 );
4576 assert_eq!(
4577 result
4578 .changed
4579 .iter()
4580 .map(|i| i.as_ref())
4581 .collect::<Vec<_>>(),
4582 vec!["specs--source-one"]
4583 );
4584 }
4585
4586 #[test]
4589 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4590 let tmp = TempDir::new().unwrap();
4598 let mut engine = build_demo_engine(&tmp);
4599 let report = engine.reload_one_mem_report("specs").unwrap();
4600 assert_eq!(report.mem, "specs");
4601 assert_eq!(
4602 report.head_before, report.head_after,
4603 "unchanged disk → stable cursor"
4604 );
4605 assert!(
4606 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4607 "folder heads carry the changelog-ts cursor, got {}",
4608 report.head_after
4609 );
4610 assert_eq!(report.entities_loaded, 3);
4613 assert!(report.changed_entity_ids.is_empty());
4615 }
4616
4617 #[test]
4618 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4619 let tmp = TempDir::new().unwrap();
4624 let mut engine = build_demo_engine(&tmp);
4625 std::fs::write(
4626 tmp.path().join("new-via-disk.md"),
4627 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4628 )
4629 .unwrap();
4630 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4631 std::fs::write(
4632 tmp.path().join("source-one.md"),
4633 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4634 )
4635 .unwrap();
4636
4637 let report = engine.reload_one_mem_report("specs").unwrap();
4638 assert_eq!(report.mem, "specs");
4639 let ids: Vec<&str> = report
4640 .changed_entity_ids
4641 .iter()
4642 .map(|id| id.as_ref())
4643 .collect();
4644 assert_eq!(
4646 ids,
4647 vec![
4648 "specs--lonely-three",
4649 "specs--new-via-disk",
4650 "specs--source-one",
4651 ]
4652 );
4653 }
4654
4655 #[test]
4656 fn reload_one_mem_report_rejects_unknown_mem() {
4657 let tmp = TempDir::new().unwrap();
4658 let mut engine = build_demo_engine(&tmp);
4659 let err = engine.reload_one_mem_report("missing").unwrap_err();
4660 assert!(matches!(err, EngineError::UnknownMem(_)));
4661 }
4662
4663 #[test]
4664 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4665 let tmp = TempDir::new().unwrap();
4666 let mut engine = build_demo_engine(&tmp);
4667 let reports = engine.reload_each_writable_mem_reports().unwrap();
4668 assert_eq!(reports.len(), 1);
4669 assert_eq!(reports[0].mem, "specs");
4670 assert_eq!(reports[0].entities_loaded, 3);
4671 }
4672
4673 #[test]
4680 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4681 let tmp = TempDir::new().unwrap();
4682
4683 let memstead_dir = tmp.path().join(".memstead");
4686 std::fs::create_dir_all(&memstead_dir).unwrap();
4687 let workspace_toml = memstead_dir.join("workspace.toml");
4688 std::fs::write(
4689 &workspace_toml,
4690 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4691 )
4692 .unwrap();
4693 let mounts_json = memstead_dir.join("state").join("mounts.json");
4694 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4695 let mem_dir = tmp.path().join("specs");
4696 std::fs::create_dir_all(&mem_dir).unwrap();
4697 let mounts_body = format!(
4698 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4699 mem_dir.display(),
4700 );
4701 std::fs::write(&mounts_json, mounts_body).unwrap();
4702
4703 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4704 assert!(
4705 engine.settings().mem_create_rules.is_empty(),
4706 "boot-time settings carry no create rules"
4707 );
4708
4709 std::fs::write(
4711 &workspace_toml,
4712 "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",
4713 )
4714 .unwrap();
4715
4716 engine.reload_each_writable_mem_reports().unwrap();
4717
4718 let rules = &engine.settings().mem_create_rules;
4719 assert_eq!(
4720 rules.len(),
4721 1,
4722 "workspace-wide reload must refresh the policy"
4723 );
4724 assert_eq!(rules[0].pattern, "exec-*");
4725 }
4726
4727 const MIG_TYPE_TAIL: &str = r#"sections:
4732 - key: body
4733 heading: Body
4734 required: true
4735 search_weight: 10.0
4736 catch_all: true
4737 write_rules: []
4738title_weight: 100.0
4739text_fields:
4740 - body
4741hierarchy_relationship: _default
4742no_self_loop_relationships: []
4743updatable_fields: []
4744health_required_fields: []
4745staleness_threshold_days: 90
4746write_rules: []
4747"#;
4748
4749 fn mig_manifest(name: &str, version: &str) -> String {
4754 format!(
4755 r#"name: {name}
4756version: {version}
4757description: migration test schema
4758when_to_use: tests
4759types:
4760 - doc
4761relationships:
4762 mode: strict
4763 definitions:
4764 - name: USES
4765 description: link
4766 default_weight: 1.0
4767 - name: _default
4768 description: fallback
4769 default_weight: 1.0
4770community:
4771 resolution: 1.0
4772 seed: 42
4773"#
4774 )
4775 }
4776
4777 fn mig_type_yaml(with_status: bool) -> String {
4778 let metadata = if with_status {
4779 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4780 } else {
4781 "metadata_fields: []\n"
4782 };
4783 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4784 }
4785
4786 fn write_mig_schema(
4787 root: &std::path::Path,
4788 dir: &str,
4789 name: &str,
4790 version: &str,
4791 with_status: bool,
4792 ) {
4793 let d = root.join(dir);
4794 std::fs::create_dir_all(d.join("types")).unwrap();
4795 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4796 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4797 }
4798
4799 #[test]
4816 fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4817 for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4818 run_mutation_sequence(seed);
4819 }
4820 }
4821
4822 struct Xorshift(u64);
4823 impl Xorshift {
4824 fn next(&mut self) -> u64 {
4825 let mut x = self.0;
4826 x ^= x << 13;
4827 x ^= x >> 7;
4828 x ^= x << 17;
4829 self.0 = x;
4830 x
4831 }
4832 fn pick(&mut self, n: usize) -> usize {
4833 (self.next() % n as u64) as usize
4834 }
4835 }
4836
4837 fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4838 let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4841 let live = engine.search_indexes();
4842 let mut live_mems: Vec<&String> = live.keys().collect();
4843 let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4844 live_mems.sort();
4845 fresh_mems.sort();
4846 assert_eq!(
4847 live_mems, fresh_mems,
4848 "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4849 );
4850 for (mem, idx) in live {
4851 let mut got = idx.stored_ids().unwrap();
4852 let mut want = fresh[mem].stored_ids().unwrap();
4853 got.sort();
4854 want.sort();
4855 assert_eq!(
4856 got, want,
4857 "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4858 );
4859 }
4860 let schema = engine
4863 .schemas
4864 .iter()
4865 .min_by(|a, b| a.0.cmp(b.0))
4866 .map(|(_, s)| s.clone())
4867 .expect("schema present");
4868 let weights_schema = schema.clone();
4869 let fresh_partition = crate::graph::community::detect_communities(
4870 engine.store(),
4871 schema.manifest.community.resolution,
4872 schema.manifest.community.seed,
4873 move |rel_type| {
4874 weights_schema
4875 .manifest
4876 .relationships
4877 .definitions
4878 .iter()
4879 .find(|d| d.name == rel_type)
4880 .map(|d| d.default_weight as f64)
4881 .unwrap_or(1.0)
4882 },
4883 );
4884 assert_eq!(
4885 engine.communities().entity_cluster_map,
4886 fresh_partition.entity_cluster_map,
4887 "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4888 );
4889 }
4890
4891 fn run_mutation_sequence(seed: u64) {
4892 use indexmap::IndexMap;
4893
4894 let (_tmp, mut engine) = migration_engine();
4895 let mut rng = Xorshift(seed);
4896 let mut live: Vec<crate::EntityId> = vec![
4897 crate::EntityId::new("specs", "one"),
4898 crate::EntityId::new("specs", "two"),
4899 ];
4900 let mut counter = 0usize;
4901 let mut kinds_hit: std::collections::HashSet<&'static str> =
4902 std::collections::HashSet::new();
4903
4904 const KINDS: [&str; 7] = [
4905 "create",
4906 "update",
4907 "relate",
4908 "delete",
4909 "rename",
4910 "batch_applied",
4911 "batch_refused",
4912 ];
4913
4914 let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4915 anchors: Vec::new(),
4916 anchors_unset: Vec::new(),
4917 id,
4918 expected_hash: None,
4919 sections: IndexMap::new(),
4920 append_sections: IndexMap::new(),
4921 patch_sections: IndexMap::new(),
4922 sections_unset: Vec::new(),
4923 metadata: IndexMap::new(),
4924 metadata_unset: Vec::new(),
4925 declare_relations: Vec::new(),
4926 dry_run: false,
4927 relations_unset: Vec::new(),
4928 };
4929
4930 for op_i in 0..30usize {
4931 let kind = *KINDS
4934 .get(op_i)
4935 .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4936 match kind {
4937 "create" => {
4938 counter += 1;
4939 let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4940 args.entity_type = "doc".to_string();
4941 args.sections = IndexMap::from_iter([(
4942 "body".to_string(),
4943 format!("generated body {counter}"),
4944 )]);
4945 let out = engine
4946 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4947 .expect("generated create is conformant");
4948 live.push(out.id);
4949 kinds_hit.insert("create");
4950 }
4951 "update" => {
4952 let id = live[rng.pick(live.len())].clone();
4953 let mut args = bare_update(id);
4954 args.append_sections
4955 .insert("body".to_string(), format!("appended at op {op_i}"));
4956 engine
4957 .update_entity(args, crate::vcs::Actor::Cli, None, None)
4958 .expect("append update is conformant");
4959 kinds_hit.insert("update");
4960 }
4961 "relate" => {
4962 if live.len() >= 2 {
4963 let a = rng.pick(live.len());
4964 let mut b = rng.pick(live.len());
4965 if a == b {
4966 b = (b + 1) % live.len();
4967 }
4968 engine
4969 .relate_entity(
4970 crate::engine::RelateEntityArgs {
4971 source: live[a].clone(),
4972 expected_hash: None,
4973 rel_type: "USES".to_string(),
4974 target: live[b].clone(),
4975 remove: false,
4976 description: None,
4977 dry_run: false,
4978 },
4979 crate::vcs::Actor::Cli,
4980 None,
4981 None,
4982 )
4983 .expect("USES relate is legal under mig-a");
4984 kinds_hit.insert("relate");
4985 }
4986 }
4987 "delete" => {
4988 if live.len() > 2
4991 && let Some(pos) = (0..live.len()).find(|&i| {
4992 engine.store().incoming(&live[i]).is_empty()
4993 && engine
4994 .store()
4995 .get(&live[i])
4996 .is_some_and(|e| e.relationships.is_empty())
4997 })
4998 {
4999 let id = live.remove(pos);
5000 engine
5001 .delete_entity(
5002 crate::engine::DeleteEntityArgs {
5003 id: id.clone(),
5004 expected_hash: None,
5005 },
5006 crate::vcs::Actor::Cli,
5007 None,
5008 None,
5009 )
5010 .expect("reference-free delete lands");
5011 kinds_hit.insert("delete");
5012 }
5013 }
5014 "rename" => {
5015 counter += 1;
5016 let pos = rng.pick(live.len());
5017 let old = live[pos].clone();
5018 let out = engine
5019 .rename_entity(
5020 crate::engine::RenameEntityArgs {
5021 id: old,
5022 new_title: format!("Renamed {counter}"),
5023 expected_hash: None,
5024 },
5025 crate::vcs::Actor::Cli,
5026 None,
5027 None,
5028 )
5029 .expect("fresh-slug rename lands");
5030 live[pos] = out.new_id;
5031 kinds_hit.insert("rename");
5032 }
5033 "batch_applied" => {
5034 let id_a = live[rng.pick(live.len())].clone();
5035 let mut a = bare_update(id_a);
5036 a.append_sections
5037 .insert("body".to_string(), format!("batch line {op_i}"));
5038 let result = engine
5039 .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
5040 .expect("batch envelope");
5041 assert!(result.applied, "single-entry append batch applies");
5042 kinds_hit.insert("batch_applied");
5043 }
5044 "batch_refused" => {
5045 let id_a = live[rng.pick(live.len())].clone();
5046 let mut a = bare_update(id_a);
5047 a.append_sections
5048 .insert("body".to_string(), "doomed".to_string());
5049 let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
5050 let result = engine
5051 .batch_update(
5052 vec![(a, None), (missing, None)],
5053 crate::vcs::Actor::Cli,
5054 None,
5055 false,
5056 )
5057 .expect("refused batch returns a report-all envelope");
5058 assert!(!result.applied, "the missing target refuses the batch");
5059 kinds_hit.insert("batch_refused");
5060 }
5061 _ => unreachable!(),
5062 }
5063
5064 if op_i == 14 {
5065 engine
5069 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5070 .expect("integral switch");
5071 kinds_hit.insert("schema_switch");
5072 }
5073 if op_i == 19 {
5074 engine.reload_one_mem("specs").expect("reload lands");
5075 kinds_hit.insert("reload");
5076 }
5077
5078 if op_i % 10 == 9 {
5079 assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
5080 }
5081 }
5082
5083 assert_derived_oracles(&engine, seed, "sequence end");
5084
5085 for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
5086 assert!(
5087 kinds_hit.contains(kind),
5088 "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
5089 );
5090 }
5091 }
5092
5093 fn migration_engine() -> (tempfile::TempDir, Engine) {
5094 let tmp = tempfile::TempDir::new().unwrap();
5095 let schemas_dir = tmp.path().join("schemas");
5096 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5097 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5098 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
5099 let mem_dir = tmp.path().join("mem");
5100 std::fs::create_dir_all(&mem_dir).unwrap();
5101 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5102 let mut mount = folder_mount("specs", mem_dir);
5103 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5104 let mut engine = Engine::from_mounts_with_schemas_dir(
5105 vec![(
5106 mount,
5107 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5108 )],
5109 Some(&schemas_dir),
5110 )
5111 .unwrap();
5112 for title in ["One", "Two"] {
5113 let mut args = empty_create_args("specs", title);
5114 args.entity_type = "doc".to_string();
5115 args.sections =
5116 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
5117 engine
5118 .create_entity(args, crate::vcs::Actor::Cli, None, None)
5119 .expect("conformant create under mig-a");
5120 }
5121 (tmp, engine)
5122 }
5123
5124 fn sref(s: &str) -> memstead_schema::SchemaRef {
5125 s.parse().unwrap()
5126 }
5127
5128 #[test]
5134 fn set_schema_repairs_a_mount_expectation_ahead_of_the_served_pin() {
5135 let (_tmp, mut engine) = migration_engine();
5136 let idx = engine
5139 .mounts
5140 .iter()
5141 .position(|m| m.mount.mem == "specs")
5142 .unwrap();
5143 engine.mounts[idx].mount.schema = Some(sref("mig-b@0.1.0"));
5144 assert_eq!(engine.schemas.get("specs").unwrap().id().0, "mig-a");
5145
5146 let out = engine
5147 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5148 .unwrap();
5149 assert_eq!(
5153 out.outcome,
5154 crate::engine::SetSchemaResult::MigrationStarted,
5155 "a served pin behind the target enters the switch path, never a noop: {out:?}"
5156 );
5157 assert!(!out.findings.is_empty());
5158 assert_eq!(
5159 engine.schemas.get("specs").unwrap().id().0,
5160 "mig-b",
5161 "writes now validate against the target"
5162 );
5163
5164 let (_tmp2, mut clean) = migration_engine();
5167 let again = clean.set_mem_schema("specs", &sref("mig-a@0.1.0")).unwrap();
5168 assert_eq!(again.outcome, crate::engine::SetSchemaResult::Noop);
5169 }
5170
5171 #[test]
5172 fn set_schema_noop_on_current_pin() {
5173 let (_tmp, mut engine) = migration_engine();
5174 let out = engine
5175 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
5176 .unwrap();
5177 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
5178 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5179 assert_eq!(out.migration_target, None);
5180 assert!(out.findings.is_empty());
5181 }
5182
5183 #[test]
5192 fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
5193 let (_tmp, mut engine) = migration_engine();
5194
5195 let _ = engine.communities();
5196 let _ = engine.search_indexes();
5197 assert!(engine.community_memo.get().is_some());
5198 assert!(engine.search_indexes_memo.get().is_some());
5199 let store_gen_before = engine.store().generation();
5200
5201 engine
5202 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5203 .unwrap();
5204
5205 assert_eq!(
5206 engine.store().generation(),
5207 store_gen_before,
5208 "a schema switch mutates no store content"
5209 );
5210 assert!(
5211 engine.community_memo.get().is_none(),
5212 "the community memo must clear on a schema switch (weights derive from the schema)"
5213 );
5214 assert!(
5215 engine.search_indexes_memo.get().is_none(),
5216 "the search memo must clear on a schema switch (the field set derives from the schema)"
5217 );
5218 }
5219
5220 #[test]
5221 fn set_schema_switches_immediately_when_integral() {
5222 let (_tmp, mut engine) = migration_engine();
5225 let out = engine
5226 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5227 .unwrap();
5228 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5229 assert_eq!(out.schema_pin, "mig-a@0.2.0");
5230 assert_eq!(out.migration_target, None);
5231 assert!(out.findings.is_empty());
5232 assert_eq!(
5233 engine.schema_pin("specs").unwrap().as_display(),
5234 "mig-a@0.2.0"
5235 );
5236 assert!(engine.migration_target("specs").is_none());
5237 }
5238
5239 #[test]
5245 fn set_schema_switch_persists_pin_into_backend_config() {
5246 let tmp = tempfile::TempDir::new().unwrap();
5247 let schemas_dir = tmp.path().join("schemas");
5248 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5249 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5250 let mem_dir = tmp.path().join("mem");
5251 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
5252 std::fs::write(
5254 mem_dir.join(".memstead").join("config.json"),
5255 br#"{"schema":"mig-a@0.1.0"}"#,
5256 )
5257 .unwrap();
5258 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5259 let mut mount = folder_mount("specs", mem_dir.clone());
5260 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5261 let mut engine = Engine::from_mounts_with_schemas_dir(
5262 vec![(
5263 mount,
5264 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5265 )],
5266 Some(&schemas_dir),
5267 )
5268 .unwrap();
5269
5270 let out = engine
5271 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5272 .unwrap();
5273 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5274
5275 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
5278 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
5279 assert_eq!(
5280 cfg["schema"], "mig-a@0.2.0",
5281 "atomic switch must update the authoritative backend config"
5282 );
5283 }
5284
5285 #[test]
5286 fn set_schema_unknown_target_refuses_schema_not_found() {
5287 let (_tmp, mut engine) = migration_engine();
5288 let err = engine
5289 .set_mem_schema("specs", &sref("nope@9.9.9"))
5290 .unwrap_err();
5291 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
5292 assert!(engine.migration_target("specs").is_none());
5294 }
5295
5296 #[test]
5297 fn set_schema_migration_lifecycle_end_to_end() {
5298 let (_tmp, mut engine) = migration_engine();
5299 let target = sref("mig-b@0.1.0");
5300
5301 let out = engine.set_mem_schema("specs", &target).unwrap();
5303 assert_eq!(
5304 out.outcome,
5305 crate::engine::SetSchemaResult::MigrationStarted
5306 );
5307 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5308 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
5309 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
5310 assert!(
5311 out.findings
5312 .iter()
5313 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
5314 );
5315
5316 let one = crate::entity::EntityId::new("specs", "one");
5318 assert!(engine.store().get(&one).is_some());
5319
5320 let out = engine.set_mem_schema("specs", &target).unwrap();
5322 assert_eq!(
5323 out.outcome,
5324 crate::engine::SetSchemaResult::MigrationPending
5325 );
5326 assert_eq!(out.findings.len(), 2);
5327
5328 let mut bad = crate::engine::UpdateEntityArgs {
5332 anchors: Vec::new(),
5333 id: one.clone(),
5334 expected_hash: None,
5335 sections: indexmap::IndexMap::new(),
5336 append_sections: indexmap::IndexMap::new(),
5337 patch_sections: indexmap::IndexMap::new(),
5338 sections_unset: Vec::new(),
5339 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
5340 metadata_unset: Vec::new(),
5341 declare_relations: Vec::new(),
5342 dry_run: false,
5343 relations_unset: Vec::new(),
5344 anchors_unset: Vec::new(),
5345 };
5346 let err = engine
5347 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
5348 .unwrap_err();
5349 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
5350 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
5351 engine
5352 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
5353 .expect("repair write validated against the migration target");
5354
5355 let out = engine.set_mem_schema("specs", &target).unwrap();
5357 assert_eq!(
5358 out.outcome,
5359 crate::engine::SetSchemaResult::MigrationPending
5360 );
5361 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
5362
5363 let two = crate::entity::EntityId::new("specs", "two");
5365 let repair = crate::engine::UpdateEntityArgs {
5366 anchors: Vec::new(),
5367 id: two.clone(),
5368 expected_hash: None,
5369 sections: indexmap::IndexMap::new(),
5370 append_sections: indexmap::IndexMap::new(),
5371 patch_sections: indexmap::IndexMap::new(),
5372 sections_unset: Vec::new(),
5373 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
5374 metadata_unset: Vec::new(),
5375 declare_relations: Vec::new(),
5376 dry_run: false,
5377 relations_unset: Vec::new(),
5378 anchors_unset: Vec::new(),
5379 };
5380 engine
5381 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
5382 .unwrap();
5383 let out = engine.set_mem_schema("specs", &target).unwrap();
5384 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5385 assert_eq!(out.schema_pin, "mig-b@0.1.0");
5386 assert_eq!(out.migration_target, None);
5387 assert!(out.findings.is_empty());
5388 assert_eq!(
5389 engine.schema_pin("specs").unwrap().as_display(),
5390 "mig-b@0.1.0"
5391 );
5392 assert!(engine.migration_target("specs").is_none());
5393 }
5394
5395 #[test]
5400 fn relations_unset_works_during_migration_without_mode_flag() {
5401 let (_tmp, mut engine) = migration_engine();
5402 let one = crate::entity::EntityId::new("specs", "one");
5403 let two = crate::entity::EntityId::new("specs", "two");
5404 engine
5405 .relate_entity(
5406 crate::engine::RelateEntityArgs {
5407 source: one.clone(),
5408 expected_hash: None,
5409 rel_type: "USES".to_string(),
5410 target: two.clone(),
5411 remove: false,
5412 description: None,
5413 dry_run: false,
5414 },
5415 crate::vcs::Actor::Cli,
5416 None,
5417 None,
5418 )
5419 .unwrap();
5420 let shut = engine
5422 .update_entity(
5423 crate::engine::UpdateEntityArgs {
5424 anchors: Vec::new(),
5425 id: one.clone(),
5426 expected_hash: None,
5427 sections: indexmap::IndexMap::new(),
5428 append_sections: indexmap::IndexMap::new(),
5429 patch_sections: indexmap::IndexMap::new(),
5430 sections_unset: Vec::new(),
5431 metadata: indexmap::IndexMap::new(),
5432 metadata_unset: Vec::new(),
5433 declare_relations: Vec::new(),
5434 dry_run: false,
5435 relations_unset: vec![crate::ops::RelationUnsetArg {
5436 rel_type: "USES".to_string(),
5437 target: two.clone(),
5438 }],
5439 anchors_unset: Vec::new(),
5440 },
5441 crate::vcs::Actor::Cli,
5442 None,
5443 None,
5444 )
5445 .unwrap_err();
5446 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5447
5448 engine
5452 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5453 .unwrap();
5454 engine
5455 .update_entity(
5456 crate::engine::UpdateEntityArgs {
5457 anchors: Vec::new(),
5458 id: one.clone(),
5459 expected_hash: None,
5460 sections: indexmap::IndexMap::new(),
5461 append_sections: indexmap::IndexMap::new(),
5462 patch_sections: indexmap::IndexMap::new(),
5463 sections_unset: Vec::new(),
5464 metadata: indexmap::IndexMap::from_iter([(
5465 "status".to_string(),
5466 "open".to_string(),
5467 )]),
5468 metadata_unset: Vec::new(),
5469 declare_relations: Vec::new(),
5470 dry_run: false,
5471 relations_unset: vec![crate::ops::RelationUnsetArg {
5472 rel_type: "USES".to_string(),
5473 target: two.clone(),
5474 }],
5475 anchors_unset: Vec::new(),
5476 },
5477 crate::vcs::Actor::Cli,
5478 None,
5479 None,
5480 )
5481 .expect("repair-shaped update lands during migration without a flag");
5482 let entity = engine.store().get(&one).unwrap();
5483 assert!(entity.relationships.is_empty());
5484 }
5485
5486 #[test]
5491 fn boot_resumes_dual_pin_validation_against_target() {
5492 let (tmp, engine) = migration_engine();
5493 drop(engine);
5494 let schemas_dir = tmp.path().join("schemas");
5495 let mem_dir = tmp.path().join("mem");
5496 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5497 let mut mount = folder_mount("specs", mem_dir);
5498 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5499 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5500 let engine = Engine::from_mounts_with_schemas_dir(
5501 vec![(
5502 mount,
5503 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5504 )],
5505 Some(&schemas_dir),
5506 )
5507 .unwrap();
5508 let (name, version) = {
5510 let s = engine.schema_for("specs").unwrap();
5511 let (n, v) = s.id();
5512 (n.to_string(), v.to_string())
5513 };
5514 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5515 assert_eq!(
5518 engine.schema_pin("specs").unwrap().as_display(),
5519 "mig-a@0.1.0"
5520 );
5521 assert_eq!(
5522 engine.migration_target("specs").unwrap().as_display(),
5523 "mig-b@0.1.0"
5524 );
5525 }
5526
5527 #[test]
5537 fn every_lifecycle_setter_refuses_on_read_only_mount() {
5538 let tmp = TempDir::new().unwrap();
5539 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5540 let mut engine = Engine::from_mounts(vec![(
5541 archive_mount("ext", archive_path.clone()),
5542 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5543 )])
5544 .unwrap();
5545
5546 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5547 let attempts: Vec<(&str, EngineError)> = vec![
5548 (
5549 "set_mem_schema",
5550 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5551 ),
5552 (
5553 "set_mem_version",
5554 engine
5555 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5556 .unwrap_err(),
5557 ),
5558 (
5559 "set_mem_description",
5560 engine
5561 .set_mem_description("ext", Some("x".into()), None)
5562 .unwrap_err(),
5563 ),
5564 (
5565 "set_mem_title",
5566 engine
5567 .set_mem_title("ext", Some("x".into()), None)
5568 .unwrap_err(),
5569 ),
5570 (
5571 "set_mem_subject",
5572 engine.set_mem_subject("ext", None, None).unwrap_err(),
5573 ),
5574 (
5575 "set_mem_internal",
5576 engine.set_mem_internal("ext", true, None).unwrap_err(),
5577 ),
5578 (
5579 "set_mem_sync_state",
5580 engine
5581 .set_mem_sync_state("ext", "k", "t", None)
5582 .unwrap_err(),
5583 ),
5584 ];
5585 for (setter, err) in attempts {
5586 match err {
5587 EngineError::ReadOnlyMount(v) => {
5588 assert_eq!(v, "ext", "{setter} must name the refused mem")
5589 }
5590 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5591 }
5592 }
5593 }
5594
5595 #[test]
5601 fn no_config_setter_reverts_a_siblings_write() {
5602 type Setter = fn(&mut Engine) -> Result<(), EngineError>;
5603 let setters: Vec<(&str, Setter)> = vec![
5604 ("version", |e| {
5605 e.set_mem_version("specs", semver::Version::new(9, 0, 0), None)
5606 .map(|_| ())
5607 }),
5608 ("description", |e| {
5609 e.set_mem_description("specs", Some("mine".into()), None)
5610 .map(|_| ())
5611 }),
5612 ("title", |e| {
5613 e.set_mem_title("specs", Some("Mine".into()), None)
5614 .map(|_| ())
5615 }),
5616 ("internal", |e| {
5617 e.set_mem_internal("specs", true, None).map(|_| ())
5618 }),
5619 ("sync_state", |e| {
5620 e.set_mem_sync_state("specs", "src/facet", "tok", None)
5621 .map(|_| ())
5622 }),
5623 ("review_mark", |e| {
5627 e.set_review_mark("specs", None, None).map(|_| ())
5628 }),
5629 ];
5630
5631 for (name, set) in setters {
5632 let tmp = TempDir::new().unwrap();
5633 let mem_dir = tmp.path().to_path_buf();
5634 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5635 std::fs::create_dir_all(&meta).unwrap();
5636 let path = meta.join("config.json");
5637 std::fs::write(
5638 &path,
5639 br#"{"schema": "default@1.0.0", "version": "0.1.0"}"#.as_slice(),
5640 )
5641 .unwrap();
5642
5643 let writer = FilesystemMemWriter::new(mem_dir.clone());
5644 let mut engine = Engine::from_mounts(vec![(
5645 folder_mount("specs", mem_dir.clone()),
5646 Box::new(writer) as Box<dyn MemBackend>,
5647 )])
5648 .unwrap();
5649 engine
5652 .create_entity(
5653 crate::engine::test_helpers::empty_create_args("specs", "Seed"),
5654 crate::vcs::Actor::Cli,
5655 None,
5656 None,
5657 )
5658 .unwrap();
5659
5660 let mut sibling: memstead_schema::MemConfig =
5662 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5663 sibling
5664 .extra
5665 .insert("siblingMark".into(), serde_json::json!("kept"));
5666 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5667
5668 set(&mut engine).unwrap_or_else(|e| panic!("{name} setter failed: {e}"));
5669
5670 let after: memstead_schema::MemConfig =
5671 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5672 assert_eq!(
5673 after.extra.get("siblingMark"),
5674 Some(&serde_json::json!("kept")),
5675 "the {name} setter reverted a field it never set"
5676 );
5677 }
5678 }
5679
5680 #[test]
5683 fn intervention_is_reported_on_the_response_and_only_when_real() {
5684 let tmp = TempDir::new().unwrap();
5685 let mem_dir = tmp.path().to_path_buf();
5686 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5687 std::fs::create_dir_all(&meta).unwrap();
5688 let path = meta.join("config.json");
5689 std::fs::write(&path, br#"{"schema": "default@1.0.0"}"#.as_slice()).unwrap();
5690 let writer = FilesystemMemWriter::new(mem_dir.clone());
5691 let mut engine = Engine::from_mounts(vec![(
5692 folder_mount("specs", mem_dir.clone()),
5693 Box::new(writer) as Box<dyn MemBackend>,
5694 )])
5695 .unwrap();
5696
5697 let quiet = engine
5700 .set_mem_description("specs", Some("first".into()), None)
5701 .unwrap();
5702 assert!(
5703 !quiet
5704 .warnings
5705 .iter()
5706 .any(|w| w.code() == "CONFIG_WRITE_INTERVENED"),
5707 "single-writer workspace must stay silent: {:?}",
5708 quiet.warnings
5709 );
5710
5711 let mut sibling: memstead_schema::MemConfig =
5713 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5714 sibling.title = Some("theirs".into());
5715 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5716
5717 let loud = engine
5718 .set_mem_description("specs", Some("second".into()), None)
5719 .unwrap();
5720 let hint = loud
5721 .warnings
5722 .iter()
5723 .find(|w| w.code() == "CONFIG_WRITE_INTERVENED")
5724 .expect("intervention must be reported on the response");
5725 assert!(
5726 format!("{hint}").contains("title"),
5727 "the report names what they changed: {hint}"
5728 );
5729 let after: memstead_schema::MemConfig =
5731 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5732 assert_eq!(after.title.as_deref(), Some("theirs"));
5733 assert_eq!(after.description.as_deref(), Some("second"));
5734 }
5735}