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