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 pub(crate) fn register_writable_mem_batched(
886 &mut self,
887 mount: Mount,
888 backend: Box<dyn MemBackend>,
889 origin: MemOrigin,
890 ) -> Result<(), EngineError> {
891 self.register_writable_mem_inner(mount, backend, origin, false)
892 }
893
894 pub(crate) fn finish_batched_registrations(&mut self) {
895 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
896 .mounts
897 .iter()
898 .map(|m| (m.mount.mem.clone(), m.mount.capability))
899 .collect();
900 crate::entity::store_builder::validate_loaded_relations(
901 &mut self.store,
902 &self.schemas,
903 &mount_caps,
904 &mut self.load_warnings,
905 );
906 crate::entity::store_builder::remap_alias_target_edge_sources(
907 &mut self.store,
908 &self.schemas,
909 );
910 self.invalidate_communities();
911 self.invalidate_search_indexes();
912 }
913
914 pub fn full_refresh(&mut self) -> crate::ops::FullRefreshReport {
939 let started = std::time::Instant::now();
940 let mut report = crate::ops::FullRefreshReport::default();
941
942 let Some(_root) = self.workspace_root.clone() else {
943 report.failures.push(crate::ops::RefreshFailure {
944 item: "workspace".to_string(),
945 error: "engine has no workspace root (ad-hoc mount-list construction) — \
946 nothing to re-scan"
947 .to_string(),
948 });
949 report.elapsed_ms = started.elapsed().as_millis() as u64;
950 return report;
951 };
952
953 self.refresh_workspace_settings_if_possible();
956
957 self.refresh_schema_sources(&mut report);
959
960 match self.reconcile_roster_forced() {
964 Ok(change) => {
965 report.mems_mounted = change.added;
966 report.mems_unmounted = change.removed;
967 report.mems_quarantined = change.quarantined;
968 report.failures.extend(change.failures);
969 }
970 Err(e) => report.failures.push(crate::ops::RefreshFailure {
971 item: "mount-manifest".to_string(),
972 error: e.to_string(),
973 }),
974 }
975 report.mems_mounted.sort();
976 report.mems_unmounted.sort();
977 report.mems_quarantined.sort();
978
979 report.elapsed_ms = started.elapsed().as_millis() as u64;
980 report
981 }
982
983 pub(crate) fn refresh_schema_sources(&mut self, report: &mut crate::ops::FullRefreshReport) {
986 let Some(root) = self.workspace_root.clone() else {
987 return;
988 };
989 use crate::schema_source::SchemaSource as _;
990 let mut fresh: Vec<std::sync::Arc<memstead_schema::Schema>> = Vec::new();
991 let mut sources_complete = true;
992 match crate::schema_source::FolderSchemaSource::for_workspace(&root).read_schemas() {
993 Ok(mut s) => fresh.append(&mut s),
994 Err(e) => {
995 sources_complete = false;
996 report.failures.push(crate::ops::RefreshFailure {
997 item: "schema-source:folder".to_string(),
998 error: e.to_string(),
999 });
1000 }
1001 }
1002 if let Some(ops) = self.git_branch_ops() {
1003 match (ops.read_ref_schemas)(&root) {
1004 Ok(mut s) => fresh.append(&mut s),
1005 Err(e) => {
1006 sources_complete = false;
1007 report.failures.push(crate::ops::RefreshFailure {
1008 item: "schema-source:memstead-ref".to_string(),
1009 error: e.to_string(),
1010 });
1011 }
1012 }
1013 }
1014 let key = |s: &memstead_schema::Schema| {
1015 let (name, version) = s.id();
1016 format!("{name}@{version}")
1017 };
1018 let existing: std::collections::HashSet<String> =
1019 self.workspace_schemas.iter().map(|s| key(s)).collect();
1020 let fresh_keys: std::collections::HashSet<String> = fresh.iter().map(|s| key(s)).collect();
1021 for schema in fresh {
1022 let k = key(&schema);
1023 if !existing.contains(&k) && !report.schemas_added.contains(&k) {
1024 report.schemas_added.push(k);
1025 self.workspace_schemas.push(schema);
1026 }
1027 }
1028 report.schemas_added.sort();
1029 if sources_complete {
1033 report.schema_removals_skipped = existing
1034 .difference(&fresh_keys)
1035 .cloned()
1036 .collect::<Vec<_>>();
1037 report.schema_removals_skipped.sort();
1038 }
1039 }
1040
1041 pub fn set_workspace_root(&mut self, root: PathBuf) {
1047 self.workspace_root = Some(root);
1048 self.capture_roster_fingerprint();
1049 }
1050
1051 pub fn persist_state(&self) -> Result<(), EngineError> {
1074 let Some(root) = self.workspace_root.as_ref() else {
1075 return Ok(());
1076 };
1077 use crate::workspace_store::WorkspaceStoreAdapter as _;
1078 let store = crate::FileWorkspaceStore::new();
1079 let map = |e: crate::workspace_store::StoreError| {
1080 EngineError::Mem(format!("persist workspace state: {e}"))
1081 };
1082
1083 let ours: Vec<crate::workspace::Mount> = self
1100 .mounts
1101 .iter()
1102 .map(|m| m.mount.clone())
1103 .chain(self.quarantined.iter().map(|q| q.mount.clone()))
1104 .collect();
1105
1106 for attempt in 0..8 {
1107 let expected = store.read_state_bytes(root).map_err(map)?;
1108 let on_disk: Vec<crate::workspace::Mount> = match expected.as_deref() {
1109 Some(bytes) => store.parse_state_bytes(root, bytes).map_err(map)?,
1110 None => Vec::new(),
1111 };
1112
1113 let merged = {
1114 let baseline = self.mounts_baseline.borrow();
1115 merge_mount_rosters(&baseline, &ours, on_disk)
1116 };
1117 let workspace = crate::workspace::Workspace {
1118 mounts: merged,
1119 settings: self.settings.clone(),
1120 };
1121
1122 if store
1123 .save_state_cas(root, &workspace, expected.as_deref())
1124 .map_err(map)?
1125 {
1126 *self.mounts_baseline.borrow_mut() = ours;
1127 return Ok(());
1128 }
1129 if attempt == 7 {
1130 return Err(EngineError::Mem(
1131 "workspace state is being written concurrently: eight compare-and-set \
1132 attempts all lost the race. Retry, or find the writer that is not \
1133 backing off."
1134 .to_string(),
1135 ));
1136 }
1137 }
1138 unreachable!("the loop returns on success and on exhaustion")
1139 }
1140 pub fn set_mem_schema(
1160 &mut self,
1161 mem: &str,
1162 target: &memstead_schema::SchemaRef,
1163 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1164 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1165 if self.quarantine_reason(mem).is_some() {
1171 return self.set_schema_on_quarantined(mem, target);
1172 }
1173 let mount_idx = self
1174 .mounts
1175 .iter()
1176 .position(|m| m.mount.mem == mem)
1177 .ok_or_else(|| self.unknown_mem_error(mem))?;
1178 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1184 return Err(EngineError::ReadOnlyMount(mem.to_string()));
1185 }
1186
1187 let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
1190 let consulted: Vec<_> = self
1193 .workspace_schemas
1194 .iter()
1195 .chain(self.builtin_schemas.iter())
1196 .cloned()
1197 .collect();
1198 EngineError::SchemaNotFound {
1199 mem: mem.to_string(),
1200 pin: target.as_display(),
1201 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1202 &target.name,
1203 &target.version,
1204 &consulted,
1205 ),
1206 install_hint: None,
1207 }
1208 .with_schema_install_probe(self.workspace_root())
1209 })?;
1210
1211 let current_pin = self.mounts[mount_idx].mount.schema.clone();
1216 let current_pin_display = current_pin
1217 .as_ref()
1218 .map(|p| p.as_display())
1219 .unwrap_or_else(|| "<unset>".to_string());
1220 let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1221
1222 let served_pin: Option<memstead_schema::SchemaRef> = self.schemas.get(mem).map(|s| {
1233 let (name, version) = s.id();
1234 memstead_schema::SchemaRef::new(name, version)
1235 });
1236 if served_pin.as_ref() == Some(target) && in_flight.is_none() {
1242 if current_pin.as_ref() == Some(target) {
1243 return Ok(SetSchemaOutcome {
1244 mem: mem.to_string(),
1245 schema_pin: current_pin_display,
1246 migration_target: in_flight.map(|t| t.as_display()),
1247 outcome: SetSchemaResult::Noop,
1248 findings: Vec::new(),
1249 });
1250 }
1251 self.mounts[mount_idx].mount.schema = Some(target.clone());
1252 self.mounts[mount_idx].mount.migration_target = None;
1253 self.persist_state()?;
1254 return Ok(SetSchemaOutcome {
1255 mem: mem.to_string(),
1256 schema_pin: target.as_display(),
1257 migration_target: None,
1258 outcome: SetSchemaResult::Switched,
1259 findings: Vec::new(),
1260 });
1261 }
1262
1263 let findings = crate::ops::integrity::conformance_findings(
1269 &self.store,
1270 mem,
1271 target_schema.as_ref(),
1272 &self.schemas,
1273 );
1274
1275 if findings.is_empty() {
1276 self.persist_mem_schema_pin(mount_idx, target)?;
1284 self.mounts[mount_idx].mount.schema = Some(target.clone());
1285 self.mounts[mount_idx].mount.migration_target = None;
1286 self.schemas_insert(mem.to_string(), target_schema);
1287 self.invalidate_communities();
1288 self.invalidate_search_indexes();
1293 self.persist_state()?;
1294 return Ok(SetSchemaOutcome {
1295 mem: mem.to_string(),
1296 schema_pin: target.as_display(),
1297 migration_target: None,
1298 outcome: SetSchemaResult::Switched,
1299 findings: Vec::new(),
1300 });
1301 }
1302
1303 let outcome = if in_flight.as_ref() == Some(target) {
1304 SetSchemaResult::MigrationPending
1305 } else {
1306 SetSchemaResult::MigrationStarted
1307 };
1308 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1309 self.schemas_insert(mem.to_string(), target_schema);
1312 self.invalidate_communities();
1313 self.invalidate_search_indexes();
1315 self.persist_state()?;
1316 Ok(SetSchemaOutcome {
1317 mem: mem.to_string(),
1318 schema_pin: current_pin_display,
1319 migration_target: Some(target.as_display()),
1320 outcome,
1321 findings,
1322 })
1323 }
1324
1325 pub(crate) fn write_mem_config_merged(
1374 &mut self,
1375 mount_idx: usize,
1376 mem_name: &str,
1377 note: Option<&str>,
1378 apply: &dyn Fn(&mut memstead_schema::config::MemConfig),
1379 ) -> Result<(memstead_schema::config::MemConfig, Vec<String>), EngineError> {
1380 let backend = self.mounts[mount_idx].backend.as_ref();
1381 let read = |b: &dyn crate::backend::MemBackend| -> Result<Vec<u8>, EngineError> {
1382 b.read_mem_config()
1383 .map_err(|e| EngineError::Mem(format!("read mem config for update: {e}")))?
1384 .ok_or_else(|| {
1385 EngineError::InvalidInput(format!(
1386 "mem '{mem_name}' has no stored MemConfig (initialize the mem via \
1387 `memstead init` or `memstead mem create` first)"
1388 ))
1389 })
1390 };
1391
1392 let stored = read(backend)?;
1393 let intervened = match self.mounts[mount_idx].mem_config.as_ref() {
1398 Some(cached) => changed_config_fields(cached, &stored),
1399 None => Vec::new(),
1400 };
1401
1402 let render =
1403 |raw: &[u8]| -> Result<(memstead_schema::config::MemConfig, Vec<u8>), EngineError> {
1404 let value: serde_json::Value = serde_json::from_slice(raw)
1405 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1406 let mut cfg = memstead_schema::config::parse_mem_config(&value)
1407 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1408 apply(&mut cfg);
1409 let mut bytes = serde_json::to_vec_pretty(&cfg)
1410 .map_err(|e| EngineError::Mem(format!("serialize mem config: {e}")))?;
1411 bytes.push(b'\n');
1412 Ok((cfg, bytes))
1413 };
1414 let (mut parsed, mut bytes) = render(&stored)?;
1415
1416 let mut expected = stored;
1425 for attempt in 0..8 {
1426 let wrote = self.mounts[mount_idx].backend.write_mem_config_cas(
1427 Some(&expected),
1428 &bytes,
1429 note,
1430 )?;
1431 if wrote {
1432 break;
1433 }
1434 if attempt == 7 {
1435 return Err(EngineError::Mem(format!(
1436 "mem '{mem_name}' config is being written concurrently: eight \
1437 compare-and-set attempts all lost the race. Retry, or find the \
1438 writer that is not backing off."
1439 )));
1440 }
1441 expected = read(self.mounts[mount_idx].backend.as_ref())?;
1442 let rendered = render(&expected)?;
1443 parsed = rendered.0;
1444 bytes = rendered.1;
1445 }
1446
1447 let mounted = &mut self.mounts[mount_idx];
1448 mounted.mem_config = Some(parsed.clone());
1449 if let Some(sha) = mounted.backend.current_head().ok().flatten() {
1452 mounted.last_known_head = Some(sha);
1453 }
1454 Ok((parsed, intervened))
1455 }
1456
1457 fn persist_mem_schema_pin(
1458 &mut self,
1459 mount_idx: usize,
1460 target: &memstead_schema::SchemaRef,
1461 ) -> Result<(), EngineError> {
1462 let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1463 if let Some(value) = value
1466 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1467 {
1468 self.mounts[mount_idx].mem_config = Some(cfg);
1469 }
1470 Ok(())
1471 }
1472
1473 pub fn export_markdown(
1502 &self,
1503 mem_filter: Option<&str>,
1504 schema_filter: Option<&str>,
1505 ) -> Result<crate::ops::ExportResult, EngineError> {
1506 use crate::workspace::MountStorage;
1507 let fallback = engine_fallback_type();
1508 let supported_backends = vec!["folder".to_string()];
1509
1510 if let Some(name) = mem_filter {
1511 let mount = self
1512 .mounts
1513 .iter()
1514 .find(|m| m.mount.mem == name)
1515 .ok_or_else(|| self.unknown_mem_error(name))?;
1516 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1517 return Err(EngineError::MarkdownExportUnsupportedBackend {
1518 mem: name.to_string(),
1519 active_backend: mount.mount.storage.backend_id().to_string(),
1520 supported_backends,
1521 });
1522 }
1523 }
1524
1525 let mut total_written = 0;
1526 let mut refused: Vec<crate::ops::RefusedEntity> = Vec::new();
1527 let mut total_unchanged = 0;
1528 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1529
1530 for mount in &self.mounts {
1531 let mem_name = mount.mount.mem.as_str();
1532 if let Some(filter) = mem_filter
1533 && mem_name != filter
1534 {
1535 continue;
1536 }
1537 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1538 skipped_mounts.push(crate::ops::SkippedMount {
1539 mem: mem_name.to_string(),
1540 active_backend: mount.mount.storage.backend_id().to_string(),
1541 reason: "backend_does_not_support_markdown_export".to_string(),
1542 });
1543 continue;
1544 };
1545 let schema = match self.schemas.get(mem_name) {
1546 Some(s) => s,
1547 None => continue,
1548 };
1549
1550 for entity in self.store.all_entities() {
1551 if entity.stub || entity.file_path.is_empty() {
1552 continue;
1553 }
1554 if entity.id.mem() != mem_name {
1555 continue;
1556 }
1557 if let Some(filter) = schema_filter
1558 && entity.entity_type != filter
1559 {
1560 continue;
1561 }
1562 let type_def = schema
1563 .get_type(&entity.entity_type)
1564 .unwrap_or_else(|| fallback.clone());
1565 let generated = generate_markdown(entity, type_def.as_ref());
1566
1567 let full_path = mem_dir.join(&entity.file_path);
1568 let needs_write = match std::fs::read_to_string(&full_path) {
1569 Ok(existing) => existing != generated,
1570 Err(_) => true,
1571 };
1572 if needs_write {
1573 match crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref()) {
1574 Ok(_) => total_written += 1,
1575 Err(e @ crate::entity::writer::WriteError::UnterminatedFence { .. }) => {
1580 refused.push(crate::ops::RefusedEntity {
1581 id: entity.id.to_string(),
1582 reason: "UNTERMINATED_FENCE_IN_STORED_BODY".to_string(),
1583 detail: e.to_string(),
1584 });
1585 }
1586 Err(_) => {}
1587 }
1588 } else {
1589 total_unchanged += 1;
1590 }
1591 }
1592 }
1593
1594 Ok(crate::ops::ExportResult {
1595 refused_entities: refused,
1596 written: total_written,
1597 unchanged: total_unchanged,
1598 skipped_mounts,
1599 })
1600 }
1601
1602 pub(crate) fn ref_schema_source_for(
1629 &self,
1630 config: &memstead_schema::MemConfig,
1631 ) -> Option<Vec<memstead_schema::SchemaSourceFile>> {
1632 let ops = self.git_branch_ops.as_ref()?;
1633 let root = self.workspace_root.as_deref()?;
1634 let pin = config.schema.as_ref()?;
1635 (ops.collect_ref_schema_source)(root, pin).ok().flatten()
1636 }
1637
1638 pub fn export_mem(
1639 &self,
1640 mem_name: &str,
1641 output_path: &std::path::Path,
1642 ) -> Result<crate::ops::MemExportResult, EngineError> {
1643 let mount = self
1644 .mounts
1645 .iter()
1646 .find(|m| m.mount.mem == mem_name)
1647 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1648 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1649 EngineError::InvalidInput(format!(
1650 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1651 ))
1652 })?;
1653 if config.version.is_none() {
1661 return Err(EngineError::MemConfigIncomplete {
1662 mem: mem_name.to_string(),
1663 missing_fields: vec!["version".to_string()],
1664 });
1665 }
1666 let fenced: Vec<String> = self
1672 .store
1673 .all_entities()
1674 .filter(|e| e.mem == mem_name && !e.stub)
1675 .filter(|e| {
1676 e.sections
1677 .values()
1678 .any(|v| crate::markdown::closing_fence_if_unterminated(v.trim()).is_some())
1679 })
1680 .map(|e| e.id.to_string())
1681 .collect();
1682 let workspace_root = self.workspace_root.as_deref();
1683 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1687 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1688 let exported = match &mount.mount.storage {
1689 MountStorage::Folder { path } => crate::ops::export::export_mem(
1690 path,
1691 config,
1692 output_path,
1693 workspace_root,
1694 workspace_schemas_dir,
1695 self.ref_schema_source_for(config),
1696 )
1697 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1698 MountStorage::GitBranch { gitdir, branch } => {
1699 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1700 EngineError::Backend(BackendError::Other(
1701 "git-branch export hook not installed (full flavour not loaded)"
1702 .to_string(),
1703 ))
1704 })?;
1705 let (provenance, redactions) = mount
1709 .backend
1710 .read_provenance(None)
1711 .ok()
1712 .map(|records| crate::ops::export::build_redacted_archive_provenance(&records))
1713 .unwrap_or((None, Vec::new()));
1714 let provenance_bytes = provenance.and_then(|prov| prov.to_archive_bytes().ok());
1715 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1718 (hook.export)(
1719 gitdir,
1720 branch,
1721 mem_name,
1722 config,
1723 output_path,
1724 workspace_root,
1725 workspace_schemas_dir,
1726 provenance_bytes.as_deref(),
1727 anchors_bytes.as_deref(),
1728 )
1729 .map(|mut r| {
1730 r.redactions = redactions;
1731 r
1732 })
1733 .map_err(EngineError::Backend)
1734 }
1735 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1736 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1741 "export not yet supported for in-memory backend".to_string(),
1742 ))),
1743 };
1744 exported.map(|mut r| {
1745 r.unterminated_fence_entities = fenced;
1746 r
1747 })
1748 }
1749
1750 pub fn record_pipeline_edit_provenance(
1774 &self,
1775 mem: &str,
1776 kind: &str,
1777 edits: &[(String, Option<Vec<u8>>)],
1778 note: Option<&str>,
1779 verb: &str,
1780 ) -> Result<(), crate::backend::BackendError> {
1781 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1782 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1783 None => Ok(()),
1784 }
1785 }
1786
1787 pub fn set_mem_version(
1788 &mut self,
1789 mem_name: &str,
1790 new_version: semver::Version,
1791 note: Option<&str>,
1792 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1793 let mount_idx = self
1796 .mounts
1797 .iter()
1798 .position(|m| m.mount.mem == mem_name)
1799 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1800 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1801 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1802 }
1803
1804 let mut warnings = self.reload_if_stale(Some(mem_name));
1812 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1817 warnings.push(w);
1818 }
1819
1820 let old_version = self.mounts[mount_idx]
1824 .mem_config
1825 .as_ref()
1826 .and_then(|c| c.version.clone());
1827 let target = new_version.clone();
1828 let (_, intervened) = self.write_mem_config_merged(
1829 mount_idx,
1830 mem_name,
1831 note,
1832 &move |c: &mut memstead_schema::config::MemConfig| {
1833 c.version = Some(target.clone());
1834 },
1835 )?;
1836 if !intervened.is_empty() {
1837 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1838 mem: mem_name.to_string(),
1839 fields: intervened,
1840 });
1841 }
1842
1843 Ok(crate::ops::SetMemVersionOutcome {
1844 mem: mem_name.to_string(),
1845 old_version,
1846 new_version,
1847 warnings,
1848 })
1849 }
1850
1851 pub fn set_mem_description(
1858 &mut self,
1859 mem_name: &str,
1860 new_description: Option<String>,
1861 note: Option<&str>,
1862 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1863 let mount_idx = self
1864 .mounts
1865 .iter()
1866 .position(|m| m.mount.mem == mem_name)
1867 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1868 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1869 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1870 }
1871
1872 let mut warnings = self.reload_if_stale(Some(mem_name));
1873 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1874 warnings.push(w);
1875 }
1876
1877 let old_description = self.mounts[mount_idx]
1878 .mem_config
1879 .as_ref()
1880 .and_then(|c| c.description.clone());
1881 let target = new_description.clone();
1882 let (_, intervened) = self.write_mem_config_merged(
1883 mount_idx,
1884 mem_name,
1885 note,
1886 &move |c: &mut memstead_schema::config::MemConfig| {
1887 c.description = target.clone();
1888 },
1889 )?;
1890 if !intervened.is_empty() {
1891 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1892 mem: mem_name.to_string(),
1893 fields: intervened,
1894 });
1895 }
1896
1897 Ok(crate::ops::SetMemDescriptionOutcome {
1898 mem: mem_name.to_string(),
1899 old_description,
1900 new_description,
1901 warnings,
1902 })
1903 }
1904
1905 pub fn set_mem_title(
1910 &mut self,
1911 mem_name: &str,
1912 new_title: Option<String>,
1913 note: Option<&str>,
1914 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1915 let mount_idx = self
1916 .mounts
1917 .iter()
1918 .position(|m| m.mount.mem == mem_name)
1919 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1920 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1921 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1922 }
1923
1924 let mut warnings = self.reload_if_stale(Some(mem_name));
1925 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1926 warnings.push(w);
1927 }
1928
1929 let old_title = self.mounts[mount_idx]
1930 .mem_config
1931 .as_ref()
1932 .and_then(|c| c.title.clone());
1933 let target = new_title.clone();
1934 let (_, intervened) = self.write_mem_config_merged(
1935 mount_idx,
1936 mem_name,
1937 note,
1938 &move |c: &mut memstead_schema::config::MemConfig| {
1939 c.title = target.clone();
1940 },
1941 )?;
1942 if !intervened.is_empty() {
1943 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1944 mem: mem_name.to_string(),
1945 fields: intervened,
1946 });
1947 }
1948
1949 Ok(crate::ops::SetMemTitleOutcome {
1950 mem: mem_name.to_string(),
1951 old_title,
1952 new_title,
1953 warnings,
1954 })
1955 }
1956
1957 pub fn set_mem_subject(
1961 &mut self,
1962 mem_name: &str,
1963 new_subject: Option<memstead_schema::MemSubject>,
1964 note: Option<&str>,
1965 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1966 let mount_idx = self
1967 .mounts
1968 .iter()
1969 .position(|m| m.mount.mem == mem_name)
1970 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1971 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1972 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1973 }
1974
1975 let mut warnings = self.reload_if_stale(Some(mem_name));
1976 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
1977 warnings.push(w);
1978 }
1979
1980 let old_subject = self.mounts[mount_idx]
1981 .mem_config
1982 .as_ref()
1983 .and_then(|c| c.subject.clone());
1984 let target = new_subject.clone();
1985 let (_, intervened) = self.write_mem_config_merged(
1986 mount_idx,
1987 mem_name,
1988 note,
1989 &move |c: &mut memstead_schema::config::MemConfig| {
1990 c.subject = target.clone();
1991 },
1992 )?;
1993 if !intervened.is_empty() {
1994 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1995 mem: mem_name.to_string(),
1996 fields: intervened,
1997 });
1998 }
1999
2000 Ok(crate::ops::SetMemSubjectOutcome {
2001 mem: mem_name.to_string(),
2002 old_subject,
2003 new_subject,
2004 warnings,
2005 })
2006 }
2007
2008 pub fn set_mem_internal(
2020 &mut self,
2021 mem_name: &str,
2022 internal: bool,
2023 note: Option<&str>,
2024 ) -> Result<crate::ops::SetMemInternalOutcome, EngineError> {
2025 let mount_idx = self
2026 .mounts
2027 .iter()
2028 .position(|m| m.mount.mem == mem_name)
2029 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2030 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2031 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2032 }
2033
2034 let _ = self.reload_if_stale(Some(mem_name));
2035
2036 let (_, intervened) = self.write_mem_config_merged(
2037 mount_idx,
2038 mem_name,
2039 note,
2040 &move |c: &mut memstead_schema::config::MemConfig| {
2041 if internal {
2042 c.extra
2043 .insert("internal".to_string(), serde_json::Value::Bool(true));
2044 } else {
2045 c.extra.remove("internal");
2046 }
2047 },
2048 )?;
2049 let mut warnings = Vec::new();
2054 if !intervened.is_empty() {
2055 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2056 mem: mem_name.to_string(),
2057 fields: intervened,
2058 });
2059 }
2060
2061 Ok(crate::ops::SetMemInternalOutcome {
2062 mem: mem_name.to_string(),
2063 internal,
2064 warnings,
2065 })
2066 }
2067
2068 pub fn set_mem_sync_state(
2093 &mut self,
2094 mem_name: &str,
2095 key: &str,
2096 token: &str,
2097 note: Option<&str>,
2098 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
2099 let mount_idx = self
2102 .mounts
2103 .iter()
2104 .position(|m| m.mount.mem == mem_name)
2105 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2106 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2107 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2108 }
2109
2110 let mut warnings = self.reload_if_stale(Some(mem_name));
2114 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
2115 warnings.push(w);
2116 }
2117
2118 let seen: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
2125 let key_owned = key.to_string();
2126 let token_owned = token.to_string();
2127 let (_, intervened) = self.write_mem_config_merged(
2128 mount_idx,
2129 mem_name,
2130 note,
2131 &|c: &mut memstead_schema::config::MemConfig| {
2132 *seen.borrow_mut() = if token_owned.is_empty() {
2133 c.sync_state.remove(&key_owned)
2134 } else {
2135 c.sync_state.insert(key_owned.clone(), token_owned.clone())
2136 };
2137 },
2138 )?;
2139 let previous = seen.into_inner();
2140 let removed = token.is_empty() && previous.is_some();
2143 if !intervened.is_empty() {
2144 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2145 mem: mem_name.to_string(),
2146 fields: intervened,
2147 });
2148 }
2149
2150 Ok(crate::ops::SetMemSyncStateOutcome {
2151 mem: mem_name.to_string(),
2152 key: key.to_string(),
2153 previous,
2154 removed,
2155 warnings,
2156 })
2157 }
2158
2159 pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
2206 let pending: Vec<String> = self
2207 .mounts
2208 .iter()
2209 .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
2210 .map(|m| m.mount.mem.clone())
2211 .collect();
2212 for name in pending {
2213 match self.reload_one_mem(&name) {
2214 Ok(_) => {
2215 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
2216 state.deferred = false;
2217 }
2218 let store = &self.store;
2224 self.load_warnings.retain(|w| match w {
2225 crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
2226 store.get(resolved_id).is_none_or(|e| e.stub)
2227 }
2228 _ => true,
2229 });
2230 }
2231 Err(e) => {
2232 let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
2237 continue;
2238 };
2239 let removed = self.mounts.remove(idx);
2240 self.schemas_remove(&name);
2241 self.quarantined.push(crate::engine::QuarantinedMem {
2242 mount: removed.mount,
2243 reason_code: e.code().to_string(),
2244 reason_message: e.to_string(),
2245 });
2246 self.mem_router = std::sync::Arc::new(
2247 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2248 );
2249 self.invalidate_communities();
2250 self.invalidate_search_indexes();
2256 }
2257 }
2258 }
2259 }
2260
2261 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2262 if self.quarantine_reason(mem).is_some() {
2285 return self.reattach_quarantined_mem(mem);
2286 }
2287 let mut sink: Vec<WarningHint> = Vec::new();
2288 let result = self.reload_one_mem_inner(mem, &mut sink)?;
2289 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2290 self.load_warnings
2291 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2292 Ok(result)
2293 }
2294
2295 fn set_schema_on_quarantined(
2305 &mut self,
2306 mem: &str,
2307 target: &memstead_schema::SchemaRef,
2308 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2309 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2310 if self.resolve_schema_by_ref(target).is_none() {
2312 let consulted: Vec<_> = self
2313 .workspace_schemas
2314 .iter()
2315 .chain(self.builtin_schemas.iter())
2316 .cloned()
2317 .collect();
2318 return Err(EngineError::SchemaNotFound {
2319 mem: mem.to_string(),
2320 pin: target.as_display(),
2321 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2322 &target.name,
2323 &target.version,
2324 &consulted,
2325 ),
2326 install_hint: None,
2327 }
2328 .with_schema_install_probe(self.workspace_root()));
2329 }
2330 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2331 return Err(self.unknown_mem_error(mem));
2332 };
2333 self.quarantined[q_idx].mount.schema = Some(target.clone());
2337 self.quarantined[q_idx].mount.migration_target = None;
2338 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2339 let _ = bump_backend_schema_pin(backend.as_ref(), target);
2340 }
2341 self.persist_state()?;
2342 let _ = self.reattach_quarantined_mem(mem);
2347 Ok(SetSchemaOutcome {
2348 mem: mem.to_string(),
2349 schema_pin: target.as_display(),
2350 migration_target: None,
2351 outcome: SetSchemaResult::Switched,
2352 findings: Vec::new(),
2353 })
2354 }
2355
2356 fn reattach_quarantined_mem(
2364 &mut self,
2365 mem: &str,
2366 ) -> Result<crate::ops::ReloadResult, EngineError> {
2367 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2368 return Err(self.unknown_mem_error(mem));
2369 };
2370 let mount = self.quarantined[q_idx].mount.clone();
2371
2372 let requarantine = |this: &mut Self, e: &EngineError| {
2373 this.quarantined[q_idx].reason_code = e.code().to_string();
2374 this.quarantined[q_idx].reason_message = e.to_string();
2375 };
2376
2377 let backend = match (self.backend_factory)(&mount) {
2378 Ok(b) => b,
2379 Err(e) => {
2380 let err = EngineError::Mem(e.to_string());
2381 requarantine(self, &err);
2382 return Err(self.unknown_mem_error(mem));
2383 }
2384 };
2385
2386 let last_known_head = backend.current_head().ok().flatten();
2388 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2389 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2390 memstead_schema::config::parse_mem_config(&value).ok()
2391 });
2392 let archive_provenance = backend
2393 .read_archive_provenance()
2394 .ok()
2395 .flatten()
2396 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2397 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2398 let effective_pin = mount
2399 .migration_target
2400 .clone()
2401 .or(config_pin)
2402 .or(mount.schema.clone());
2403 let Some(effective_pin) = effective_pin else {
2404 let err = EngineError::MemConfigIncomplete {
2405 mem: mem.to_string(),
2406 missing_fields: vec!["schema".to_string()],
2407 };
2408 requarantine(self, &err);
2409 return Err(self.unknown_mem_error(mem));
2410 };
2411 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2412 .workspace_schemas
2413 .iter()
2414 .chain(self.builtin_schemas.iter())
2415 .cloned()
2416 .collect();
2417 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2418 Ok(s) => s,
2419 Err(sources) => {
2420 let err = EngineError::SchemaNotFound {
2421 mem: mem.to_string(),
2422 pin: effective_pin.as_display(),
2423 sources,
2424 install_hint: None,
2425 }
2426 .with_schema_install_probe(self.workspace_root());
2427 requarantine(self, &err);
2428 return Err(self.unknown_mem_error(mem));
2429 }
2430 };
2431
2432 self.quarantined.remove(q_idx);
2436 self.schemas_insert(mem.to_string(), schema);
2437 self.mounts.push(crate::engine::MountedBackend {
2438 mount,
2439 backend,
2440 last_known_head,
2441 mem_config,
2442 archive_provenance,
2443 deferred: false,
2446 });
2447 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2448 &self.mounts,
2449 ));
2450 let mut sink: Vec<WarningHint> = Vec::new();
2451 match self.reload_one_mem_inner(mem, &mut sink) {
2452 Ok(result) => {
2453 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2454 self.load_warnings
2455 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2456 self.invalidate_communities();
2457 Ok(result)
2458 }
2459 Err(e) => {
2460 let mount_idx = self.mounts.len() - 1;
2461 let mounted = self.mounts.remove(mount_idx);
2462 self.schemas_remove(mem);
2463 self.mem_router = std::sync::Arc::new(
2464 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2465 );
2466 self.quarantined.push(crate::engine::QuarantinedMem {
2467 mount: mounted.mount,
2468 reason_code: e.code().to_string(),
2469 reason_message: e.to_string(),
2470 });
2471 self.invalidate_communities();
2475 self.invalidate_search_indexes();
2476 Err(e)
2477 }
2478 }
2479 }
2480
2481 fn reload_one_mem_inner(
2487 &mut self,
2488 mem: &str,
2489 warnings_sink: &mut Vec<WarningHint>,
2490 ) -> Result<crate::ops::ReloadResult, EngineError> {
2491 let mount_idx = self
2494 .mounts
2495 .iter()
2496 .position(|m| m.mount.mem == mem)
2497 .ok_or_else(|| self.unknown_mem_error(mem))?;
2498 let schema = self
2499 .schemas
2500 .get(mem)
2501 .cloned()
2502 .ok_or_else(|| self.unknown_mem_error(mem))?;
2503
2504 let pre: HashMap<EntityId, String> = self
2506 .store
2507 .all_entities()
2508 .filter(|e| !e.stub && e.mem == mem)
2509 .map(|e| (e.id.clone(), e.content_hash.clone()))
2510 .collect();
2511 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2512
2513 let backend = self.mounts[mount_idx].backend.as_ref();
2516 let (entries, read_errors) = collect_source_entries(backend)?;
2517 let unbacked = super::boot::unbacked_mount_warning(
2522 &self.mounts[mount_idx].mount,
2523 backend,
2524 Some(entries.len()),
2525 );
2526 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2527
2528 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2532 let known_suffixes: Vec<String> = mem_names
2533 .iter()
2534 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2535 .collect();
2536
2537 self.store.remove_entities_by_mem(mem);
2539 if let Some(w) = unbacked {
2540 warnings_sink.push(w);
2541 }
2542 let fallback = engine_fallback_type();
2543 push_entities_into_store(
2544 &mut self.store,
2545 load_result.entities,
2546 fallback.as_ref(),
2547 Some(crate::entity::store_builder::LoadCollector {
2548 warnings: warnings_sink,
2549 known_suffixes: &known_suffixes,
2550 mem_names: &mem_names,
2551 }),
2552 );
2553 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2560 .mounts
2561 .iter()
2562 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2563 .collect();
2564 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2572 crate::entity::store_builder::validate_loaded_relations(
2573 &mut self.store,
2574 &self.schemas,
2575 &mount_caps,
2576 warnings_sink,
2577 );
2578 crate::entity::store_builder::remap_alias_target_edge_sources(
2579 &mut self.store,
2580 &self.schemas,
2581 );
2582 if let crate::workspace::MountStorage::Folder { path } =
2592 &self.mounts[mount_idx].mount.storage
2593 {
2594 let root = path.clone();
2595 self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2596 self.load_errors
2600 .extend(load_result.errors.into_iter().map(|(p, m)| {
2601 let abs = if p.is_relative() { root.join(&p) } else { p };
2602 (abs, m)
2603 }));
2604 } else {
2605 self.load_errors.extend(load_result.errors);
2606 }
2607
2608 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2616 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2617 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2618 {
2619 self.mounts[mount_idx].mem_config = Some(cfg);
2620 }
2621
2622 let mut added: Vec<EntityId> = Vec::new();
2624 let mut changed: Vec<EntityId> = Vec::new();
2625 for entity in self.store.all_entities() {
2626 if entity.stub || entity.mem != mem {
2627 continue;
2628 }
2629 match pre.get(&entity.id) {
2630 None => added.push(entity.id.clone()),
2631 Some(prev_hash) if prev_hash != &entity.content_hash => {
2632 changed.push(entity.id.clone());
2633 }
2634 Some(_) => {}
2635 }
2636 }
2637 let post_ids: std::collections::HashSet<EntityId> = self
2638 .store
2639 .all_entities()
2640 .filter(|e| !e.stub && e.mem == mem)
2641 .map(|e| e.id.clone())
2642 .collect();
2643 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2644 added.sort_by(|a, b| a.0.cmp(&b.0));
2645 changed.sort_by(|a, b| a.0.cmp(&b.0));
2646 removed.sort_by(|a, b| a.0.cmp(&b.0));
2647
2648 self.invalidate_communities();
2649 self.invalidate_search_indexes();
2650
2651 Ok(crate::ops::ReloadResult {
2652 added,
2653 changed,
2654 removed,
2655 })
2656 }
2657
2658 pub fn reload_one_mem_report(
2686 &mut self,
2687 mem: &str,
2688 ) -> Result<crate::ops::ReloadReport, EngineError> {
2689 let tracks_head = self
2700 .mounts
2701 .iter()
2702 .find(|m| m.mount.mem == mem)
2703 .and_then(|m| m.backend.current_head().ok().flatten())
2704 .is_some();
2705 let head_before = if tracks_head {
2706 self.mounts
2707 .iter()
2708 .find(|m| m.mount.mem == mem)
2709 .and_then(|m| m.last_known_head.clone())
2710 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2711 } else {
2712 crate::ops::EMPTY_TREE_SHA.to_string()
2713 };
2714
2715 let result = self.reload_one_mem(mem)?;
2716
2717 let head_after_raw = self
2724 .mounts
2725 .iter()
2726 .find(|m| m.mount.mem == mem)
2727 .and_then(|m| m.backend.current_head().ok().flatten());
2728 if let Some(new_head) = head_after_raw.clone()
2729 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2730 {
2731 m.last_known_head = Some(new_head);
2732 }
2733 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2734
2735 let entities_loaded = self
2736 .store
2737 .all_entities()
2738 .filter(|e| !e.stub && e.mem == mem)
2739 .count();
2740
2741 let mut changed_entity_ids: Vec<EntityId> = result
2746 .added
2747 .into_iter()
2748 .chain(result.changed)
2749 .chain(result.removed)
2750 .collect();
2751 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2752
2753 Ok(crate::ops::ReloadReport {
2754 mem: mem.to_string(),
2755 head_before,
2756 head_after,
2757 entities_loaded,
2758 changed_entity_ids,
2759 })
2760 }
2761
2762 pub fn reload_each_writable_mem_reports(
2802 &mut self,
2803 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2804 self.refresh_workspace_settings_if_possible();
2805 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2806 let mut out = Vec::with_capacity(names.len());
2807 for name in names {
2808 let report = self.reload_one_mem_report(&name)?;
2809 out.push(report);
2810 }
2811 Ok(out)
2812 }
2813
2814 fn refresh_workspace_settings_if_possible(&mut self) {
2826 let Some(root) = self.workspace_root.clone() else {
2827 return;
2828 };
2829 let store = crate::workspace_store::FileWorkspaceStore::new();
2830 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2831 Ok(w) => w,
2832 Err(_) => return,
2833 };
2834 self.set_settings(workspace.settings);
2835 }
2836
2837 pub fn reload_each_writable_mem(
2853 &mut self,
2854 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2855 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2856 let mut sink = std::mem::take(&mut self.load_warnings);
2862 sink.clear();
2863 let mut out = Vec::with_capacity(names.len());
2864 let mut loop_err = None;
2865 for name in names {
2866 match self.reload_one_mem_inner(&name, &mut sink) {
2867 Ok(result) => out.push((name, result)),
2868 Err(e) => {
2869 loop_err = Some(e);
2870 break;
2871 }
2872 }
2873 }
2874 self.load_warnings = sink;
2875 if let Some(e) = loop_err {
2876 return Err(e);
2877 }
2878 Ok(out)
2879 }
2880}
2881
2882fn changed_config_fields(
2901 cached: &memstead_schema::config::MemConfig,
2902 stored_bytes: &[u8],
2903) -> Vec<String> {
2904 let (Ok(a), Ok(b)) = (
2905 serde_json::to_value(cached),
2906 serde_json::from_slice::<serde_json::Value>(stored_bytes),
2907 ) else {
2908 return Vec::new();
2909 };
2910 let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
2911 return Vec::new();
2912 };
2913 let mut keys: std::collections::BTreeSet<&String> = a.keys().collect();
2914 keys.extend(b.keys());
2915 keys.into_iter()
2916 .filter(|k| a.get(*k) != b.get(*k))
2917 .map(|k| k.to_string())
2918 .collect()
2919}
2920
2921pub fn bump_backend_schema_pin(
2922 backend: &dyn crate::backend::MemBackend,
2923 target: &memstead_schema::SchemaRef,
2924) -> Result<Option<serde_json::Value>, EngineError> {
2925 let Some(bytes) = backend
2926 .read_mem_config()
2927 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2928 else {
2929 return Ok(None);
2930 };
2931 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2932 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2933 value["schema"] = serde_json::Value::String(target.as_display());
2934 let new_bytes = serde_json::to_vec_pretty(&value)
2935 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2936 backend
2937 .write_mem_config(&new_bytes)
2938 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2939 Ok(Some(value))
2940}
2941
2942fn merge_mount_rosters(
2956 baseline: &[crate::workspace::Mount],
2957 ours: &[crate::workspace::Mount],
2958 on_disk: Vec<crate::workspace::Mount>,
2959) -> Vec<crate::workspace::Mount> {
2960 use std::collections::{HashMap, HashSet};
2961
2962 let ours_names: HashSet<&str> = ours.iter().map(|m| m.mem.as_str()).collect();
2963 let removed_by_us: HashSet<&str> = baseline
2964 .iter()
2965 .map(|m| m.mem.as_str())
2966 .filter(|n| !ours_names.contains(n))
2967 .collect();
2968 let baseline_by_name: HashMap<&str, &crate::workspace::Mount> =
2969 baseline.iter().map(|m| (m.mem.as_str(), m)).collect();
2970
2971 let mut merged: Vec<crate::workspace::Mount> = on_disk
2972 .into_iter()
2973 .filter(|m| !removed_by_us.contains(m.mem.as_str()))
2974 .collect();
2975
2976 for mount in ours {
2977 let untouched_by_us = baseline_by_name
2978 .get(mount.mem.as_str())
2979 .is_some_and(|b| *b == mount);
2980 match merged.iter_mut().find(|d| d.mem == mount.mem) {
2981 Some(slot) => {
2982 if !untouched_by_us {
2983 *slot = mount.clone();
2984 }
2985 }
2986 None => merged.push(mount.clone()),
2987 }
2988 }
2989 merged
2990}
2991
2992#[cfg(test)]
2993mod tests {
2994
2995 use tempfile::TempDir;
2996
2997 use crate::backend::{BackendError, MemBackend};
2998 use crate::engine::test_helpers::*;
2999 use crate::engine::{Engine, EngineError};
3000 use crate::mem::MemOrigin;
3001 use crate::ops::WarningHint;
3002 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
3003
3004 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
3005 let manifest = format!(
3006 r#"name: {manifest_name}
3007version: 1.0.0
3008description: Install-gate test schema
3009when_to_use: Tests
3010types:
3011 - sample
3012relationships:
3013 mode: strict
3014 definitions:
3015 - name: PART_OF
3016 description: hier
3017 default_weight: 3.0
3018 - name: _default
3019 description: fallback
3020 default_weight: 1.0
3021community:
3022 resolution: 1.0
3023 seed: 42
3024"#
3025 );
3026 let type_yaml = format!(
3027 r#"name: sample
3028description: t
3029when_to_use: tests
3030sections:
3031 - key: body
3032 heading: {heading}
3033 required: true
3034 search_weight: 10.0
3035 catch_all: true
3036 write_rules: []
3037metadata_fields: []
3038title_weight: 100.0
3039text_fields:
3040 - body
3041hierarchy_relationship: PART_OF
3042no_self_loop_relationships: []
3043updatable_fields:
3044 - title
3045 - body
3046health_required_fields:
3047 - body
3048staleness_threshold_days: 90
3049write_rules: []
3050"#
3051 );
3052 vec![
3053 ("schema.yaml".to_string(), manifest.into_bytes()),
3054 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3055 ]
3056 }
3057
3058 #[test]
3062 fn install_gate_refuses_non_roundtrip_heading() {
3063 let ok =
3064 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
3065 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
3066
3067 let err = Engine::validate_schema_package(
3068 "gate",
3069 "1.0.0",
3070 &schema_package_files("Body Text", "gate"),
3071 )
3072 .expect_err("non-deriving heading must refuse install");
3073 match &err {
3074 EngineError::SchemaPackageInvalid { name, message, .. } => {
3075 assert_eq!(name, "gate");
3076 assert!(
3077 message.contains("'body'") && message.contains("'Body Text'"),
3078 "message names the offending tuple: {message}"
3079 );
3080 }
3081 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3082 }
3083 }
3084
3085 fn exemplar_package_files(
3092 section_key: &str,
3093 status_value: &str,
3094 rel_type: &str,
3095 ) -> Vec<(String, Vec<u8>)> {
3096 let manifest = r#"name: gate
3097version: 1.0.0
3098description: exemplar gate fixture
3099when_to_use: tests
3100types:
3101 - sample
3102 - other
3103relationships:
3104 mode: strict
3105 definitions:
3106 - name: PART_OF
3107 description: hier
3108 default_weight: 3.0
3109 - name: REFINES
3110 description: pinned
3111 default_weight: 1.0
3112 source_types: [other]
3113 - name: _default
3114 description: fallback
3115 default_weight: 1.0
3116community:
3117 resolution: 1.0
3118 seed: 42
3119"#
3120 .to_string();
3121 let type_yaml = format!(
3122 r#"name: sample
3123description: t
3124when_to_use: tests
3125sections:
3126 - key: body
3127 heading: Body
3128 required: true
3129 search_weight: 10.0
3130 catch_all: true
3131 write_rules: []
3132metadata_fields:
3133 - key: status
3134 description: workflow state
3135 field_type: string
3136 enum_values: [draft, final]
3137title_weight: 100.0
3138text_fields:
3139 - body
3140hierarchy_relationship: PART_OF
3141no_self_loop_relationships: []
3142updatable_fields:
3143 - title
3144 - body
3145health_required_fields:
3146 - body
3147staleness_threshold_days: 90
3148write_rules: []
3149exemplar:
3150 title: A Conforming Sample
3151 metadata:
3152 status: "{status_value}"
3153 sections:
3154 {section_key}: "One canonical body paragraph."
3155 relations:
3156 - to: parent-placeholder
3157 type: {rel_type}
3158"#
3159 );
3160 let other_yaml = r#"name: other
3161description: shape-pin partner
3162when_to_use: tests
3163sections:
3164 - key: body
3165 heading: Body
3166 required: true
3167 search_weight: 10.0
3168 catch_all: true
3169 write_rules: []
3170metadata_fields: []
3171title_weight: 100.0
3172text_fields:
3173 - body
3174hierarchy_relationship: PART_OF
3175no_self_loop_relationships: []
3176updatable_fields:
3177 - title
3178 - body
3179health_required_fields:
3180 - body
3181staleness_threshold_days: 90
3182write_rules: []
3183"#
3184 .to_string();
3185 vec![
3186 ("schema.yaml".to_string(), manifest.into_bytes()),
3187 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3188 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
3189 ]
3190 }
3191
3192 #[test]
3199 fn install_gate_validates_exemplars_through_the_real_create_path() {
3200 let ok = Engine::validate_schema_package(
3202 "gate",
3203 "1.0.0",
3204 &exemplar_package_files("body", "draft", "PART_OF"),
3205 );
3206 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
3207
3208 let err = Engine::validate_schema_package(
3210 "gate",
3211 "1.0.0",
3212 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
3213 )
3214 .expect_err("wrong section key must refuse");
3215 match &err {
3216 EngineError::SchemaPackageInvalid { message, .. } => {
3217 assert!(
3218 message.contains("'sample'") && message.contains("exemplar"),
3219 "names type and calls out the exemplar: {message}"
3220 );
3221 assert!(
3222 message.contains("UNKNOWN_SECTION")
3223 || message.contains("MISSING_REQUIRED_SECTION"),
3224 "carries the typed defect code: {message}"
3225 );
3226 }
3227 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3228 }
3229
3230 let err = Engine::validate_schema_package(
3232 "gate",
3233 "1.0.0",
3234 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
3235 )
3236 .expect_err("illegal enum value must refuse");
3237 assert!(
3238 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3239 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
3240 "got {err:?}"
3241 );
3242
3243 let err = Engine::validate_schema_package(
3246 "gate",
3247 "1.0.0",
3248 &exemplar_package_files("body", "draft", "REFINES"),
3249 )
3250 .expect_err("relationship shape violation must refuse");
3251 assert!(
3252 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3253 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
3254 "got {err:?}"
3255 );
3256 }
3257
3258 #[test]
3263 fn worked_example_package_exemplars_validate() {
3264 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3265 .join("../memstead-schema/examples/minimal");
3266 let schema = std::sync::Arc::new(
3267 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
3268 );
3269 assert!(
3270 schema.types.values().all(|td| td.exemplar.is_some()),
3271 "every worked-example type models the exemplar practice"
3272 );
3273 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
3274 }
3275
3276 #[test]
3283 fn builtin_exemplars_validate_through_the_install_gate() {
3284 let schemas = memstead_schema::builtins::load_builtin_schemas()
3285 .expect("built-in schemas always load");
3286 for schema in &schemas {
3288 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
3289 let (name, version) = schema.id();
3290 panic!("built-in {name}@{version}: {defect}");
3291 }
3292 }
3293 let mut newest: std::collections::HashMap<
3295 String,
3296 &std::sync::Arc<memstead_schema::Schema>,
3297 > = std::collections::HashMap::new();
3298 for schema in &schemas {
3299 let name = schema.manifest.name.clone();
3300 match newest.get(&name) {
3301 Some(cur) if cur.version >= schema.version => {}
3302 _ => {
3303 newest.insert(name, schema);
3304 }
3305 }
3306 }
3307 for (name, schema) in &newest {
3308 for (type_name, td) in &schema.types {
3309 assert!(
3310 td.exemplar.is_some(),
3311 "built-in {name}@{} type '{type_name}' has no exemplar — the \
3312 reference schemas model the practice completely",
3313 schema.version
3314 );
3315 }
3316 }
3317 }
3318
3319 #[test]
3324 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
3325 let mut files = exemplar_package_files("body", "draft", "PART_OF");
3326 let patched = String::from_utf8(files[1].1.clone())
3327 .unwrap()
3328 .replace("to: parent-placeholder", "to: other--real-entity");
3329 files[1].1 = patched.into_bytes();
3330 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3331 .expect_err("mem-prefixed exemplar target must refuse");
3332 assert!(
3333 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3334 if message.contains("bare") && message.contains("'sample'")),
3335 "got {err:?}"
3336 );
3337 }
3338
3339 #[test]
3343 fn install_gate_refuses_manifest_identity_mismatch() {
3344 let err = Engine::validate_schema_package(
3345 "gate",
3346 "1.0.0",
3347 &schema_package_files("Body", "other"),
3348 )
3349 .expect_err("identity mismatch must refuse install");
3350 assert!(
3351 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3352 if message.contains("other@1.0.0")),
3353 "got {err:?}"
3354 );
3355 }
3356
3357 #[test]
3358 fn reload_each_writable_mem_repopulates_load_warnings() {
3359 let tmp = TempDir::new().unwrap();
3363 let mem_dir = tmp.path().to_path_buf();
3364 let writer = FilesystemMemWriter::new(mem_dir.clone());
3365 let mut mount = folder_mount("specs", mem_dir.clone());
3368 mount.schema = Some("default@1.3.0".parse().unwrap());
3369 let mut engine =
3370 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3371 assert!(
3374 engine
3375 .load_warnings()
3376 .iter()
3377 .all(|w| w.code() == "MOUNT_UNBACKED"),
3378 "clean boot has no warnings beyond the empty-mount one: {:?}",
3379 engine.load_warnings()
3380 );
3381
3382 let body =
3384 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3385 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3386
3387 engine.reload_each_writable_mem().unwrap();
3388 let warnings = engine.load_warnings();
3389 assert!(
3390 warnings
3391 .iter()
3392 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3393 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3394 );
3395 }
3396
3397 #[test]
3404 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3405 let tmp = TempDir::new().unwrap();
3406 let mem_dir = tmp.path().to_path_buf();
3407 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3409 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3410 let writer = FilesystemMemWriter::new(mem_dir.clone());
3411 let mut engine = Engine::from_mounts(vec![(
3412 folder_mount("specs", mem_dir.clone()),
3413 Box::new(writer) as Box<dyn MemBackend>,
3414 )])
3415 .unwrap();
3416 assert!(
3418 !engine
3419 .load_warnings()
3420 .iter()
3421 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3422 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3423 engine.load_warnings()
3424 );
3425
3426 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";
3428 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3429
3430 engine.reload_each_writable_mem().unwrap();
3431
3432 let invalid: Vec<_> = engine
3433 .load_warnings()
3434 .iter()
3435 .filter_map(|w| match w {
3436 WarningHint::ParsedRelationInvalid {
3437 rel_type,
3438 reason,
3439 origin,
3440 ..
3441 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3442 _ => None,
3443 })
3444 .collect();
3445 assert_eq!(
3446 invalid.len(),
3447 1,
3448 "reload must surface the parse-time drift, got: {invalid:?}",
3449 );
3450 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3451 assert_eq!(invalid[0].1, "unknown_rel_type");
3452 assert_eq!(invalid[0].2, "writable");
3453 }
3454
3455 #[test]
3456 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3457 let tmp = TempDir::new().unwrap();
3464 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3465 let a_dir = tmp.path().join("a");
3466 std::fs::create_dir_all(&a_dir).unwrap();
3467 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3468 let b_dir = tmp.path().join("b");
3469 std::fs::create_dir_all(&b_dir).unwrap();
3470 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3471 let mut engine = Engine::from_mounts(vec![
3472 (
3473 folder_mount("alpha", a_dir.clone()),
3474 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3475 ),
3476 (
3477 folder_mount("beta", b_dir.clone()),
3478 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3479 ),
3480 ])
3481 .unwrap();
3482 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3483 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3484 assert!(
3485 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3486 "boot must populate one warning per mem: {pre:?}"
3487 );
3488
3489 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3491 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3492 engine.reload_one_mem("alpha").unwrap();
3493
3494 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3495 assert!(
3496 !post.contains(&"alpha".to_string()),
3497 "reload must drop the healed mem's stale warning: {post:?}"
3498 );
3499 assert!(
3500 post.contains(&"beta".to_string()),
3501 "reload of alpha must not clear beta's slice: {post:?}"
3502 );
3503 }
3504
3505 #[test]
3506 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3507 let tmp = TempDir::new().unwrap();
3512 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3513 let a_dir = tmp.path().join("a");
3514 std::fs::create_dir_all(&a_dir).unwrap();
3515 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3516 let b_dir = tmp.path().join("b");
3517 std::fs::create_dir_all(&b_dir).unwrap();
3518 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3519 let mut engine = Engine::from_mounts(vec![
3520 (
3521 folder_mount("alpha", a_dir.clone()),
3522 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3523 ),
3524 (
3525 folder_mount("beta", b_dir.clone()),
3526 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3527 ),
3528 ])
3529 .unwrap();
3530 assert!(
3531 engine
3532 .load_warnings()
3533 .iter()
3534 .any(|w| w.source_mem() == Some("alpha")),
3535 "boot must carry alpha-sourced warnings"
3536 );
3537
3538 engine.unregister_writable_mem("alpha").unwrap();
3539
3540 let post = engine.load_warnings();
3541 assert!(
3542 !post.iter().any(|w| w.source_mem() == Some("alpha")),
3543 "delete must purge the removed mem's warnings: {post:?}"
3544 );
3545 assert!(
3546 post.iter().any(|w| w.source_mem() == Some("beta")),
3547 "delete of alpha must keep beta's warnings: {post:?}"
3548 );
3549 }
3550
3551 #[test]
3552 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3553 let tmp = TempDir::new().unwrap();
3559 let a_dir = tmp.path().join("a");
3560 std::fs::create_dir_all(&a_dir).unwrap();
3561 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";
3562 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3563 let b_dir = tmp.path().join("b");
3564 std::fs::create_dir_all(&b_dir).unwrap();
3565 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3566 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3567 let mut engine = Engine::from_mounts(vec![
3568 (
3569 folder_mount("alpha", a_dir.clone()),
3570 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3571 ),
3572 (
3573 folder_mount("beta", b_dir.clone()),
3574 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3575 ),
3576 ])
3577 .unwrap();
3578 let alpha_sourced = |engine: &Engine| {
3579 engine
3580 .load_warnings()
3581 .iter()
3582 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3583 };
3584 assert!(
3585 alpha_sourced(&engine),
3586 "boot must flag alpha's invalid row: {:?}",
3587 engine.load_warnings()
3588 );
3589
3590 engine.unregister_writable_mem("beta").unwrap();
3591
3592 assert!(
3593 alpha_sourced(&engine),
3594 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3595 engine.load_warnings()
3596 );
3597 }
3598
3599 #[test]
3607 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3608 let tmp = TempDir::new().unwrap();
3609 let mem_dir = tmp.path().to_path_buf();
3610 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3611 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3612 let writer = FilesystemMemWriter::new(mem_dir.clone());
3613 let mut engine = Engine::from_mounts(vec![(
3614 folder_mount("specs", mem_dir.clone()),
3615 Box::new(writer) as Box<dyn MemBackend>,
3616 )])
3617 .unwrap();
3618 assert!(
3619 !engine.load_warnings().is_empty(),
3620 "boot must populate load_warnings"
3621 );
3622
3623 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3626 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3627 engine.reload_each_writable_mem_reports().unwrap();
3628 assert!(
3629 engine.load_warnings().is_empty(),
3630 "reports sweep must drop healed warnings: {:?}",
3631 engine.load_warnings()
3632 );
3633
3634 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3636 engine.reload_each_writable_mem_reports().unwrap();
3637 assert!(
3638 engine
3639 .load_warnings()
3640 .iter()
3641 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3642 "reports sweep must surface fresh drift: {:?}",
3643 engine.load_warnings()
3644 );
3645 }
3646
3647 #[test]
3655 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3656 let tmp = TempDir::new().unwrap();
3657 let a_dir = tmp.path().join("a");
3658 let b_dir = tmp.path().join("b");
3659 std::fs::create_dir_all(&a_dir).unwrap();
3660 std::fs::create_dir_all(&b_dir).unwrap();
3661 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3662 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3663 let mut engine = Engine::from_mounts(vec![
3664 (
3665 folder_mount("specs", a_dir),
3666 Box::new(a_writer) as Box<dyn MemBackend>,
3667 ),
3668 (
3669 folder_mount("memos", b_dir),
3670 Box::new(b_writer) as Box<dyn MemBackend>,
3671 ),
3672 ])
3673 .unwrap();
3674
3675 let mut settings = crate::workspace::WorkspaceSettings::default();
3677 settings.cross_mem_links.insert(
3678 "specs".to_string(),
3679 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3680 );
3681 engine.set_settings(settings);
3682
3683 let (actor, client) = cli_actor();
3684 let source = engine
3685 .create_entity(
3686 empty_create_args("specs", "Source"),
3687 actor,
3688 Some(&client),
3689 None,
3690 )
3691 .unwrap();
3692 let target = engine
3693 .create_entity(
3694 empty_create_args("memos", "Target"),
3695 actor,
3696 Some(&client),
3697 None,
3698 )
3699 .unwrap();
3700 engine
3701 .relate_entity(
3702 crate::engine::RelateEntityArgs {
3703 source: source.id.clone(),
3704 expected_hash: Some(source.content_hash.clone()),
3705 rel_type: "USES".to_string(),
3706 target: target.id.clone(),
3707 remove: false,
3708 description: None,
3709 dry_run: false,
3710 },
3711 actor,
3712 Some(&client),
3713 None,
3714 )
3715 .unwrap();
3716
3717 let has_edge = |e: &Engine| {
3719 let out = e
3720 .store()
3721 .outgoing(&source.id)
3722 .iter()
3723 .any(|edge| edge.target == target.id);
3724 let inc = e
3725 .store()
3726 .incoming(&target.id)
3727 .iter()
3728 .any(|edge| edge.from == source.id);
3729 (out, inc)
3730 };
3731
3732 assert_eq!(
3733 has_edge(&engine),
3734 (true, true),
3735 "edge must be indexed in both directions after relate",
3736 );
3737
3738 engine.reload_one_mem("memos").unwrap();
3740 assert_eq!(
3741 has_edge(&engine),
3742 (true, true),
3743 "cross-mem edge into B must survive a per-mem reload of B",
3744 );
3745
3746 engine.reload_each_writable_mem().unwrap();
3749 assert_eq!(
3750 has_edge(&engine),
3751 (true, true),
3752 "per-mem and workspace reload converge on the same edge",
3753 );
3754
3755 assert!(
3758 engine
3759 .store()
3760 .get(&source.id)
3761 .unwrap()
3762 .relationships
3763 .iter()
3764 .any(|r| r.target == target.id),
3765 "source record must retain the relationship throughout",
3766 );
3767 }
3768
3769 #[test]
3775 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3776 let tmp = TempDir::new().unwrap();
3777 let a_dir = tmp.path().join("a");
3778 let b_dir = tmp.path().join("b");
3779 std::fs::create_dir_all(&a_dir).unwrap();
3780 std::fs::create_dir_all(&b_dir).unwrap();
3781 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3782 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3783 let mut engine = Engine::from_mounts(vec![
3784 (
3785 folder_mount("specs", a_dir),
3786 Box::new(a_writer) as Box<dyn MemBackend>,
3787 ),
3788 (
3789 folder_mount("memos", b_dir),
3790 Box::new(b_writer) as Box<dyn MemBackend>,
3791 ),
3792 ])
3793 .unwrap();
3794 let mut settings = crate::workspace::WorkspaceSettings::default();
3795 settings.cross_mem_links.insert(
3796 "specs".to_string(),
3797 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3798 );
3799 engine.set_settings(settings);
3800
3801 let (actor, client) = cli_actor();
3802 let source = engine
3803 .create_entity(
3804 empty_create_args("specs", "Source"),
3805 actor,
3806 Some(&client),
3807 None,
3808 )
3809 .unwrap();
3810 let target = engine
3811 .create_entity(
3812 empty_create_args("memos", "Target"),
3813 actor,
3814 Some(&client),
3815 None,
3816 )
3817 .unwrap();
3818 engine
3819 .relate_entity(
3820 crate::engine::RelateEntityArgs {
3821 source: source.id.clone(),
3822 expected_hash: Some(source.content_hash.clone()),
3823 rel_type: "USES".to_string(),
3824 target: target.id.clone(),
3825 remove: false,
3826 description: None,
3827 dry_run: false,
3828 },
3829 actor,
3830 Some(&client),
3831 None,
3832 )
3833 .unwrap();
3834
3835 engine.reload_one_mem("specs").unwrap();
3836
3837 let out = engine
3838 .store()
3839 .outgoing(&source.id)
3840 .iter()
3841 .any(|edge| edge.target == target.id);
3842 let inc = engine
3843 .store()
3844 .incoming(&target.id)
3845 .iter()
3846 .any(|edge| edge.from == source.id);
3847 assert!(
3848 out && inc,
3849 "outgoing cross-mem edge must survive a source-mem reload"
3850 );
3851 }
3852
3853 #[test]
3854 fn workspace_root_setter_round_trips() {
3855 let tmp = TempDir::new().unwrap();
3856 let mem_dir = tmp.path().to_path_buf();
3857 let writer = FilesystemMemWriter::new(mem_dir.clone());
3858 let mut engine = Engine::from_mounts(vec![(
3859 folder_mount("specs", mem_dir),
3860 Box::new(writer) as Box<dyn MemBackend>,
3861 )])
3862 .unwrap();
3863 let root = tmp.path().to_path_buf();
3864 engine.set_workspace_root(root.clone());
3865 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3866 }
3867
3868 #[test]
3869 fn export_mem_folder_backend_produces_archive() {
3870 let tmp = TempDir::new().unwrap();
3874 let mem_dir = tmp.path().join("specs");
3875 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3876 let config_body = r#"{
3877 "format": 1,
3878 "schema": "default@1.0.0",
3879 "version": "1.0.0"
3880 }"#;
3881 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3882
3883 let writer = FilesystemMemWriter::new(mem_dir.clone());
3884 let engine = Engine::from_mounts(vec![(
3885 folder_mount("specs", mem_dir.clone()),
3886 Box::new(writer) as Box<dyn MemBackend>,
3887 )])
3888 .unwrap();
3889
3890 let archive_path = tmp.path().join("specs.mem");
3891 let result = engine.export_mem("specs", &archive_path).unwrap();
3892 assert!(archive_path.exists(), "archive must exist on disk");
3893 assert!(result.size_bytes > 0);
3894 assert_eq!(result.entity_count, 0);
3897 }
3898
3899 #[test]
3900 fn export_mem_unknown_mem_returns_unknown_mem() {
3901 let tmp = TempDir::new().unwrap();
3902 let mem_dir = tmp.path().to_path_buf();
3903 let writer = FilesystemMemWriter::new(mem_dir.clone());
3904 let engine = Engine::from_mounts(vec![(
3905 folder_mount("specs", mem_dir),
3906 Box::new(writer) as Box<dyn MemBackend>,
3907 )])
3908 .unwrap();
3909 let output = tmp.path().join("out.mem");
3910 let err = engine.export_mem("missing", &output).unwrap_err();
3911 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3912 }
3913
3914 #[test]
3915 fn export_mem_missing_config_returns_invalid_input() {
3916 let tmp = TempDir::new().unwrap();
3920 let mem_dir = tmp.path().to_path_buf();
3921 let writer = FilesystemMemWriter::new(mem_dir.clone());
3922 let engine = Engine::from_mounts(vec![(
3923 folder_mount("specs", mem_dir),
3924 Box::new(writer) as Box<dyn MemBackend>,
3925 )])
3926 .unwrap();
3927 let output = tmp.path().join("out.mem");
3928 let err = engine.export_mem("specs", &output).unwrap_err();
3929 assert!(matches!(err, EngineError::InvalidInput(_)));
3930 }
3931
3932 #[test]
3933 fn export_mem_archive_backend_returns_sealed() {
3934 let tmp = TempDir::new().unwrap();
3937 let archive_path = build_archive(
3938 tmp.path(),
3939 "ext",
3940 &[(
3941 ".memstead/config.json",
3942 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3943 )],
3944 );
3945 let engine = Engine::from_mounts(vec![(
3946 archive_mount("ext", archive_path.clone()),
3947 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3948 )])
3949 .unwrap();
3950 let output = tmp.path().join("out.mem");
3951 let err = engine.export_mem("ext", &output).unwrap_err();
3952 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3953 }
3954
3955 #[test]
3956 fn export_markdown_writes_unchanged_files_zero_writes() {
3957 let tmp = TempDir::new().unwrap();
3962 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3963 let result = engine.export_markdown(None, None).unwrap();
3964 assert_eq!(
3965 result.written, 0,
3966 "freshly-created entity's file already matches generated markdown"
3967 );
3968 assert_eq!(
3969 result.unchanged, 1,
3970 "the one seeded entity counts as unchanged"
3971 );
3972 assert!(
3973 result.skipped_mounts.is_empty(),
3974 "folder-only workspace has no skipped mounts"
3975 );
3976 }
3977
3978 #[test]
3979 fn export_markdown_skips_non_folder_mounts() {
3980 let tmp = TempDir::new().unwrap();
3984 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3985 let engine = Engine::from_mounts(vec![(
3986 archive_mount("ext", archive_path.clone()),
3987 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3988 )])
3989 .unwrap();
3990 let result = engine.export_markdown(None, None).unwrap();
3991 assert_eq!(result.written, 0);
3992 assert_eq!(result.unchanged, 0);
3993 assert_eq!(
3994 result.skipped_mounts.len(),
3995 1,
3996 "archive mount is in the skipped list"
3997 );
3998 let entry = &result.skipped_mounts[0];
3999 assert_eq!(entry.mem, "ext");
4000 assert_eq!(entry.active_backend, "archive");
4001 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
4002 }
4003
4004 #[test]
4005 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
4006 let tmp = TempDir::new().unwrap();
4010 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4011 let engine = Engine::from_mounts(vec![(
4012 archive_mount("ext", archive_path.clone()),
4013 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4014 )])
4015 .unwrap();
4016 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
4017 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
4018 let details = err.details();
4019 assert_eq!(details["mem"], "ext");
4020 assert_eq!(details["active_backend"], "archive");
4021 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
4022 }
4023
4024 #[test]
4025 fn register_writable_mem_adds_mount_and_router_entry() {
4026 let tmp = TempDir::new().unwrap();
4029 let mem_a = tmp.path().join("a");
4030 std::fs::create_dir_all(&mem_a).unwrap();
4031 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4032
4033 let mut engine = Engine::from_mounts(vec![(
4034 folder_mount("alpha", mem_a),
4035 Box::new(writer_a) as Box<dyn MemBackend>,
4036 )])
4037 .unwrap();
4038 assert!(engine.mem_router().is_writable("alpha"));
4039
4040 let mem_b = tmp.path().join("b");
4041 std::fs::create_dir_all(&mem_b).unwrap();
4042 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4043
4044 engine
4045 .register_writable_mem(
4046 folder_mount("beta", mem_b.clone()),
4047 Box::new(writer_b) as Box<dyn MemBackend>,
4048 MemOrigin::ExplicitToml,
4049 )
4050 .unwrap();
4051
4052 assert!(engine.mem_router().is_writable("alpha"));
4054 assert!(engine.mem_router().is_writable("beta"));
4055 assert!(engine.mem_router().is_visible("beta"));
4056
4057 assert!(engine.mount("beta").is_some());
4059 assert!(engine.schemas().contains_key("beta"));
4060
4061 assert_eq!(
4063 engine.mem_router().dir_for_mem("beta"),
4064 Some(mem_b.as_path()),
4065 );
4066 }
4067
4068 #[test]
4074 fn register_writable_mem_resolves_schema_from_mem_config() {
4075 let tmp = TempDir::new().unwrap();
4076 let mem_a = tmp.path().join("a");
4077 std::fs::create_dir_all(&mem_a).unwrap();
4078 let mut engine = Engine::from_mounts(vec![(
4079 folder_mount("alpha", mem_a.clone()),
4080 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
4081 )])
4082 .unwrap();
4083
4084 let mem_b = tmp.path().join("b");
4085 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
4086 std::fs::write(
4087 mem_b.join(".memstead").join("config.json"),
4088 r#"{"schema":"software@0.1.0"}"#,
4089 )
4090 .unwrap();
4091 let mount_b = crate::workspace::Mount {
4092 mem: "beta".to_string(),
4093 schema: Some(memstead_schema::SchemaRef::new(
4094 "totally-not-a-schema",
4095 semver::Version::new(9, 9, 9),
4096 )),
4097 storage: crate::workspace::MountStorage::Folder {
4098 path: mem_b.clone(),
4099 },
4100 capability: crate::workspace::MountCapability::Write,
4101 lifecycle: crate::workspace::MountLifecycle::Eager,
4102 cross_linkable: true,
4103 migration_target: None,
4104 };
4105 engine
4106 .register_writable_mem(
4107 mount_b,
4108 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
4109 MemOrigin::ExplicitToml,
4110 )
4111 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
4112
4113 assert!(engine.schemas().contains_key("beta"));
4114 let surfaced = engine.load_warnings().iter().any(|w| {
4115 matches!(
4116 w,
4117 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
4118 if mem == "beta"
4119 && config_pin == "software@0.1.0"
4120 && mount_pin == "totally-not-a-schema@9.9.9"
4121 )
4122 });
4123 assert!(
4124 surfaced,
4125 "SchemaPinMismatch must surface for beta: {:?}",
4126 engine.load_warnings(),
4127 );
4128 }
4129
4130 #[test]
4131 fn register_writable_mem_rejects_existing_name() {
4132 let tmp = TempDir::new().unwrap();
4135 let mem_a = tmp.path().join("a");
4136 std::fs::create_dir_all(&mem_a).unwrap();
4137 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4138
4139 let mut engine = Engine::from_mounts(vec![(
4140 folder_mount("alpha", mem_a),
4141 Box::new(writer_a) as Box<dyn MemBackend>,
4142 )])
4143 .unwrap();
4144 let mount_count_pre = engine.mounts().len();
4145
4146 let mem_collide = tmp.path().join("alpha-2");
4147 std::fs::create_dir_all(&mem_collide).unwrap();
4148 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
4149
4150 let err = engine
4151 .register_writable_mem(
4152 folder_mount("alpha", mem_collide),
4153 Box::new(writer_collide) as Box<dyn MemBackend>,
4154 MemOrigin::ExplicitToml,
4155 )
4156 .unwrap_err();
4157 match err {
4158 EngineError::MemNameCollision {
4159 name,
4160 source_origin,
4161 } => {
4162 assert_eq!(name, "alpha");
4163 assert!(
4168 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
4169 );
4170 }
4171 other => panic!("expected MemNameCollision, got {other:?}"),
4172 }
4173
4174 assert_eq!(engine.mounts().len(), mount_count_pre);
4176 }
4177
4178 #[test]
4179 fn register_writable_mem_loads_entities_into_store() {
4180 let tmp = TempDir::new().unwrap();
4183 let mem_a = tmp.path().join("a");
4184 std::fs::create_dir_all(&mem_a).unwrap();
4185 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4186
4187 let mut engine = Engine::from_mounts(vec![(
4188 folder_mount("alpha", mem_a),
4189 Box::new(writer_a) as Box<dyn MemBackend>,
4190 )])
4191 .unwrap();
4192 let pre_count = engine.store().all_entities().count();
4193
4194 let mem_b = tmp.path().join("b");
4196 std::fs::create_dir_all(&mem_b).unwrap();
4197 std::fs::write(
4198 mem_b.join("b1.md"),
4199 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4200 )
4201 .unwrap();
4202 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4203
4204 engine
4205 .register_writable_mem(
4206 folder_mount("beta", mem_b),
4207 Box::new(writer_b) as Box<dyn MemBackend>,
4208 MemOrigin::ExplicitToml,
4209 )
4210 .unwrap();
4211
4212 let post_count = engine.store().all_entities().count();
4213 assert!(post_count > pre_count, "register must load entities");
4214 let beta_count = engine
4215 .store()
4216 .all_entities()
4217 .filter(|e| e.mem == "beta")
4218 .count();
4219 assert_eq!(beta_count, 1);
4220 }
4221
4222 #[test]
4223 fn register_then_unregister_round_trips() {
4224 let tmp = TempDir::new().unwrap();
4228 let mem_a = tmp.path().join("a");
4229 std::fs::create_dir_all(&mem_a).unwrap();
4230 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4231
4232 let mut engine = Engine::from_mounts(vec![(
4233 folder_mount("alpha", mem_a),
4234 Box::new(writer_a) as Box<dyn MemBackend>,
4235 )])
4236 .unwrap();
4237 let pre_mounts = engine.mounts().len();
4238
4239 let mem_b = tmp.path().join("b");
4240 std::fs::create_dir_all(&mem_b).unwrap();
4241 let writer_b = FilesystemMemWriter::new(mem_b);
4242
4243 engine
4244 .register_writable_mem(
4245 folder_mount("beta", tmp.path().join("b")),
4246 Box::new(writer_b) as Box<dyn MemBackend>,
4247 MemOrigin::ExplicitToml,
4248 )
4249 .unwrap();
4250 assert_eq!(engine.mounts().len(), pre_mounts + 1);
4251
4252 let removed = engine.unregister_writable_mem("beta").unwrap();
4253 assert!(removed.is_some());
4254 assert_eq!(engine.mounts().len(), pre_mounts);
4255 assert!(!engine.mem_router().is_writable("beta"));
4256 }
4257
4258 #[test]
4259 fn unregister_writable_mem_returns_false_for_unknown_name() {
4260 let tmp = TempDir::new().unwrap();
4264 let mem_dir = tmp.path().to_path_buf();
4265 let writer = FilesystemMemWriter::new(mem_dir.clone());
4266 let mut engine = Engine::from_mounts(vec![(
4267 folder_mount("specs", mem_dir),
4268 Box::new(writer) as Box<dyn MemBackend>,
4269 )])
4270 .unwrap();
4271 let removed = engine.unregister_writable_mem("missing").unwrap();
4272 assert!(removed.is_none(), "unknown mem returns Ok(None)");
4273 assert!(engine.mem_router().is_writable("specs"));
4275 }
4276
4277 #[test]
4278 fn unregister_writable_mem_drops_mount_and_router_entry() {
4279 let tmp = TempDir::new().unwrap();
4284 let mem_a = tmp.path().join("a");
4285 std::fs::create_dir_all(&mem_a).unwrap();
4286 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4287 let mem_b = tmp.path().join("b");
4288 std::fs::create_dir_all(&mem_b).unwrap();
4289 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4290
4291 let mut engine = Engine::from_mounts(vec![
4292 (
4293 folder_mount("alpha", mem_a),
4294 Box::new(writer_a) as Box<dyn MemBackend>,
4295 ),
4296 (
4297 folder_mount("beta", mem_b),
4298 Box::new(writer_b) as Box<dyn MemBackend>,
4299 ),
4300 ])
4301 .unwrap();
4302
4303 let removed = engine.unregister_writable_mem("alpha").unwrap();
4304 assert!(removed.is_some());
4305
4306 assert!(!engine.mem_router().is_writable("alpha"));
4308 assert!(!engine.mem_router().is_visible("alpha"));
4309 assert!(engine.mount("alpha").is_none());
4310
4311 assert!(engine.mem_router().is_writable("beta"));
4313 assert!(engine.mount("beta").is_some());
4314 }
4315
4316 #[test]
4317 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
4318 let tmp = TempDir::new().unwrap();
4322 let mem_a = tmp.path().join("a");
4323 std::fs::create_dir_all(&mem_a).unwrap();
4324 std::fs::write(
4325 mem_a.join("a1.md"),
4326 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
4327 )
4328 .unwrap();
4329 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4330
4331 let mem_b = tmp.path().join("b");
4332 std::fs::create_dir_all(&mem_b).unwrap();
4333 std::fs::write(
4334 mem_b.join("b1.md"),
4335 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4336 )
4337 .unwrap();
4338 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4339
4340 let mut engine = Engine::from_mounts(vec![
4341 (
4342 folder_mount("alpha", mem_a),
4343 Box::new(writer_a) as Box<dyn MemBackend>,
4344 ),
4345 (
4346 folder_mount("beta", mem_b),
4347 Box::new(writer_b) as Box<dyn MemBackend>,
4348 ),
4349 ])
4350 .unwrap();
4351
4352 let pre_total = engine.store().all_entities().count();
4353 assert!(pre_total >= 2, "both mems must load entities");
4354
4355 engine.unregister_writable_mem("alpha").unwrap();
4356
4357 let alpha_remaining = engine
4359 .store()
4360 .all_entities()
4361 .filter(|e| e.mem == "alpha")
4362 .count();
4363 assert_eq!(alpha_remaining, 0);
4364
4365 let beta_remaining = engine
4367 .store()
4368 .all_entities()
4369 .filter(|e| e.mem == "beta")
4370 .count();
4371 assert!(beta_remaining > 0, "beta entities must survive");
4372 }
4373 #[test]
4374 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4375 let tmp = TempDir::new().unwrap();
4376 let mut engine = build_demo_engine(&tmp);
4377 let result = engine
4378 .reload_one_mem("specs")
4379 .expect("reload on stable disk must succeed");
4380 assert!(result.added.is_empty(), "added: {:?}", result.added);
4381 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4382 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4383 }
4384
4385 #[test]
4386 fn reload_one_mem_picks_up_external_addition() {
4387 let tmp = TempDir::new().unwrap();
4388 let mut engine = build_demo_engine(&tmp);
4389 std::fs::write(
4392 tmp.path().join("external.md"),
4393 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4394 )
4395 .unwrap();
4396 let result = engine.reload_one_mem("specs").unwrap();
4397 assert_eq!(
4398 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4399 vec!["specs--external"]
4400 );
4401 assert!(result.changed.is_empty());
4402 assert!(result.removed.is_empty());
4403 assert!(
4405 engine
4406 .get_entity(&crate::EntityId::new("specs", "external"))
4407 .is_some()
4408 );
4409 }
4410
4411 #[test]
4412 fn reload_one_mem_picks_up_external_removal() {
4413 let tmp = TempDir::new().unwrap();
4414 let mut engine = build_demo_engine(&tmp);
4415 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4418 let result = engine.reload_one_mem("specs").unwrap();
4419 assert!(result.added.is_empty());
4420 assert!(result.changed.is_empty());
4421 assert_eq!(
4422 result
4423 .removed
4424 .iter()
4425 .map(|i| i.as_ref())
4426 .collect::<Vec<_>>(),
4427 vec!["specs--lonely-three"]
4428 );
4429 }
4430
4431 #[test]
4432 fn reload_one_mem_picks_up_external_change() {
4433 let tmp = TempDir::new().unwrap();
4434 let mut engine = build_demo_engine(&tmp);
4435 std::fs::write(
4438 tmp.path().join("source-one.md"),
4439 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4440 )
4441 .unwrap();
4442 let result = engine.reload_one_mem("specs").unwrap();
4443 assert!(result.added.is_empty());
4444 assert_eq!(
4445 result
4446 .changed
4447 .iter()
4448 .map(|i| i.as_ref())
4449 .collect::<Vec<_>>(),
4450 vec!["specs--source-one"]
4451 );
4452 assert!(result.removed.is_empty());
4453 }
4454
4455 #[test]
4456 fn reload_one_mem_rejects_unknown_mem() {
4457 let tmp = TempDir::new().unwrap();
4458 let mut engine = build_demo_engine(&tmp);
4459 let err = engine.reload_one_mem("nope").unwrap_err();
4460 match err {
4461 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4462 other => panic!("expected UnknownMem, got {other:?}"),
4463 }
4464 }
4465
4466 #[test]
4467 fn reload_each_writable_mem_returns_one_entry_per_mount() {
4468 let tmp = TempDir::new().unwrap();
4469 let mut engine = build_demo_engine(&tmp);
4470 let reports = engine
4471 .reload_each_writable_mem()
4472 .expect("batch reload on stable disk must succeed");
4473 assert_eq!(reports.len(), 1);
4474 assert_eq!(reports[0].0, "specs");
4475 assert!(reports[0].1.added.is_empty());
4476 assert!(reports[0].1.changed.is_empty());
4477 assert!(reports[0].1.removed.is_empty());
4478 }
4479
4480 #[test]
4483 fn settings_default_to_empty_on_fresh_engine() {
4484 let tmp = TempDir::new().unwrap();
4485 let engine = build_demo_engine(&tmp);
4486 let s = engine.settings();
4487 assert!(s.mem_create_rules.is_empty());
4488 assert!(s.mem_delete_rules.is_empty());
4489 assert!(s.cross_mem_links.is_empty());
4490 }
4491
4492 #[test]
4493 fn set_settings_replaces_workspace_policy() {
4494 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4495 let tmp = TempDir::new().unwrap();
4496 let mut engine = build_demo_engine(&tmp);
4497 let mut settings = WorkspaceSettings::default();
4498 settings.mem_create_rules.push(CreateRuleSetting {
4499 pattern: "exec-*".to_string(),
4500 schemas: vec!["default@1.0.0".to_string()],
4501 default_cross_links: None,
4502 });
4503 settings.mem_delete_rules.push(DeleteRuleSetting {
4504 pattern: "exec-*".to_string(),
4505 });
4506 engine.set_settings(settings);
4507 assert_eq!(engine.settings().mem_create_rules.len(), 1);
4508 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4509 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4510 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4511 }
4512
4513 #[test]
4516 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4517 let tmp = TempDir::new().unwrap();
4518 let mut engine = build_demo_engine(&tmp);
4519 std::fs::write(
4521 tmp.path().join("new-via-disk.md"),
4522 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4523 )
4524 .unwrap();
4525 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4526 std::fs::write(
4527 tmp.path().join("source-one.md"),
4528 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4529 )
4530 .unwrap();
4531
4532 let reports = engine.reload_each_writable_mem().unwrap();
4533 assert_eq!(reports.len(), 1);
4534 let (mem, result) = &reports[0];
4535 assert_eq!(mem, "specs");
4536 assert_eq!(
4537 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4538 vec!["specs--new-via-disk"]
4539 );
4540 assert_eq!(
4541 result
4542 .removed
4543 .iter()
4544 .map(|i| i.as_ref())
4545 .collect::<Vec<_>>(),
4546 vec!["specs--lonely-three"]
4547 );
4548 assert_eq!(
4549 result
4550 .changed
4551 .iter()
4552 .map(|i| i.as_ref())
4553 .collect::<Vec<_>>(),
4554 vec!["specs--source-one"]
4555 );
4556 }
4557
4558 #[test]
4561 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4562 let tmp = TempDir::new().unwrap();
4570 let mut engine = build_demo_engine(&tmp);
4571 let report = engine.reload_one_mem_report("specs").unwrap();
4572 assert_eq!(report.mem, "specs");
4573 assert_eq!(
4574 report.head_before, report.head_after,
4575 "unchanged disk → stable cursor"
4576 );
4577 assert!(
4578 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4579 "folder heads carry the changelog-ts cursor, got {}",
4580 report.head_after
4581 );
4582 assert_eq!(report.entities_loaded, 3);
4585 assert!(report.changed_entity_ids.is_empty());
4587 }
4588
4589 #[test]
4590 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4591 let tmp = TempDir::new().unwrap();
4596 let mut engine = build_demo_engine(&tmp);
4597 std::fs::write(
4598 tmp.path().join("new-via-disk.md"),
4599 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4600 )
4601 .unwrap();
4602 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4603 std::fs::write(
4604 tmp.path().join("source-one.md"),
4605 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4606 )
4607 .unwrap();
4608
4609 let report = engine.reload_one_mem_report("specs").unwrap();
4610 assert_eq!(report.mem, "specs");
4611 let ids: Vec<&str> = report
4612 .changed_entity_ids
4613 .iter()
4614 .map(|id| id.as_ref())
4615 .collect();
4616 assert_eq!(
4618 ids,
4619 vec![
4620 "specs--lonely-three",
4621 "specs--new-via-disk",
4622 "specs--source-one",
4623 ]
4624 );
4625 }
4626
4627 #[test]
4628 fn reload_one_mem_report_rejects_unknown_mem() {
4629 let tmp = TempDir::new().unwrap();
4630 let mut engine = build_demo_engine(&tmp);
4631 let err = engine.reload_one_mem_report("missing").unwrap_err();
4632 assert!(matches!(err, EngineError::UnknownMem(_)));
4633 }
4634
4635 #[test]
4636 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4637 let tmp = TempDir::new().unwrap();
4638 let mut engine = build_demo_engine(&tmp);
4639 let reports = engine.reload_each_writable_mem_reports().unwrap();
4640 assert_eq!(reports.len(), 1);
4641 assert_eq!(reports[0].mem, "specs");
4642 assert_eq!(reports[0].entities_loaded, 3);
4643 }
4644
4645 #[test]
4652 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4653 let tmp = TempDir::new().unwrap();
4654
4655 let memstead_dir = tmp.path().join(".memstead");
4658 std::fs::create_dir_all(&memstead_dir).unwrap();
4659 let workspace_toml = memstead_dir.join("workspace.toml");
4660 std::fs::write(
4661 &workspace_toml,
4662 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4663 )
4664 .unwrap();
4665 let mounts_json = memstead_dir.join("state").join("mounts.json");
4666 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4667 let mem_dir = tmp.path().join("specs");
4668 std::fs::create_dir_all(&mem_dir).unwrap();
4669 let mounts_body = format!(
4670 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4671 mem_dir.display(),
4672 );
4673 std::fs::write(&mounts_json, mounts_body).unwrap();
4674
4675 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4676 assert!(
4677 engine.settings().mem_create_rules.is_empty(),
4678 "boot-time settings carry no create rules"
4679 );
4680
4681 std::fs::write(
4683 &workspace_toml,
4684 "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",
4685 )
4686 .unwrap();
4687
4688 engine.reload_each_writable_mem_reports().unwrap();
4689
4690 let rules = &engine.settings().mem_create_rules;
4691 assert_eq!(
4692 rules.len(),
4693 1,
4694 "workspace-wide reload must refresh the policy"
4695 );
4696 assert_eq!(rules[0].pattern, "exec-*");
4697 }
4698
4699 const MIG_TYPE_TAIL: &str = r#"sections:
4704 - key: body
4705 heading: Body
4706 required: true
4707 search_weight: 10.0
4708 catch_all: true
4709 write_rules: []
4710title_weight: 100.0
4711text_fields:
4712 - body
4713hierarchy_relationship: _default
4714no_self_loop_relationships: []
4715updatable_fields: []
4716health_required_fields: []
4717staleness_threshold_days: 90
4718write_rules: []
4719"#;
4720
4721 fn mig_manifest(name: &str, version: &str) -> String {
4726 format!(
4727 r#"name: {name}
4728version: {version}
4729description: migration test schema
4730when_to_use: tests
4731types:
4732 - doc
4733relationships:
4734 mode: strict
4735 definitions:
4736 - name: USES
4737 description: link
4738 default_weight: 1.0
4739 - name: _default
4740 description: fallback
4741 default_weight: 1.0
4742community:
4743 resolution: 1.0
4744 seed: 42
4745"#
4746 )
4747 }
4748
4749 fn mig_type_yaml(with_status: bool) -> String {
4750 let metadata = if with_status {
4751 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4752 } else {
4753 "metadata_fields: []\n"
4754 };
4755 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4756 }
4757
4758 fn write_mig_schema(
4759 root: &std::path::Path,
4760 dir: &str,
4761 name: &str,
4762 version: &str,
4763 with_status: bool,
4764 ) {
4765 let d = root.join(dir);
4766 std::fs::create_dir_all(d.join("types")).unwrap();
4767 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4768 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4769 }
4770
4771 #[test]
4788 fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4789 for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4790 run_mutation_sequence(seed);
4791 }
4792 }
4793
4794 struct Xorshift(u64);
4795 impl Xorshift {
4796 fn next(&mut self) -> u64 {
4797 let mut x = self.0;
4798 x ^= x << 13;
4799 x ^= x >> 7;
4800 x ^= x << 17;
4801 self.0 = x;
4802 x
4803 }
4804 fn pick(&mut self, n: usize) -> usize {
4805 (self.next() % n as u64) as usize
4806 }
4807 }
4808
4809 fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4810 let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4813 let live = engine.search_indexes();
4814 let mut live_mems: Vec<&String> = live.keys().collect();
4815 let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4816 live_mems.sort();
4817 fresh_mems.sort();
4818 assert_eq!(
4819 live_mems, fresh_mems,
4820 "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4821 );
4822 for (mem, idx) in live {
4823 let mut got = idx.stored_ids().unwrap();
4824 let mut want = fresh[mem].stored_ids().unwrap();
4825 got.sort();
4826 want.sort();
4827 assert_eq!(
4828 got, want,
4829 "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4830 );
4831 }
4832 let schema = engine
4835 .schemas
4836 .iter()
4837 .min_by(|a, b| a.0.cmp(b.0))
4838 .map(|(_, s)| s.clone())
4839 .expect("schema present");
4840 let weights_schema = schema.clone();
4841 let fresh_partition = crate::graph::community::detect_communities(
4842 engine.store(),
4843 schema.manifest.community.resolution,
4844 schema.manifest.community.seed,
4845 move |rel_type| {
4846 weights_schema
4847 .manifest
4848 .relationships
4849 .definitions
4850 .iter()
4851 .find(|d| d.name == rel_type)
4852 .map(|d| d.default_weight as f64)
4853 .unwrap_or(1.0)
4854 },
4855 );
4856 assert_eq!(
4857 engine.communities().entity_cluster_map,
4858 fresh_partition.entity_cluster_map,
4859 "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4860 );
4861 }
4862
4863 fn run_mutation_sequence(seed: u64) {
4864 use indexmap::IndexMap;
4865
4866 let (_tmp, mut engine) = migration_engine();
4867 let mut rng = Xorshift(seed);
4868 let mut live: Vec<crate::EntityId> = vec![
4869 crate::EntityId::new("specs", "one"),
4870 crate::EntityId::new("specs", "two"),
4871 ];
4872 let mut counter = 0usize;
4873 let mut kinds_hit: std::collections::HashSet<&'static str> =
4874 std::collections::HashSet::new();
4875
4876 const KINDS: [&str; 7] = [
4877 "create",
4878 "update",
4879 "relate",
4880 "delete",
4881 "rename",
4882 "batch_applied",
4883 "batch_refused",
4884 ];
4885
4886 let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4887 anchors: Vec::new(),
4888 anchors_unset: Vec::new(),
4889 id,
4890 expected_hash: None,
4891 sections: IndexMap::new(),
4892 append_sections: IndexMap::new(),
4893 patch_sections: IndexMap::new(),
4894 sections_unset: Vec::new(),
4895 metadata: IndexMap::new(),
4896 metadata_unset: Vec::new(),
4897 declare_relations: Vec::new(),
4898 dry_run: false,
4899 relations_unset: Vec::new(),
4900 };
4901
4902 for op_i in 0..30usize {
4903 let kind = *KINDS
4906 .get(op_i)
4907 .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4908 match kind {
4909 "create" => {
4910 counter += 1;
4911 let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4912 args.entity_type = "doc".to_string();
4913 args.sections = IndexMap::from_iter([(
4914 "body".to_string(),
4915 format!("generated body {counter}"),
4916 )]);
4917 let out = engine
4918 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4919 .expect("generated create is conformant");
4920 live.push(out.id);
4921 kinds_hit.insert("create");
4922 }
4923 "update" => {
4924 let id = live[rng.pick(live.len())].clone();
4925 let mut args = bare_update(id);
4926 args.append_sections
4927 .insert("body".to_string(), format!("appended at op {op_i}"));
4928 engine
4929 .update_entity(args, crate::vcs::Actor::Cli, None, None)
4930 .expect("append update is conformant");
4931 kinds_hit.insert("update");
4932 }
4933 "relate" => {
4934 if live.len() >= 2 {
4935 let a = rng.pick(live.len());
4936 let mut b = rng.pick(live.len());
4937 if a == b {
4938 b = (b + 1) % live.len();
4939 }
4940 engine
4941 .relate_entity(
4942 crate::engine::RelateEntityArgs {
4943 source: live[a].clone(),
4944 expected_hash: None,
4945 rel_type: "USES".to_string(),
4946 target: live[b].clone(),
4947 remove: false,
4948 description: None,
4949 dry_run: false,
4950 },
4951 crate::vcs::Actor::Cli,
4952 None,
4953 None,
4954 )
4955 .expect("USES relate is legal under mig-a");
4956 kinds_hit.insert("relate");
4957 }
4958 }
4959 "delete" => {
4960 if live.len() > 2
4963 && let Some(pos) = (0..live.len()).find(|&i| {
4964 engine.store().incoming(&live[i]).is_empty()
4965 && engine
4966 .store()
4967 .get(&live[i])
4968 .is_some_and(|e| e.relationships.is_empty())
4969 })
4970 {
4971 let id = live.remove(pos);
4972 engine
4973 .delete_entity(
4974 crate::engine::DeleteEntityArgs {
4975 id: id.clone(),
4976 expected_hash: None,
4977 },
4978 crate::vcs::Actor::Cli,
4979 None,
4980 None,
4981 )
4982 .expect("reference-free delete lands");
4983 kinds_hit.insert("delete");
4984 }
4985 }
4986 "rename" => {
4987 counter += 1;
4988 let pos = rng.pick(live.len());
4989 let old = live[pos].clone();
4990 let out = engine
4991 .rename_entity(
4992 crate::engine::RenameEntityArgs {
4993 id: old,
4994 new_title: format!("Renamed {counter}"),
4995 expected_hash: None,
4996 },
4997 crate::vcs::Actor::Cli,
4998 None,
4999 None,
5000 )
5001 .expect("fresh-slug rename lands");
5002 live[pos] = out.new_id;
5003 kinds_hit.insert("rename");
5004 }
5005 "batch_applied" => {
5006 let id_a = live[rng.pick(live.len())].clone();
5007 let mut a = bare_update(id_a);
5008 a.append_sections
5009 .insert("body".to_string(), format!("batch line {op_i}"));
5010 let result = engine
5011 .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
5012 .expect("batch envelope");
5013 assert!(result.applied, "single-entry append batch applies");
5014 kinds_hit.insert("batch_applied");
5015 }
5016 "batch_refused" => {
5017 let id_a = live[rng.pick(live.len())].clone();
5018 let mut a = bare_update(id_a);
5019 a.append_sections
5020 .insert("body".to_string(), "doomed".to_string());
5021 let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
5022 let result = engine
5023 .batch_update(
5024 vec![(a, None), (missing, None)],
5025 crate::vcs::Actor::Cli,
5026 None,
5027 false,
5028 )
5029 .expect("refused batch returns a report-all envelope");
5030 assert!(!result.applied, "the missing target refuses the batch");
5031 kinds_hit.insert("batch_refused");
5032 }
5033 _ => unreachable!(),
5034 }
5035
5036 if op_i == 14 {
5037 engine
5041 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5042 .expect("integral switch");
5043 kinds_hit.insert("schema_switch");
5044 }
5045 if op_i == 19 {
5046 engine.reload_one_mem("specs").expect("reload lands");
5047 kinds_hit.insert("reload");
5048 }
5049
5050 if op_i % 10 == 9 {
5051 assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
5052 }
5053 }
5054
5055 assert_derived_oracles(&engine, seed, "sequence end");
5056
5057 for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
5058 assert!(
5059 kinds_hit.contains(kind),
5060 "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
5061 );
5062 }
5063 }
5064
5065 fn migration_engine() -> (tempfile::TempDir, Engine) {
5066 let tmp = tempfile::TempDir::new().unwrap();
5067 let schemas_dir = tmp.path().join("schemas");
5068 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5069 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5070 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
5071 let mem_dir = tmp.path().join("mem");
5072 std::fs::create_dir_all(&mem_dir).unwrap();
5073 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5074 let mut mount = folder_mount("specs", mem_dir);
5075 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5076 let mut engine = Engine::from_mounts_with_schemas_dir(
5077 vec![(
5078 mount,
5079 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5080 )],
5081 Some(&schemas_dir),
5082 )
5083 .unwrap();
5084 for title in ["One", "Two"] {
5085 let mut args = empty_create_args("specs", title);
5086 args.entity_type = "doc".to_string();
5087 args.sections =
5088 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
5089 engine
5090 .create_entity(args, crate::vcs::Actor::Cli, None, None)
5091 .expect("conformant create under mig-a");
5092 }
5093 (tmp, engine)
5094 }
5095
5096 fn sref(s: &str) -> memstead_schema::SchemaRef {
5097 s.parse().unwrap()
5098 }
5099
5100 #[test]
5106 fn set_schema_repairs_a_mount_expectation_ahead_of_the_served_pin() {
5107 let (_tmp, mut engine) = migration_engine();
5108 let idx = engine
5111 .mounts
5112 .iter()
5113 .position(|m| m.mount.mem == "specs")
5114 .unwrap();
5115 engine.mounts[idx].mount.schema = Some(sref("mig-b@0.1.0"));
5116 assert_eq!(engine.schemas.get("specs").unwrap().id().0, "mig-a");
5117
5118 let out = engine
5119 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5120 .unwrap();
5121 assert_eq!(
5125 out.outcome,
5126 crate::engine::SetSchemaResult::MigrationStarted,
5127 "a served pin behind the target enters the switch path, never a noop: {out:?}"
5128 );
5129 assert!(!out.findings.is_empty());
5130 assert_eq!(
5131 engine.schemas.get("specs").unwrap().id().0,
5132 "mig-b",
5133 "writes now validate against the target"
5134 );
5135
5136 let (_tmp2, mut clean) = migration_engine();
5139 let again = clean.set_mem_schema("specs", &sref("mig-a@0.1.0")).unwrap();
5140 assert_eq!(again.outcome, crate::engine::SetSchemaResult::Noop);
5141 }
5142
5143 #[test]
5144 fn set_schema_noop_on_current_pin() {
5145 let (_tmp, mut engine) = migration_engine();
5146 let out = engine
5147 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
5148 .unwrap();
5149 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
5150 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5151 assert_eq!(out.migration_target, None);
5152 assert!(out.findings.is_empty());
5153 }
5154
5155 #[test]
5164 fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
5165 let (_tmp, mut engine) = migration_engine();
5166
5167 let _ = engine.communities();
5168 let _ = engine.search_indexes();
5169 assert!(engine.community_memo.get().is_some());
5170 assert!(engine.search_indexes_memo.get().is_some());
5171 let store_gen_before = engine.store().generation();
5172
5173 engine
5174 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5175 .unwrap();
5176
5177 assert_eq!(
5178 engine.store().generation(),
5179 store_gen_before,
5180 "a schema switch mutates no store content"
5181 );
5182 assert!(
5183 engine.community_memo.get().is_none(),
5184 "the community memo must clear on a schema switch (weights derive from the schema)"
5185 );
5186 assert!(
5187 engine.search_indexes_memo.get().is_none(),
5188 "the search memo must clear on a schema switch (the field set derives from the schema)"
5189 );
5190 }
5191
5192 #[test]
5193 fn set_schema_switches_immediately_when_integral() {
5194 let (_tmp, mut engine) = migration_engine();
5197 let out = engine
5198 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5199 .unwrap();
5200 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5201 assert_eq!(out.schema_pin, "mig-a@0.2.0");
5202 assert_eq!(out.migration_target, None);
5203 assert!(out.findings.is_empty());
5204 assert_eq!(
5205 engine.schema_pin("specs").unwrap().as_display(),
5206 "mig-a@0.2.0"
5207 );
5208 assert!(engine.migration_target("specs").is_none());
5209 }
5210
5211 #[test]
5217 fn set_schema_switch_persists_pin_into_backend_config() {
5218 let tmp = tempfile::TempDir::new().unwrap();
5219 let schemas_dir = tmp.path().join("schemas");
5220 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5221 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5222 let mem_dir = tmp.path().join("mem");
5223 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
5224 std::fs::write(
5226 mem_dir.join(".memstead").join("config.json"),
5227 br#"{"schema":"mig-a@0.1.0"}"#,
5228 )
5229 .unwrap();
5230 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5231 let mut mount = folder_mount("specs", mem_dir.clone());
5232 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5233 let mut engine = Engine::from_mounts_with_schemas_dir(
5234 vec![(
5235 mount,
5236 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5237 )],
5238 Some(&schemas_dir),
5239 )
5240 .unwrap();
5241
5242 let out = engine
5243 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5244 .unwrap();
5245 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5246
5247 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
5250 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
5251 assert_eq!(
5252 cfg["schema"], "mig-a@0.2.0",
5253 "atomic switch must update the authoritative backend config"
5254 );
5255 }
5256
5257 #[test]
5258 fn set_schema_unknown_target_refuses_schema_not_found() {
5259 let (_tmp, mut engine) = migration_engine();
5260 let err = engine
5261 .set_mem_schema("specs", &sref("nope@9.9.9"))
5262 .unwrap_err();
5263 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
5264 assert!(engine.migration_target("specs").is_none());
5266 }
5267
5268 #[test]
5269 fn set_schema_migration_lifecycle_end_to_end() {
5270 let (_tmp, mut engine) = migration_engine();
5271 let target = sref("mig-b@0.1.0");
5272
5273 let out = engine.set_mem_schema("specs", &target).unwrap();
5275 assert_eq!(
5276 out.outcome,
5277 crate::engine::SetSchemaResult::MigrationStarted
5278 );
5279 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5280 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
5281 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
5282 assert!(
5283 out.findings
5284 .iter()
5285 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
5286 );
5287
5288 let one = crate::entity::EntityId::new("specs", "one");
5290 assert!(engine.store().get(&one).is_some());
5291
5292 let out = engine.set_mem_schema("specs", &target).unwrap();
5294 assert_eq!(
5295 out.outcome,
5296 crate::engine::SetSchemaResult::MigrationPending
5297 );
5298 assert_eq!(out.findings.len(), 2);
5299
5300 let mut bad = crate::engine::UpdateEntityArgs {
5304 anchors: Vec::new(),
5305 id: one.clone(),
5306 expected_hash: None,
5307 sections: indexmap::IndexMap::new(),
5308 append_sections: indexmap::IndexMap::new(),
5309 patch_sections: indexmap::IndexMap::new(),
5310 sections_unset: Vec::new(),
5311 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
5312 metadata_unset: Vec::new(),
5313 declare_relations: Vec::new(),
5314 dry_run: false,
5315 relations_unset: Vec::new(),
5316 anchors_unset: Vec::new(),
5317 };
5318 let err = engine
5319 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
5320 .unwrap_err();
5321 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
5322 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
5323 engine
5324 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
5325 .expect("repair write validated against the migration target");
5326
5327 let out = engine.set_mem_schema("specs", &target).unwrap();
5329 assert_eq!(
5330 out.outcome,
5331 crate::engine::SetSchemaResult::MigrationPending
5332 );
5333 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
5334
5335 let two = crate::entity::EntityId::new("specs", "two");
5337 let repair = crate::engine::UpdateEntityArgs {
5338 anchors: Vec::new(),
5339 id: two.clone(),
5340 expected_hash: None,
5341 sections: indexmap::IndexMap::new(),
5342 append_sections: indexmap::IndexMap::new(),
5343 patch_sections: indexmap::IndexMap::new(),
5344 sections_unset: Vec::new(),
5345 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
5346 metadata_unset: Vec::new(),
5347 declare_relations: Vec::new(),
5348 dry_run: false,
5349 relations_unset: Vec::new(),
5350 anchors_unset: Vec::new(),
5351 };
5352 engine
5353 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
5354 .unwrap();
5355 let out = engine.set_mem_schema("specs", &target).unwrap();
5356 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5357 assert_eq!(out.schema_pin, "mig-b@0.1.0");
5358 assert_eq!(out.migration_target, None);
5359 assert!(out.findings.is_empty());
5360 assert_eq!(
5361 engine.schema_pin("specs").unwrap().as_display(),
5362 "mig-b@0.1.0"
5363 );
5364 assert!(engine.migration_target("specs").is_none());
5365 }
5366
5367 #[test]
5372 fn relations_unset_works_during_migration_without_mode_flag() {
5373 let (_tmp, mut engine) = migration_engine();
5374 let one = crate::entity::EntityId::new("specs", "one");
5375 let two = crate::entity::EntityId::new("specs", "two");
5376 engine
5377 .relate_entity(
5378 crate::engine::RelateEntityArgs {
5379 source: one.clone(),
5380 expected_hash: None,
5381 rel_type: "USES".to_string(),
5382 target: two.clone(),
5383 remove: false,
5384 description: None,
5385 dry_run: false,
5386 },
5387 crate::vcs::Actor::Cli,
5388 None,
5389 None,
5390 )
5391 .unwrap();
5392 let shut = engine
5394 .update_entity(
5395 crate::engine::UpdateEntityArgs {
5396 anchors: Vec::new(),
5397 id: one.clone(),
5398 expected_hash: None,
5399 sections: indexmap::IndexMap::new(),
5400 append_sections: indexmap::IndexMap::new(),
5401 patch_sections: indexmap::IndexMap::new(),
5402 sections_unset: Vec::new(),
5403 metadata: indexmap::IndexMap::new(),
5404 metadata_unset: Vec::new(),
5405 declare_relations: Vec::new(),
5406 dry_run: false,
5407 relations_unset: vec![crate::ops::RelationUnsetArg {
5408 rel_type: "USES".to_string(),
5409 target: two.clone(),
5410 }],
5411 anchors_unset: Vec::new(),
5412 },
5413 crate::vcs::Actor::Cli,
5414 None,
5415 None,
5416 )
5417 .unwrap_err();
5418 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5419
5420 engine
5424 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5425 .unwrap();
5426 engine
5427 .update_entity(
5428 crate::engine::UpdateEntityArgs {
5429 anchors: Vec::new(),
5430 id: one.clone(),
5431 expected_hash: None,
5432 sections: indexmap::IndexMap::new(),
5433 append_sections: indexmap::IndexMap::new(),
5434 patch_sections: indexmap::IndexMap::new(),
5435 sections_unset: Vec::new(),
5436 metadata: indexmap::IndexMap::from_iter([(
5437 "status".to_string(),
5438 "open".to_string(),
5439 )]),
5440 metadata_unset: Vec::new(),
5441 declare_relations: Vec::new(),
5442 dry_run: false,
5443 relations_unset: vec![crate::ops::RelationUnsetArg {
5444 rel_type: "USES".to_string(),
5445 target: two.clone(),
5446 }],
5447 anchors_unset: Vec::new(),
5448 },
5449 crate::vcs::Actor::Cli,
5450 None,
5451 None,
5452 )
5453 .expect("repair-shaped update lands during migration without a flag");
5454 let entity = engine.store().get(&one).unwrap();
5455 assert!(entity.relationships.is_empty());
5456 }
5457
5458 #[test]
5463 fn boot_resumes_dual_pin_validation_against_target() {
5464 let (tmp, engine) = migration_engine();
5465 drop(engine);
5466 let schemas_dir = tmp.path().join("schemas");
5467 let mem_dir = tmp.path().join("mem");
5468 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5469 let mut mount = folder_mount("specs", mem_dir);
5470 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5471 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5472 let engine = Engine::from_mounts_with_schemas_dir(
5473 vec![(
5474 mount,
5475 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5476 )],
5477 Some(&schemas_dir),
5478 )
5479 .unwrap();
5480 let (name, version) = {
5482 let s = engine.schema_for("specs").unwrap();
5483 let (n, v) = s.id();
5484 (n.to_string(), v.to_string())
5485 };
5486 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5487 assert_eq!(
5490 engine.schema_pin("specs").unwrap().as_display(),
5491 "mig-a@0.1.0"
5492 );
5493 assert_eq!(
5494 engine.migration_target("specs").unwrap().as_display(),
5495 "mig-b@0.1.0"
5496 );
5497 }
5498
5499 #[test]
5509 fn every_lifecycle_setter_refuses_on_read_only_mount() {
5510 let tmp = TempDir::new().unwrap();
5511 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5512 let mut engine = Engine::from_mounts(vec![(
5513 archive_mount("ext", archive_path.clone()),
5514 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5515 )])
5516 .unwrap();
5517
5518 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5519 let attempts: Vec<(&str, EngineError)> = vec![
5520 (
5521 "set_mem_schema",
5522 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5523 ),
5524 (
5525 "set_mem_version",
5526 engine
5527 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5528 .unwrap_err(),
5529 ),
5530 (
5531 "set_mem_description",
5532 engine
5533 .set_mem_description("ext", Some("x".into()), None)
5534 .unwrap_err(),
5535 ),
5536 (
5537 "set_mem_title",
5538 engine
5539 .set_mem_title("ext", Some("x".into()), None)
5540 .unwrap_err(),
5541 ),
5542 (
5543 "set_mem_subject",
5544 engine.set_mem_subject("ext", None, None).unwrap_err(),
5545 ),
5546 (
5547 "set_mem_internal",
5548 engine.set_mem_internal("ext", true, None).unwrap_err(),
5549 ),
5550 (
5551 "set_mem_sync_state",
5552 engine
5553 .set_mem_sync_state("ext", "k", "t", None)
5554 .unwrap_err(),
5555 ),
5556 ];
5557 for (setter, err) in attempts {
5558 match err {
5559 EngineError::ReadOnlyMount(v) => {
5560 assert_eq!(v, "ext", "{setter} must name the refused mem")
5561 }
5562 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5563 }
5564 }
5565 }
5566
5567 #[test]
5573 fn no_config_setter_reverts_a_siblings_write() {
5574 type Setter = fn(&mut Engine) -> Result<(), EngineError>;
5575 let setters: Vec<(&str, Setter)> = vec![
5576 ("version", |e| {
5577 e.set_mem_version("specs", semver::Version::new(9, 0, 0), None)
5578 .map(|_| ())
5579 }),
5580 ("description", |e| {
5581 e.set_mem_description("specs", Some("mine".into()), None)
5582 .map(|_| ())
5583 }),
5584 ("title", |e| {
5585 e.set_mem_title("specs", Some("Mine".into()), None)
5586 .map(|_| ())
5587 }),
5588 ("internal", |e| {
5589 e.set_mem_internal("specs", true, None).map(|_| ())
5590 }),
5591 ("sync_state", |e| {
5592 e.set_mem_sync_state("specs", "src/facet", "tok", None)
5593 .map(|_| ())
5594 }),
5595 ("review_mark", |e| {
5599 e.set_review_mark("specs", None, None).map(|_| ())
5600 }),
5601 ];
5602
5603 for (name, set) in setters {
5604 let tmp = TempDir::new().unwrap();
5605 let mem_dir = tmp.path().to_path_buf();
5606 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5607 std::fs::create_dir_all(&meta).unwrap();
5608 let path = meta.join("config.json");
5609 std::fs::write(
5610 &path,
5611 br#"{"schema": "default@1.0.0", "version": "0.1.0"}"#.as_slice(),
5612 )
5613 .unwrap();
5614
5615 let writer = FilesystemMemWriter::new(mem_dir.clone());
5616 let mut engine = Engine::from_mounts(vec![(
5617 folder_mount("specs", mem_dir.clone()),
5618 Box::new(writer) as Box<dyn MemBackend>,
5619 )])
5620 .unwrap();
5621 engine
5624 .create_entity(
5625 crate::engine::test_helpers::empty_create_args("specs", "Seed"),
5626 crate::vcs::Actor::Cli,
5627 None,
5628 None,
5629 )
5630 .unwrap();
5631
5632 let mut sibling: memstead_schema::MemConfig =
5634 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5635 sibling
5636 .extra
5637 .insert("siblingMark".into(), serde_json::json!("kept"));
5638 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5639
5640 set(&mut engine).unwrap_or_else(|e| panic!("{name} setter failed: {e}"));
5641
5642 let after: memstead_schema::MemConfig =
5643 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5644 assert_eq!(
5645 after.extra.get("siblingMark"),
5646 Some(&serde_json::json!("kept")),
5647 "the {name} setter reverted a field it never set"
5648 );
5649 }
5650 }
5651
5652 #[test]
5655 fn intervention_is_reported_on_the_response_and_only_when_real() {
5656 let tmp = TempDir::new().unwrap();
5657 let mem_dir = tmp.path().to_path_buf();
5658 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5659 std::fs::create_dir_all(&meta).unwrap();
5660 let path = meta.join("config.json");
5661 std::fs::write(&path, br#"{"schema": "default@1.0.0"}"#.as_slice()).unwrap();
5662 let writer = FilesystemMemWriter::new(mem_dir.clone());
5663 let mut engine = Engine::from_mounts(vec![(
5664 folder_mount("specs", mem_dir.clone()),
5665 Box::new(writer) as Box<dyn MemBackend>,
5666 )])
5667 .unwrap();
5668
5669 let quiet = engine
5672 .set_mem_description("specs", Some("first".into()), None)
5673 .unwrap();
5674 assert!(
5675 !quiet
5676 .warnings
5677 .iter()
5678 .any(|w| w.code() == "CONFIG_WRITE_INTERVENED"),
5679 "single-writer workspace must stay silent: {:?}",
5680 quiet.warnings
5681 );
5682
5683 let mut sibling: memstead_schema::MemConfig =
5685 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5686 sibling.title = Some("theirs".into());
5687 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5688
5689 let loud = engine
5690 .set_mem_description("specs", Some("second".into()), None)
5691 .unwrap();
5692 let hint = loud
5693 .warnings
5694 .iter()
5695 .find(|w| w.code() == "CONFIG_WRITE_INTERVENED")
5696 .expect("intervention must be reported on the response");
5697 assert!(
5698 format!("{hint}").contains("title"),
5699 "the report names what they changed: {hint}"
5700 );
5701 let after: memstead_schema::MemConfig =
5703 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5704 assert_eq!(after.title.as_deref(), Some("theirs"));
5705 assert_eq!(after.description.as_deref(), Some("second"));
5706 }
5707}