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 stamped_schema: self.stamped_schema_of(mount_idx),
1250 });
1251 }
1252 self.mounts[mount_idx].mount.schema = Some(target.clone());
1253 self.mounts[mount_idx].mount.migration_target = None;
1254 self.persist_state()?;
1255 return Ok(SetSchemaOutcome {
1256 mem: mem.to_string(),
1257 schema_pin: target.as_display(),
1258 migration_target: None,
1259 outcome: SetSchemaResult::Switched,
1260 findings: Vec::new(),
1261 stamped_schema: self.stamped_schema_of(mount_idx),
1262 });
1263 }
1264
1265 let findings = crate::ops::integrity::conformance_findings(
1271 &self.store,
1272 mem,
1273 target_schema.as_ref(),
1274 &self.schemas,
1275 );
1276
1277 if findings.is_empty() {
1278 self.persist_mem_schema_pin(mount_idx, target)?;
1286 self.mounts[mount_idx].mount.schema = Some(target.clone());
1287 self.mounts[mount_idx].mount.migration_target = None;
1288 self.schemas_insert(mem.to_string(), target_schema);
1289 self.invalidate_communities();
1290 self.invalidate_search_indexes();
1295 self.persist_state()?;
1296 let _stamp_warnings_have_no_channel_here = self.stamp_mutation_versions(mount_idx);
1306 return Ok(SetSchemaOutcome {
1307 mem: mem.to_string(),
1308 schema_pin: target.as_display(),
1309 migration_target: None,
1310 outcome: SetSchemaResult::Switched,
1311 findings: Vec::new(),
1312 stamped_schema: self.stamped_schema_of(mount_idx),
1313 });
1314 }
1315
1316 let outcome = if in_flight.as_ref() == Some(target) {
1317 SetSchemaResult::MigrationPending
1318 } else {
1319 SetSchemaResult::MigrationStarted
1320 };
1321 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1322 self.schemas_insert(mem.to_string(), target_schema);
1325 self.invalidate_communities();
1326 self.invalidate_search_indexes();
1328 self.persist_state()?;
1329 Ok(SetSchemaOutcome {
1333 mem: mem.to_string(),
1334 schema_pin: current_pin_display,
1335 migration_target: Some(target.as_display()),
1336 outcome,
1337 findings,
1338 stamped_schema: self.stamped_schema_of(mount_idx),
1339 })
1340 }
1341
1342 fn stamped_schema_of(&self, mount_idx: usize) -> Option<String> {
1346 self.mounts
1347 .get(mount_idx)
1348 .and_then(|m| m.mem_config.as_ref())
1349 .and_then(|c| c.mutation_stamp.as_ref())
1350 .map(|st| st.schema.clone())
1351 }
1352
1353 pub(crate) fn write_mem_config_merged(
1402 &mut self,
1403 mount_idx: usize,
1404 mem_name: &str,
1405 note: Option<&str>,
1406 apply: &dyn Fn(&mut memstead_schema::config::MemConfig),
1407 ) -> Result<(memstead_schema::config::MemConfig, Vec<String>), EngineError> {
1408 let backend = self.mounts[mount_idx].backend.as_ref();
1409 let read = |b: &dyn crate::backend::MemBackend| -> Result<Vec<u8>, EngineError> {
1410 b.read_mem_config()
1411 .map_err(|e| EngineError::Mem(format!("read mem config for update: {e}")))?
1412 .ok_or_else(|| {
1413 EngineError::InvalidInput(format!(
1414 "mem '{mem_name}' has no stored MemConfig (initialize the mem via \
1415 `memstead init` or `memstead mem create` first)"
1416 ))
1417 })
1418 };
1419
1420 let stored = read(backend)?;
1421 let intervened = match self.mounts[mount_idx].mem_config.as_ref() {
1426 Some(cached) => changed_config_fields(cached, &stored),
1427 None => Vec::new(),
1428 };
1429
1430 let render =
1431 |raw: &[u8]| -> Result<(memstead_schema::config::MemConfig, Vec<u8>), EngineError> {
1432 let value: serde_json::Value = serde_json::from_slice(raw)
1433 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1434 let mut cfg = memstead_schema::config::parse_mem_config(&value)
1435 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1436 apply(&mut cfg);
1437 let mut bytes = serde_json::to_vec_pretty(&cfg)
1438 .map_err(|e| EngineError::Mem(format!("serialize mem config: {e}")))?;
1439 bytes.push(b'\n');
1440 Ok((cfg, bytes))
1441 };
1442 let (mut parsed, mut bytes) = render(&stored)?;
1443
1444 let mut expected = stored;
1453 for attempt in 0..8 {
1454 let wrote = self.mounts[mount_idx].backend.write_mem_config_cas(
1455 Some(&expected),
1456 &bytes,
1457 note,
1458 )?;
1459 if wrote {
1460 break;
1461 }
1462 if attempt == 7 {
1463 return Err(EngineError::Mem(format!(
1464 "mem '{mem_name}' config is being written concurrently: eight \
1465 compare-and-set attempts all lost the race. Retry, or find the \
1466 writer that is not backing off."
1467 )));
1468 }
1469 expected = read(self.mounts[mount_idx].backend.as_ref())?;
1470 let rendered = render(&expected)?;
1471 parsed = rendered.0;
1472 bytes = rendered.1;
1473 }
1474
1475 let mounted = &mut self.mounts[mount_idx];
1476 mounted.mem_config = Some(parsed.clone());
1477 if let Some(sha) = mounted.backend.current_head().ok().flatten() {
1480 mounted.last_known_head = Some(sha);
1481 }
1482 Ok((parsed, intervened))
1483 }
1484
1485 fn persist_mem_schema_pin(
1486 &mut self,
1487 mount_idx: usize,
1488 target: &memstead_schema::SchemaRef,
1489 ) -> Result<(), EngineError> {
1490 let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1491 if let Some(value) = value
1494 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1495 {
1496 self.mounts[mount_idx].mem_config = Some(cfg);
1497 }
1498 Ok(())
1499 }
1500
1501 pub fn export_markdown(
1530 &self,
1531 mem_filter: Option<&str>,
1532 schema_filter: Option<&str>,
1533 ) -> Result<crate::ops::ExportResult, EngineError> {
1534 use crate::workspace::MountStorage;
1535 let fallback = engine_fallback_type();
1536 let supported_backends = vec!["folder".to_string()];
1537
1538 if let Some(name) = mem_filter {
1539 let mount = self
1540 .mounts
1541 .iter()
1542 .find(|m| m.mount.mem == name)
1543 .ok_or_else(|| self.unknown_mem_error(name))?;
1544 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1545 return Err(EngineError::MarkdownExportUnsupportedBackend {
1546 mem: name.to_string(),
1547 active_backend: mount.mount.storage.backend_id().to_string(),
1548 supported_backends,
1549 });
1550 }
1551 }
1552
1553 let mut total_written = 0;
1554 let mut refused: Vec<crate::ops::RefusedEntity> = Vec::new();
1555 let mut total_unchanged = 0;
1556 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1557
1558 for mount in &self.mounts {
1559 let mem_name = mount.mount.mem.as_str();
1560 if let Some(filter) = mem_filter
1561 && mem_name != filter
1562 {
1563 continue;
1564 }
1565 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1566 skipped_mounts.push(crate::ops::SkippedMount {
1567 mem: mem_name.to_string(),
1568 active_backend: mount.mount.storage.backend_id().to_string(),
1569 reason: "backend_does_not_support_markdown_export".to_string(),
1570 });
1571 continue;
1572 };
1573 let schema = match self.schemas.get(mem_name) {
1574 Some(s) => s,
1575 None => continue,
1576 };
1577
1578 for entity in self.store.all_entities() {
1579 if entity.stub || entity.file_path.is_empty() {
1580 continue;
1581 }
1582 if entity.id.mem() != mem_name {
1583 continue;
1584 }
1585 if let Some(filter) = schema_filter
1586 && entity.entity_type != filter
1587 {
1588 continue;
1589 }
1590 let type_def = schema
1591 .get_type(&entity.entity_type)
1592 .unwrap_or_else(|| fallback.clone());
1593 let generated = generate_markdown(entity, type_def.as_ref());
1594
1595 let full_path = mem_dir.join(&entity.file_path);
1596 let needs_write = match std::fs::read_to_string(&full_path) {
1597 Ok(existing) => existing != generated,
1598 Err(_) => true,
1599 };
1600 if needs_write {
1601 match crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref()) {
1602 Ok(_) => total_written += 1,
1603 Err(e @ crate::entity::writer::WriteError::UnterminatedFence { .. }) => {
1608 refused.push(crate::ops::RefusedEntity {
1609 id: entity.id.to_string(),
1610 reason: "UNTERMINATED_FENCE_IN_STORED_BODY".to_string(),
1611 detail: e.to_string(),
1612 });
1613 }
1614 Err(_) => {}
1615 }
1616 } else {
1617 total_unchanged += 1;
1618 }
1619 }
1620 }
1621
1622 Ok(crate::ops::ExportResult {
1623 refused_entities: refused,
1624 written: total_written,
1625 unchanged: total_unchanged,
1626 skipped_mounts,
1627 })
1628 }
1629
1630 pub(crate) fn ref_schema_source_for(
1657 &self,
1658 config: &memstead_schema::MemConfig,
1659 ) -> Option<Vec<memstead_schema::SchemaSourceFile>> {
1660 let ops = self.git_branch_ops.as_ref()?;
1661 let root = self.workspace_root.as_deref()?;
1662 let pin = config.schema.as_ref()?;
1663 (ops.collect_ref_schema_source)(root, pin).ok().flatten()
1664 }
1665
1666 pub fn export_mem(
1667 &self,
1668 mem_name: &str,
1669 output_path: &std::path::Path,
1670 ) -> Result<crate::ops::MemExportResult, EngineError> {
1671 let mount = self
1672 .mounts
1673 .iter()
1674 .find(|m| m.mount.mem == mem_name)
1675 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1676 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1677 EngineError::InvalidInput(format!(
1678 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1679 ))
1680 })?;
1681 if config.version.is_none() {
1689 return Err(EngineError::MemConfigIncomplete {
1690 mem: mem_name.to_string(),
1691 missing_fields: vec!["version".to_string()],
1692 });
1693 }
1694 let fenced: Vec<String> = self
1700 .store
1701 .all_entities()
1702 .filter(|e| e.mem == mem_name && !e.stub)
1703 .filter(|e| {
1704 e.sections
1705 .values()
1706 .any(|v| crate::markdown::closing_fence_if_unterminated(v.trim()).is_some())
1707 })
1708 .map(|e| e.id.to_string())
1709 .collect();
1710 let workspace_root = self.workspace_root.as_deref();
1711 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1715 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1716 let exported = match &mount.mount.storage {
1717 MountStorage::Folder { path } => crate::ops::export::export_mem(
1718 path,
1719 config,
1720 output_path,
1721 workspace_root,
1722 workspace_schemas_dir,
1723 self.ref_schema_source_for(config),
1724 )
1725 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1726 MountStorage::GitBranch { gitdir, branch } => {
1727 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1728 EngineError::Backend(BackendError::Other(
1729 "git-branch export hook not installed (full flavour not loaded)"
1730 .to_string(),
1731 ))
1732 })?;
1733 let (provenance, redactions) = mount
1737 .backend
1738 .read_provenance(None)
1739 .ok()
1740 .map(|records| crate::ops::export::build_redacted_archive_provenance(&records))
1741 .unwrap_or((None, Vec::new()));
1742 let provenance_bytes = provenance.and_then(|prov| prov.to_archive_bytes().ok());
1743 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1746 (hook.export)(
1747 gitdir,
1748 branch,
1749 mem_name,
1750 config,
1751 output_path,
1752 workspace_root,
1753 workspace_schemas_dir,
1754 provenance_bytes.as_deref(),
1755 anchors_bytes.as_deref(),
1756 )
1757 .map(|mut r| {
1758 r.redactions = redactions;
1759 r
1760 })
1761 .map_err(EngineError::Backend)
1762 }
1763 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1764 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1769 "export not yet supported for in-memory backend".to_string(),
1770 ))),
1771 };
1772 exported.map(|mut r| {
1773 r.unterminated_fence_entities = fenced;
1774 r
1775 })
1776 }
1777
1778 pub fn record_pipeline_edit_provenance(
1802 &self,
1803 mem: &str,
1804 kind: &str,
1805 edits: &[(String, Option<Vec<u8>>)],
1806 note: Option<&str>,
1807 verb: &str,
1808 ) -> Result<(), crate::backend::BackendError> {
1809 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1810 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1811 None => Ok(()),
1812 }
1813 }
1814
1815 pub fn set_mem_version(
1816 &mut self,
1817 mem_name: &str,
1818 new_version: semver::Version,
1819 note: Option<&str>,
1820 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1821 let mount_idx = self
1824 .mounts
1825 .iter()
1826 .position(|m| m.mount.mem == mem_name)
1827 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1828 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1829 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1830 }
1831
1832 let mut warnings = self.reload_if_stale(Some(mem_name));
1840 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1845 warnings.push(w);
1846 }
1847
1848 let old_version = self.mounts[mount_idx]
1852 .mem_config
1853 .as_ref()
1854 .and_then(|c| c.version.clone());
1855 let target = new_version.clone();
1856 let (_, intervened) = self.write_mem_config_merged(
1857 mount_idx,
1858 mem_name,
1859 note,
1860 &move |c: &mut memstead_schema::config::MemConfig| {
1861 c.version = Some(target.clone());
1862 },
1863 )?;
1864 if !intervened.is_empty() {
1865 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1866 mem: mem_name.to_string(),
1867 fields: intervened,
1868 });
1869 }
1870
1871 Ok(crate::ops::SetMemVersionOutcome {
1872 mem: mem_name.to_string(),
1873 old_version,
1874 new_version,
1875 warnings,
1876 })
1877 }
1878
1879 pub fn set_mem_description(
1886 &mut self,
1887 mem_name: &str,
1888 new_description: Option<String>,
1889 note: Option<&str>,
1890 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1891 let mount_idx = self
1892 .mounts
1893 .iter()
1894 .position(|m| m.mount.mem == mem_name)
1895 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1896 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1897 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1898 }
1899
1900 let mut warnings = self.reload_if_stale(Some(mem_name));
1901 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1902 warnings.push(w);
1903 }
1904
1905 let old_description = self.mounts[mount_idx]
1906 .mem_config
1907 .as_ref()
1908 .and_then(|c| c.description.clone());
1909 let target = new_description.clone();
1910 let (_, intervened) = self.write_mem_config_merged(
1911 mount_idx,
1912 mem_name,
1913 note,
1914 &move |c: &mut memstead_schema::config::MemConfig| {
1915 c.description = target.clone();
1916 },
1917 )?;
1918 if !intervened.is_empty() {
1919 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1920 mem: mem_name.to_string(),
1921 fields: intervened,
1922 });
1923 }
1924
1925 Ok(crate::ops::SetMemDescriptionOutcome {
1926 mem: mem_name.to_string(),
1927 old_description,
1928 new_description,
1929 warnings,
1930 })
1931 }
1932
1933 pub fn set_mem_title(
1938 &mut self,
1939 mem_name: &str,
1940 new_title: Option<String>,
1941 note: Option<&str>,
1942 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1943 let mount_idx = self
1944 .mounts
1945 .iter()
1946 .position(|m| m.mount.mem == mem_name)
1947 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1948 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1949 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1950 }
1951
1952 let mut warnings = self.reload_if_stale(Some(mem_name));
1953 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1954 warnings.push(w);
1955 }
1956
1957 let old_title = self.mounts[mount_idx]
1958 .mem_config
1959 .as_ref()
1960 .and_then(|c| c.title.clone());
1961 let target = new_title.clone();
1962 let (_, intervened) = self.write_mem_config_merged(
1963 mount_idx,
1964 mem_name,
1965 note,
1966 &move |c: &mut memstead_schema::config::MemConfig| {
1967 c.title = target.clone();
1968 },
1969 )?;
1970 if !intervened.is_empty() {
1971 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1972 mem: mem_name.to_string(),
1973 fields: intervened,
1974 });
1975 }
1976
1977 Ok(crate::ops::SetMemTitleOutcome {
1978 mem: mem_name.to_string(),
1979 old_title,
1980 new_title,
1981 warnings,
1982 })
1983 }
1984
1985 pub fn set_mem_subject(
1989 &mut self,
1990 mem_name: &str,
1991 new_subject: Option<memstead_schema::MemSubject>,
1992 note: Option<&str>,
1993 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1994 let mount_idx = self
1995 .mounts
1996 .iter()
1997 .position(|m| m.mount.mem == mem_name)
1998 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1999 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2000 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2001 }
2002
2003 let mut warnings = self.reload_if_stale(Some(mem_name));
2004 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
2005 warnings.push(w);
2006 }
2007
2008 let old_subject = self.mounts[mount_idx]
2009 .mem_config
2010 .as_ref()
2011 .and_then(|c| c.subject.clone());
2012 let target = new_subject.clone();
2013 let (_, intervened) = self.write_mem_config_merged(
2014 mount_idx,
2015 mem_name,
2016 note,
2017 &move |c: &mut memstead_schema::config::MemConfig| {
2018 c.subject = target.clone();
2019 },
2020 )?;
2021 if !intervened.is_empty() {
2022 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2023 mem: mem_name.to_string(),
2024 fields: intervened,
2025 });
2026 }
2027
2028 Ok(crate::ops::SetMemSubjectOutcome {
2029 mem: mem_name.to_string(),
2030 old_subject,
2031 new_subject,
2032 warnings,
2033 })
2034 }
2035
2036 pub fn set_mem_internal(
2048 &mut self,
2049 mem_name: &str,
2050 internal: bool,
2051 note: Option<&str>,
2052 ) -> Result<crate::ops::SetMemInternalOutcome, EngineError> {
2053 let mount_idx = self
2054 .mounts
2055 .iter()
2056 .position(|m| m.mount.mem == mem_name)
2057 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2058 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2059 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2060 }
2061
2062 let _ = self.reload_if_stale(Some(mem_name));
2063
2064 let (_, intervened) = self.write_mem_config_merged(
2065 mount_idx,
2066 mem_name,
2067 note,
2068 &move |c: &mut memstead_schema::config::MemConfig| {
2069 if internal {
2070 c.extra
2071 .insert("internal".to_string(), serde_json::Value::Bool(true));
2072 } else {
2073 c.extra.remove("internal");
2074 }
2075 },
2076 )?;
2077 let mut warnings = Vec::new();
2082 if !intervened.is_empty() {
2083 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2084 mem: mem_name.to_string(),
2085 fields: intervened,
2086 });
2087 }
2088
2089 Ok(crate::ops::SetMemInternalOutcome {
2090 mem: mem_name.to_string(),
2091 internal,
2092 warnings,
2093 })
2094 }
2095
2096 pub fn set_mem_sync_state(
2121 &mut self,
2122 mem_name: &str,
2123 key: &str,
2124 token: &str,
2125 note: Option<&str>,
2126 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
2127 let mount_idx = self
2130 .mounts
2131 .iter()
2132 .position(|m| m.mount.mem == mem_name)
2133 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2134 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2135 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2136 }
2137
2138 let mut warnings = self.reload_if_stale(Some(mem_name));
2142 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
2143 warnings.push(w);
2144 }
2145
2146 let seen: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
2153 let key_owned = key.to_string();
2154 let token_owned = token.to_string();
2155 let (_, intervened) = self.write_mem_config_merged(
2156 mount_idx,
2157 mem_name,
2158 note,
2159 &|c: &mut memstead_schema::config::MemConfig| {
2160 *seen.borrow_mut() = if token_owned.is_empty() {
2161 c.sync_state.remove(&key_owned)
2162 } else {
2163 c.sync_state.insert(key_owned.clone(), token_owned.clone())
2164 };
2165 },
2166 )?;
2167 let previous = seen.into_inner();
2168 let removed = token.is_empty() && previous.is_some();
2171 if !intervened.is_empty() {
2172 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2173 mem: mem_name.to_string(),
2174 fields: intervened,
2175 });
2176 }
2177
2178 Ok(crate::ops::SetMemSyncStateOutcome {
2179 mem: mem_name.to_string(),
2180 key: key.to_string(),
2181 previous,
2182 removed,
2183 warnings,
2184 })
2185 }
2186
2187 pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
2234 let pending: Vec<String> = self
2235 .mounts
2236 .iter()
2237 .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
2238 .map(|m| m.mount.mem.clone())
2239 .collect();
2240 for name in pending {
2241 match self.reload_one_mem(&name) {
2242 Ok(_) => {
2243 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
2244 state.deferred = false;
2245 }
2246 let store = &self.store;
2252 self.load_warnings.retain(|w| match w {
2253 crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
2254 store.get(resolved_id).is_none_or(|e| e.stub)
2255 }
2256 _ => true,
2257 });
2258 }
2259 Err(e) => {
2260 let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
2265 continue;
2266 };
2267 let removed = self.mounts.remove(idx);
2268 self.schemas_remove(&name);
2269 self.quarantined.push(crate::engine::QuarantinedMem {
2270 mount: removed.mount,
2271 reason_code: e.code().to_string(),
2272 reason_message: e.to_string(),
2273 });
2274 self.mem_router = std::sync::Arc::new(
2275 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2276 );
2277 self.invalidate_communities();
2278 self.invalidate_search_indexes();
2284 }
2285 }
2286 }
2287 }
2288
2289 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2290 if self.quarantine_reason(mem).is_some() {
2313 return self.reattach_quarantined_mem(mem);
2314 }
2315 let mut sink: Vec<WarningHint> = Vec::new();
2316 let result = self.reload_one_mem_inner(mem, &mut sink)?;
2317 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2318 self.load_warnings
2319 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2320 Ok(result)
2321 }
2322
2323 fn set_schema_on_quarantined(
2333 &mut self,
2334 mem: &str,
2335 target: &memstead_schema::SchemaRef,
2336 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2337 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2338 if self.resolve_schema_by_ref(target).is_none() {
2340 let consulted: Vec<_> = self
2341 .workspace_schemas
2342 .iter()
2343 .chain(self.builtin_schemas.iter())
2344 .cloned()
2345 .collect();
2346 return Err(EngineError::SchemaNotFound {
2347 mem: mem.to_string(),
2348 pin: target.as_display(),
2349 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2350 &target.name,
2351 &target.version,
2352 &consulted,
2353 ),
2354 install_hint: None,
2355 }
2356 .with_schema_install_probe(self.workspace_root()));
2357 }
2358 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2359 return Err(self.unknown_mem_error(mem));
2360 };
2361 self.quarantined[q_idx].mount.schema = Some(target.clone());
2365 self.quarantined[q_idx].mount.migration_target = None;
2366 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2367 let _ = bump_backend_schema_pin(backend.as_ref(), target);
2368 }
2369 self.persist_state()?;
2370 let _ = self.reattach_quarantined_mem(mem);
2375 let stamped_schema = self
2380 .mounts
2381 .iter()
2382 .position(|m| m.mount.mem == mem)
2383 .and_then(|idx| self.stamped_schema_of(idx));
2384 Ok(SetSchemaOutcome {
2385 mem: mem.to_string(),
2386 schema_pin: target.as_display(),
2387 migration_target: None,
2388 outcome: SetSchemaResult::Switched,
2389 findings: Vec::new(),
2390 stamped_schema,
2391 })
2392 }
2393
2394 fn reattach_quarantined_mem(
2402 &mut self,
2403 mem: &str,
2404 ) -> Result<crate::ops::ReloadResult, EngineError> {
2405 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2406 return Err(self.unknown_mem_error(mem));
2407 };
2408 let mount = self.quarantined[q_idx].mount.clone();
2409
2410 let requarantine = |this: &mut Self, e: &EngineError| {
2411 this.quarantined[q_idx].reason_code = e.code().to_string();
2412 this.quarantined[q_idx].reason_message = e.to_string();
2413 };
2414
2415 let backend = match (self.backend_factory)(&mount) {
2416 Ok(b) => b,
2417 Err(e) => {
2418 let err = EngineError::Mem(e.to_string());
2419 requarantine(self, &err);
2420 return Err(self.unknown_mem_error(mem));
2421 }
2422 };
2423
2424 let last_known_head = backend.current_head().ok().flatten();
2426 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2427 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2428 memstead_schema::config::parse_mem_config(&value).ok()
2429 });
2430 let archive_provenance = backend
2431 .read_archive_provenance()
2432 .ok()
2433 .flatten()
2434 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2435 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2436 let effective_pin = mount
2437 .migration_target
2438 .clone()
2439 .or(config_pin)
2440 .or(mount.schema.clone());
2441 let Some(effective_pin) = effective_pin else {
2442 let err = EngineError::MemConfigIncomplete {
2443 mem: mem.to_string(),
2444 missing_fields: vec!["schema".to_string()],
2445 };
2446 requarantine(self, &err);
2447 return Err(self.unknown_mem_error(mem));
2448 };
2449 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2450 .workspace_schemas
2451 .iter()
2452 .chain(self.builtin_schemas.iter())
2453 .cloned()
2454 .collect();
2455 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2456 Ok(s) => s,
2457 Err(sources) => {
2458 let err = EngineError::SchemaNotFound {
2459 mem: mem.to_string(),
2460 pin: effective_pin.as_display(),
2461 sources,
2462 install_hint: None,
2463 }
2464 .with_schema_install_probe(self.workspace_root());
2465 requarantine(self, &err);
2466 return Err(self.unknown_mem_error(mem));
2467 }
2468 };
2469
2470 self.quarantined.remove(q_idx);
2474 self.schemas_insert(mem.to_string(), schema);
2475 self.mounts.push(crate::engine::MountedBackend {
2476 mount,
2477 backend,
2478 last_known_head,
2479 mem_config,
2480 archive_provenance,
2481 deferred: false,
2484 });
2485 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2486 &self.mounts,
2487 ));
2488 let mut sink: Vec<WarningHint> = Vec::new();
2489 match self.reload_one_mem_inner(mem, &mut sink) {
2490 Ok(result) => {
2491 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2492 self.load_warnings
2493 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2494 self.invalidate_communities();
2495 Ok(result)
2496 }
2497 Err(e) => {
2498 let mount_idx = self.mounts.len() - 1;
2499 let mounted = self.mounts.remove(mount_idx);
2500 self.schemas_remove(mem);
2501 self.mem_router = std::sync::Arc::new(
2502 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2503 );
2504 self.quarantined.push(crate::engine::QuarantinedMem {
2505 mount: mounted.mount,
2506 reason_code: e.code().to_string(),
2507 reason_message: e.to_string(),
2508 });
2509 self.invalidate_communities();
2513 self.invalidate_search_indexes();
2514 Err(e)
2515 }
2516 }
2517 }
2518
2519 fn reload_one_mem_inner(
2525 &mut self,
2526 mem: &str,
2527 warnings_sink: &mut Vec<WarningHint>,
2528 ) -> Result<crate::ops::ReloadResult, EngineError> {
2529 let mount_idx = self
2532 .mounts
2533 .iter()
2534 .position(|m| m.mount.mem == mem)
2535 .ok_or_else(|| self.unknown_mem_error(mem))?;
2536 let schema = self
2537 .schemas
2538 .get(mem)
2539 .cloned()
2540 .ok_or_else(|| self.unknown_mem_error(mem))?;
2541
2542 let pre: HashMap<EntityId, String> = self
2544 .store
2545 .all_entities()
2546 .filter(|e| !e.stub && e.mem == mem)
2547 .map(|e| (e.id.clone(), e.content_hash.clone()))
2548 .collect();
2549 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2550
2551 let backend = self.mounts[mount_idx].backend.as_ref();
2554 let (entries, read_errors) = collect_source_entries(backend)?;
2555 let unbacked = super::boot::unbacked_mount_warning(
2560 &self.mounts[mount_idx].mount,
2561 backend,
2562 Some(entries.len()),
2563 );
2564 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2565
2566 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2570 let known_suffixes: Vec<String> = mem_names
2571 .iter()
2572 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2573 .collect();
2574
2575 self.store.remove_entities_by_mem(mem);
2577 if let Some(w) = unbacked {
2578 warnings_sink.push(w);
2579 }
2580 let fallback = engine_fallback_type();
2581 push_entities_into_store(
2582 &mut self.store,
2583 load_result.entities,
2584 fallback.as_ref(),
2585 Some(crate::entity::store_builder::LoadCollector {
2586 warnings: warnings_sink,
2587 known_suffixes: &known_suffixes,
2588 mem_names: &mem_names,
2589 }),
2590 );
2591 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2598 .mounts
2599 .iter()
2600 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2601 .collect();
2602 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2610 crate::entity::store_builder::validate_loaded_relations(
2611 &mut self.store,
2612 &self.schemas,
2613 &mount_caps,
2614 warnings_sink,
2615 );
2616 crate::entity::store_builder::remap_alias_target_edge_sources(
2617 &mut self.store,
2618 &self.schemas,
2619 );
2620 if let crate::workspace::MountStorage::Folder { path } =
2630 &self.mounts[mount_idx].mount.storage
2631 {
2632 let root = path.clone();
2633 self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2634 self.load_errors
2638 .extend(load_result.errors.into_iter().map(|(p, m)| {
2639 let abs = if p.is_relative() { root.join(&p) } else { p };
2640 (abs, m)
2641 }));
2642 } else {
2643 self.load_errors.extend(load_result.errors);
2644 }
2645
2646 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2654 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2655 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2656 {
2657 self.mounts[mount_idx].mem_config = Some(cfg);
2658 }
2659
2660 let mut added: Vec<EntityId> = Vec::new();
2662 let mut changed: Vec<EntityId> = Vec::new();
2663 for entity in self.store.all_entities() {
2664 if entity.stub || entity.mem != mem {
2665 continue;
2666 }
2667 match pre.get(&entity.id) {
2668 None => added.push(entity.id.clone()),
2669 Some(prev_hash) if prev_hash != &entity.content_hash => {
2670 changed.push(entity.id.clone());
2671 }
2672 Some(_) => {}
2673 }
2674 }
2675 let post_ids: std::collections::HashSet<EntityId> = self
2676 .store
2677 .all_entities()
2678 .filter(|e| !e.stub && e.mem == mem)
2679 .map(|e| e.id.clone())
2680 .collect();
2681 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2682 added.sort_by(|a, b| a.0.cmp(&b.0));
2683 changed.sort_by(|a, b| a.0.cmp(&b.0));
2684 removed.sort_by(|a, b| a.0.cmp(&b.0));
2685
2686 self.invalidate_communities();
2687 self.invalidate_search_indexes();
2688
2689 Ok(crate::ops::ReloadResult {
2690 added,
2691 changed,
2692 removed,
2693 })
2694 }
2695
2696 pub fn reload_one_mem_report(
2724 &mut self,
2725 mem: &str,
2726 ) -> Result<crate::ops::ReloadReport, EngineError> {
2727 let tracks_head = self
2738 .mounts
2739 .iter()
2740 .find(|m| m.mount.mem == mem)
2741 .and_then(|m| m.backend.current_head().ok().flatten())
2742 .is_some();
2743 let head_before = if tracks_head {
2744 self.mounts
2745 .iter()
2746 .find(|m| m.mount.mem == mem)
2747 .and_then(|m| m.last_known_head.clone())
2748 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2749 } else {
2750 crate::ops::EMPTY_TREE_SHA.to_string()
2751 };
2752
2753 let result = self.reload_one_mem(mem)?;
2754
2755 let head_after_raw = self
2762 .mounts
2763 .iter()
2764 .find(|m| m.mount.mem == mem)
2765 .and_then(|m| m.backend.current_head().ok().flatten());
2766 if let Some(new_head) = head_after_raw.clone()
2767 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2768 {
2769 m.last_known_head = Some(new_head);
2770 }
2771 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2772
2773 let entities_loaded = self
2774 .store
2775 .all_entities()
2776 .filter(|e| !e.stub && e.mem == mem)
2777 .count();
2778
2779 let mut changed_entity_ids: Vec<EntityId> = result
2784 .added
2785 .into_iter()
2786 .chain(result.changed)
2787 .chain(result.removed)
2788 .collect();
2789 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2790
2791 Ok(crate::ops::ReloadReport {
2792 mem: mem.to_string(),
2793 head_before,
2794 head_after,
2795 entities_loaded,
2796 changed_entity_ids,
2797 })
2798 }
2799
2800 pub fn reload_each_writable_mem_reports(
2840 &mut self,
2841 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2842 self.refresh_workspace_settings_if_possible();
2843 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2844 let mut out = Vec::with_capacity(names.len());
2845 for name in names {
2846 let report = self.reload_one_mem_report(&name)?;
2847 out.push(report);
2848 }
2849 Ok(out)
2850 }
2851
2852 fn refresh_workspace_settings_if_possible(&mut self) {
2864 let Some(root) = self.workspace_root.clone() else {
2865 return;
2866 };
2867 let store = crate::workspace_store::FileWorkspaceStore::new();
2868 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2869 Ok(w) => w,
2870 Err(_) => return,
2871 };
2872 self.set_settings(workspace.settings);
2873 }
2874
2875 pub fn reload_each_writable_mem(
2891 &mut self,
2892 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2893 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2894 let mut sink = std::mem::take(&mut self.load_warnings);
2900 sink.clear();
2901 let mut out = Vec::with_capacity(names.len());
2902 let mut loop_err = None;
2903 for name in names {
2904 match self.reload_one_mem_inner(&name, &mut sink) {
2905 Ok(result) => out.push((name, result)),
2906 Err(e) => {
2907 loop_err = Some(e);
2908 break;
2909 }
2910 }
2911 }
2912 self.load_warnings = sink;
2913 if let Some(e) = loop_err {
2914 return Err(e);
2915 }
2916 Ok(out)
2917 }
2918}
2919
2920fn changed_config_fields(
2939 cached: &memstead_schema::config::MemConfig,
2940 stored_bytes: &[u8],
2941) -> Vec<String> {
2942 let (Ok(a), Ok(b)) = (
2943 serde_json::to_value(cached),
2944 serde_json::from_slice::<serde_json::Value>(stored_bytes),
2945 ) else {
2946 return Vec::new();
2947 };
2948 let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
2949 return Vec::new();
2950 };
2951 let mut keys: std::collections::BTreeSet<&String> = a.keys().collect();
2952 keys.extend(b.keys());
2953 keys.into_iter()
2954 .filter(|k| a.get(*k) != b.get(*k))
2955 .map(|k| k.to_string())
2956 .collect()
2957}
2958
2959pub fn bump_backend_schema_pin(
2960 backend: &dyn crate::backend::MemBackend,
2961 target: &memstead_schema::SchemaRef,
2962) -> Result<Option<serde_json::Value>, EngineError> {
2963 let Some(bytes) = backend
2964 .read_mem_config()
2965 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2966 else {
2967 return Ok(None);
2968 };
2969 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2970 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2971 value["schema"] = serde_json::Value::String(target.as_display());
2972 let new_bytes = serde_json::to_vec_pretty(&value)
2973 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2974 backend
2975 .write_mem_config(&new_bytes)
2976 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2977 Ok(Some(value))
2978}
2979
2980fn merge_mount_rosters(
2994 baseline: &[crate::workspace::Mount],
2995 ours: &[crate::workspace::Mount],
2996 on_disk: Vec<crate::workspace::Mount>,
2997) -> Vec<crate::workspace::Mount> {
2998 use std::collections::{HashMap, HashSet};
2999
3000 let ours_names: HashSet<&str> = ours.iter().map(|m| m.mem.as_str()).collect();
3001 let removed_by_us: HashSet<&str> = baseline
3002 .iter()
3003 .map(|m| m.mem.as_str())
3004 .filter(|n| !ours_names.contains(n))
3005 .collect();
3006 let baseline_by_name: HashMap<&str, &crate::workspace::Mount> =
3007 baseline.iter().map(|m| (m.mem.as_str(), m)).collect();
3008
3009 let mut merged: Vec<crate::workspace::Mount> = on_disk
3010 .into_iter()
3011 .filter(|m| !removed_by_us.contains(m.mem.as_str()))
3012 .collect();
3013
3014 for mount in ours {
3015 let untouched_by_us = baseline_by_name
3016 .get(mount.mem.as_str())
3017 .is_some_and(|b| *b == mount);
3018 match merged.iter_mut().find(|d| d.mem == mount.mem) {
3019 Some(slot) => {
3020 if !untouched_by_us {
3021 *slot = mount.clone();
3022 }
3023 }
3024 None => merged.push(mount.clone()),
3025 }
3026 }
3027 merged
3028}
3029
3030#[cfg(test)]
3031mod tests {
3032
3033 use tempfile::TempDir;
3034
3035 use crate::backend::{BackendError, MemBackend};
3036 use crate::engine::test_helpers::*;
3037 use crate::engine::{Engine, EngineError};
3038 use crate::mem::MemOrigin;
3039 use crate::ops::WarningHint;
3040 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
3041
3042 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
3043 let manifest = format!(
3044 r#"name: {manifest_name}
3045version: 1.0.0
3046description: Install-gate test schema
3047when_to_use: Tests
3048types:
3049 - sample
3050relationships:
3051 mode: strict
3052 definitions:
3053 - name: PART_OF
3054 description: hier
3055 default_weight: 3.0
3056 - name: _default
3057 description: fallback
3058 default_weight: 1.0
3059community:
3060 resolution: 1.0
3061 seed: 42
3062"#
3063 );
3064 let type_yaml = format!(
3065 r#"name: sample
3066description: t
3067when_to_use: tests
3068sections:
3069 - key: body
3070 heading: {heading}
3071 required: true
3072 search_weight: 10.0
3073 catch_all: true
3074 write_rules: []
3075metadata_fields: []
3076title_weight: 100.0
3077text_fields:
3078 - body
3079hierarchy_relationship: PART_OF
3080no_self_loop_relationships: []
3081updatable_fields:
3082 - title
3083 - body
3084health_required_fields:
3085 - body
3086staleness_threshold_days: 90
3087write_rules: []
3088"#
3089 );
3090 vec![
3091 ("schema.yaml".to_string(), manifest.into_bytes()),
3092 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3093 ]
3094 }
3095
3096 #[test]
3100 fn install_gate_refuses_non_roundtrip_heading() {
3101 let ok =
3102 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
3103 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
3104
3105 let err = Engine::validate_schema_package(
3106 "gate",
3107 "1.0.0",
3108 &schema_package_files("Body Text", "gate"),
3109 )
3110 .expect_err("non-deriving heading must refuse install");
3111 match &err {
3112 EngineError::SchemaPackageInvalid { name, message, .. } => {
3113 assert_eq!(name, "gate");
3114 assert!(
3115 message.contains("'body'") && message.contains("'Body Text'"),
3116 "message names the offending tuple: {message}"
3117 );
3118 }
3119 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3120 }
3121 }
3122
3123 fn exemplar_package_files(
3130 section_key: &str,
3131 status_value: &str,
3132 rel_type: &str,
3133 ) -> Vec<(String, Vec<u8>)> {
3134 let manifest = r#"name: gate
3135version: 1.0.0
3136description: exemplar gate fixture
3137when_to_use: tests
3138types:
3139 - sample
3140 - other
3141relationships:
3142 mode: strict
3143 definitions:
3144 - name: PART_OF
3145 description: hier
3146 default_weight: 3.0
3147 - name: REFINES
3148 description: pinned
3149 default_weight: 1.0
3150 source_types: [other]
3151 - name: _default
3152 description: fallback
3153 default_weight: 1.0
3154community:
3155 resolution: 1.0
3156 seed: 42
3157"#
3158 .to_string();
3159 let type_yaml = format!(
3160 r#"name: sample
3161description: t
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:
3171 - key: status
3172 description: workflow state
3173 field_type: string
3174 enum_values: [draft, final]
3175title_weight: 100.0
3176text_fields:
3177 - body
3178hierarchy_relationship: PART_OF
3179no_self_loop_relationships: []
3180updatable_fields:
3181 - title
3182 - body
3183health_required_fields:
3184 - body
3185staleness_threshold_days: 90
3186write_rules: []
3187exemplar:
3188 title: A Conforming Sample
3189 metadata:
3190 status: "{status_value}"
3191 sections:
3192 {section_key}: "One canonical body paragraph."
3193 relations:
3194 - to: parent-placeholder
3195 type: {rel_type}
3196"#
3197 );
3198 let other_yaml = r#"name: other
3199description: shape-pin partner
3200when_to_use: tests
3201sections:
3202 - key: body
3203 heading: Body
3204 required: true
3205 search_weight: 10.0
3206 catch_all: true
3207 write_rules: []
3208metadata_fields: []
3209title_weight: 100.0
3210text_fields:
3211 - body
3212hierarchy_relationship: PART_OF
3213no_self_loop_relationships: []
3214updatable_fields:
3215 - title
3216 - body
3217health_required_fields:
3218 - body
3219staleness_threshold_days: 90
3220write_rules: []
3221"#
3222 .to_string();
3223 vec![
3224 ("schema.yaml".to_string(), manifest.into_bytes()),
3225 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3226 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
3227 ]
3228 }
3229
3230 #[test]
3237 fn install_gate_validates_exemplars_through_the_real_create_path() {
3238 let ok = Engine::validate_schema_package(
3240 "gate",
3241 "1.0.0",
3242 &exemplar_package_files("body", "draft", "PART_OF"),
3243 );
3244 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
3245
3246 let err = Engine::validate_schema_package(
3248 "gate",
3249 "1.0.0",
3250 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
3251 )
3252 .expect_err("wrong section key must refuse");
3253 match &err {
3254 EngineError::SchemaPackageInvalid { message, .. } => {
3255 assert!(
3256 message.contains("'sample'") && message.contains("exemplar"),
3257 "names type and calls out the exemplar: {message}"
3258 );
3259 assert!(
3260 message.contains("UNKNOWN_SECTION")
3261 || message.contains("MISSING_REQUIRED_SECTION"),
3262 "carries the typed defect code: {message}"
3263 );
3264 }
3265 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3266 }
3267
3268 let err = Engine::validate_schema_package(
3270 "gate",
3271 "1.0.0",
3272 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
3273 )
3274 .expect_err("illegal enum value must refuse");
3275 assert!(
3276 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3277 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
3278 "got {err:?}"
3279 );
3280
3281 let err = Engine::validate_schema_package(
3284 "gate",
3285 "1.0.0",
3286 &exemplar_package_files("body", "draft", "REFINES"),
3287 )
3288 .expect_err("relationship shape violation must refuse");
3289 assert!(
3290 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3291 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
3292 "got {err:?}"
3293 );
3294 }
3295
3296 #[test]
3301 fn worked_example_package_exemplars_validate() {
3302 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3303 .join("../memstead-schema/examples/minimal");
3304 let schema = std::sync::Arc::new(
3305 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
3306 );
3307 assert!(
3308 schema.types.values().all(|td| td.exemplar.is_some()),
3309 "every worked-example type models the exemplar practice"
3310 );
3311 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
3312 }
3313
3314 #[test]
3321 fn builtin_exemplars_validate_through_the_install_gate() {
3322 let schemas = memstead_schema::builtins::load_builtin_schemas()
3323 .expect("built-in schemas always load");
3324 for schema in &schemas {
3326 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
3327 let (name, version) = schema.id();
3328 panic!("built-in {name}@{version}: {defect}");
3329 }
3330 }
3331 let mut newest: std::collections::HashMap<
3333 String,
3334 &std::sync::Arc<memstead_schema::Schema>,
3335 > = std::collections::HashMap::new();
3336 for schema in &schemas {
3337 let name = schema.manifest.name.clone();
3338 match newest.get(&name) {
3339 Some(cur) if cur.version >= schema.version => {}
3340 _ => {
3341 newest.insert(name, schema);
3342 }
3343 }
3344 }
3345 for (name, schema) in &newest {
3346 for (type_name, td) in &schema.types {
3347 assert!(
3348 td.exemplar.is_some(),
3349 "built-in {name}@{} type '{type_name}' has no exemplar — the \
3350 reference schemas model the practice completely",
3351 schema.version
3352 );
3353 }
3354 }
3355 }
3356
3357 #[test]
3362 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
3363 let mut files = exemplar_package_files("body", "draft", "PART_OF");
3364 let patched = String::from_utf8(files[1].1.clone())
3365 .unwrap()
3366 .replace("to: parent-placeholder", "to: other--real-entity");
3367 files[1].1 = patched.into_bytes();
3368 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3369 .expect_err("mem-prefixed exemplar target must refuse");
3370 assert!(
3371 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3372 if message.contains("bare") && message.contains("'sample'")),
3373 "got {err:?}"
3374 );
3375 }
3376
3377 #[test]
3381 fn install_gate_refuses_manifest_identity_mismatch() {
3382 let err = Engine::validate_schema_package(
3383 "gate",
3384 "1.0.0",
3385 &schema_package_files("Body", "other"),
3386 )
3387 .expect_err("identity mismatch must refuse install");
3388 assert!(
3389 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3390 if message.contains("other@1.0.0")),
3391 "got {err:?}"
3392 );
3393 }
3394
3395 #[test]
3396 fn reload_each_writable_mem_repopulates_load_warnings() {
3397 let tmp = TempDir::new().unwrap();
3401 let mem_dir = tmp.path().to_path_buf();
3402 let writer = FilesystemMemWriter::new(mem_dir.clone());
3403 let mut mount = folder_mount("specs", mem_dir.clone());
3406 mount.schema = Some("default@1.3.0".parse().unwrap());
3407 let mut engine =
3408 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3409 assert!(
3412 engine
3413 .load_warnings()
3414 .iter()
3415 .all(|w| w.code() == "MOUNT_UNBACKED"),
3416 "clean boot has no warnings beyond the empty-mount one: {:?}",
3417 engine.load_warnings()
3418 );
3419
3420 let body =
3422 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3423 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3424
3425 engine.reload_each_writable_mem().unwrap();
3426 let warnings = engine.load_warnings();
3427 assert!(
3428 warnings
3429 .iter()
3430 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3431 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3432 );
3433 }
3434
3435 #[test]
3442 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3443 let tmp = TempDir::new().unwrap();
3444 let mem_dir = tmp.path().to_path_buf();
3445 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3447 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3448 let writer = FilesystemMemWriter::new(mem_dir.clone());
3449 let mut engine = Engine::from_mounts(vec![(
3450 folder_mount("specs", mem_dir.clone()),
3451 Box::new(writer) as Box<dyn MemBackend>,
3452 )])
3453 .unwrap();
3454 assert!(
3456 !engine
3457 .load_warnings()
3458 .iter()
3459 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3460 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3461 engine.load_warnings()
3462 );
3463
3464 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";
3466 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3467
3468 engine.reload_each_writable_mem().unwrap();
3469
3470 let invalid: Vec<_> = engine
3471 .load_warnings()
3472 .iter()
3473 .filter_map(|w| match w {
3474 WarningHint::ParsedRelationInvalid {
3475 rel_type,
3476 reason,
3477 origin,
3478 ..
3479 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3480 _ => None,
3481 })
3482 .collect();
3483 assert_eq!(
3484 invalid.len(),
3485 1,
3486 "reload must surface the parse-time drift, got: {invalid:?}",
3487 );
3488 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3489 assert_eq!(invalid[0].1, "unknown_rel_type");
3490 assert_eq!(invalid[0].2, "writable");
3491 }
3492
3493 #[test]
3494 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3495 let tmp = TempDir::new().unwrap();
3502 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3503 let a_dir = tmp.path().join("a");
3504 std::fs::create_dir_all(&a_dir).unwrap();
3505 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3506 let b_dir = tmp.path().join("b");
3507 std::fs::create_dir_all(&b_dir).unwrap();
3508 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3509 let mut engine = Engine::from_mounts(vec![
3510 (
3511 folder_mount("alpha", a_dir.clone()),
3512 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3513 ),
3514 (
3515 folder_mount("beta", b_dir.clone()),
3516 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3517 ),
3518 ])
3519 .unwrap();
3520 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3521 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3522 assert!(
3523 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3524 "boot must populate one warning per mem: {pre:?}"
3525 );
3526
3527 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3529 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3530 engine.reload_one_mem("alpha").unwrap();
3531
3532 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3533 assert!(
3534 !post.contains(&"alpha".to_string()),
3535 "reload must drop the healed mem's stale warning: {post:?}"
3536 );
3537 assert!(
3538 post.contains(&"beta".to_string()),
3539 "reload of alpha must not clear beta's slice: {post:?}"
3540 );
3541 }
3542
3543 #[test]
3544 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3545 let tmp = TempDir::new().unwrap();
3550 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3551 let a_dir = tmp.path().join("a");
3552 std::fs::create_dir_all(&a_dir).unwrap();
3553 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3554 let b_dir = tmp.path().join("b");
3555 std::fs::create_dir_all(&b_dir).unwrap();
3556 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3557 let mut engine = Engine::from_mounts(vec![
3558 (
3559 folder_mount("alpha", a_dir.clone()),
3560 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3561 ),
3562 (
3563 folder_mount("beta", b_dir.clone()),
3564 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3565 ),
3566 ])
3567 .unwrap();
3568 assert!(
3569 engine
3570 .load_warnings()
3571 .iter()
3572 .any(|w| w.source_mem() == Some("alpha")),
3573 "boot must carry alpha-sourced warnings"
3574 );
3575
3576 engine.unregister_writable_mem("alpha").unwrap();
3577
3578 let post = engine.load_warnings();
3579 assert!(
3580 !post.iter().any(|w| w.source_mem() == Some("alpha")),
3581 "delete must purge the removed mem's warnings: {post:?}"
3582 );
3583 assert!(
3584 post.iter().any(|w| w.source_mem() == Some("beta")),
3585 "delete of alpha must keep beta's warnings: {post:?}"
3586 );
3587 }
3588
3589 #[test]
3590 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3591 let tmp = TempDir::new().unwrap();
3597 let a_dir = tmp.path().join("a");
3598 std::fs::create_dir_all(&a_dir).unwrap();
3599 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";
3600 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3601 let b_dir = tmp.path().join("b");
3602 std::fs::create_dir_all(&b_dir).unwrap();
3603 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3604 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3605 let mut engine = Engine::from_mounts(vec![
3606 (
3607 folder_mount("alpha", a_dir.clone()),
3608 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3609 ),
3610 (
3611 folder_mount("beta", b_dir.clone()),
3612 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3613 ),
3614 ])
3615 .unwrap();
3616 let alpha_sourced = |engine: &Engine| {
3617 engine
3618 .load_warnings()
3619 .iter()
3620 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3621 };
3622 assert!(
3623 alpha_sourced(&engine),
3624 "boot must flag alpha's invalid row: {:?}",
3625 engine.load_warnings()
3626 );
3627
3628 engine.unregister_writable_mem("beta").unwrap();
3629
3630 assert!(
3631 alpha_sourced(&engine),
3632 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3633 engine.load_warnings()
3634 );
3635 }
3636
3637 #[test]
3645 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3646 let tmp = TempDir::new().unwrap();
3647 let mem_dir = tmp.path().to_path_buf();
3648 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3649 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3650 let writer = FilesystemMemWriter::new(mem_dir.clone());
3651 let mut engine = Engine::from_mounts(vec![(
3652 folder_mount("specs", mem_dir.clone()),
3653 Box::new(writer) as Box<dyn MemBackend>,
3654 )])
3655 .unwrap();
3656 assert!(
3657 !engine.load_warnings().is_empty(),
3658 "boot must populate load_warnings"
3659 );
3660
3661 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3664 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3665 engine.reload_each_writable_mem_reports().unwrap();
3666 assert!(
3667 engine.load_warnings().is_empty(),
3668 "reports sweep must drop healed warnings: {:?}",
3669 engine.load_warnings()
3670 );
3671
3672 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3674 engine.reload_each_writable_mem_reports().unwrap();
3675 assert!(
3676 engine
3677 .load_warnings()
3678 .iter()
3679 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3680 "reports sweep must surface fresh drift: {:?}",
3681 engine.load_warnings()
3682 );
3683 }
3684
3685 #[test]
3693 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3694 let tmp = TempDir::new().unwrap();
3695 let a_dir = tmp.path().join("a");
3696 let b_dir = tmp.path().join("b");
3697 std::fs::create_dir_all(&a_dir).unwrap();
3698 std::fs::create_dir_all(&b_dir).unwrap();
3699 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3700 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3701 let mut engine = Engine::from_mounts(vec![
3702 (
3703 folder_mount("specs", a_dir),
3704 Box::new(a_writer) as Box<dyn MemBackend>,
3705 ),
3706 (
3707 folder_mount("memos", b_dir),
3708 Box::new(b_writer) as Box<dyn MemBackend>,
3709 ),
3710 ])
3711 .unwrap();
3712
3713 let mut settings = crate::workspace::WorkspaceSettings::default();
3715 settings.cross_mem_links.insert(
3716 "specs".to_string(),
3717 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3718 );
3719 engine.set_settings(settings);
3720
3721 let (actor, client) = cli_actor();
3722 let source = engine
3723 .create_entity(
3724 empty_create_args("specs", "Source"),
3725 actor,
3726 Some(&client),
3727 None,
3728 )
3729 .unwrap();
3730 let target = engine
3731 .create_entity(
3732 empty_create_args("memos", "Target"),
3733 actor,
3734 Some(&client),
3735 None,
3736 )
3737 .unwrap();
3738 engine
3739 .relate_entity(
3740 crate::engine::RelateEntityArgs {
3741 source: source.id.clone(),
3742 expected_hash: Some(source.content_hash.clone()),
3743 rel_type: "USES".to_string(),
3744 target: target.id.clone(),
3745 remove: false,
3746 description: None,
3747 dry_run: false,
3748 },
3749 actor,
3750 Some(&client),
3751 None,
3752 )
3753 .unwrap();
3754
3755 let has_edge = |e: &Engine| {
3757 let out = e
3758 .store()
3759 .outgoing(&source.id)
3760 .iter()
3761 .any(|edge| edge.target == target.id);
3762 let inc = e
3763 .store()
3764 .incoming(&target.id)
3765 .iter()
3766 .any(|edge| edge.from == source.id);
3767 (out, inc)
3768 };
3769
3770 assert_eq!(
3771 has_edge(&engine),
3772 (true, true),
3773 "edge must be indexed in both directions after relate",
3774 );
3775
3776 engine.reload_one_mem("memos").unwrap();
3778 assert_eq!(
3779 has_edge(&engine),
3780 (true, true),
3781 "cross-mem edge into B must survive a per-mem reload of B",
3782 );
3783
3784 engine.reload_each_writable_mem().unwrap();
3787 assert_eq!(
3788 has_edge(&engine),
3789 (true, true),
3790 "per-mem and workspace reload converge on the same edge",
3791 );
3792
3793 assert!(
3796 engine
3797 .store()
3798 .get(&source.id)
3799 .unwrap()
3800 .relationships
3801 .iter()
3802 .any(|r| r.target == target.id),
3803 "source record must retain the relationship throughout",
3804 );
3805 }
3806
3807 #[test]
3813 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3814 let tmp = TempDir::new().unwrap();
3815 let a_dir = tmp.path().join("a");
3816 let b_dir = tmp.path().join("b");
3817 std::fs::create_dir_all(&a_dir).unwrap();
3818 std::fs::create_dir_all(&b_dir).unwrap();
3819 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3820 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3821 let mut engine = Engine::from_mounts(vec![
3822 (
3823 folder_mount("specs", a_dir),
3824 Box::new(a_writer) as Box<dyn MemBackend>,
3825 ),
3826 (
3827 folder_mount("memos", b_dir),
3828 Box::new(b_writer) as Box<dyn MemBackend>,
3829 ),
3830 ])
3831 .unwrap();
3832 let mut settings = crate::workspace::WorkspaceSettings::default();
3833 settings.cross_mem_links.insert(
3834 "specs".to_string(),
3835 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3836 );
3837 engine.set_settings(settings);
3838
3839 let (actor, client) = cli_actor();
3840 let source = engine
3841 .create_entity(
3842 empty_create_args("specs", "Source"),
3843 actor,
3844 Some(&client),
3845 None,
3846 )
3847 .unwrap();
3848 let target = engine
3849 .create_entity(
3850 empty_create_args("memos", "Target"),
3851 actor,
3852 Some(&client),
3853 None,
3854 )
3855 .unwrap();
3856 engine
3857 .relate_entity(
3858 crate::engine::RelateEntityArgs {
3859 source: source.id.clone(),
3860 expected_hash: Some(source.content_hash.clone()),
3861 rel_type: "USES".to_string(),
3862 target: target.id.clone(),
3863 remove: false,
3864 description: None,
3865 dry_run: false,
3866 },
3867 actor,
3868 Some(&client),
3869 None,
3870 )
3871 .unwrap();
3872
3873 engine.reload_one_mem("specs").unwrap();
3874
3875 let out = engine
3876 .store()
3877 .outgoing(&source.id)
3878 .iter()
3879 .any(|edge| edge.target == target.id);
3880 let inc = engine
3881 .store()
3882 .incoming(&target.id)
3883 .iter()
3884 .any(|edge| edge.from == source.id);
3885 assert!(
3886 out && inc,
3887 "outgoing cross-mem edge must survive a source-mem reload"
3888 );
3889 }
3890
3891 #[test]
3892 fn workspace_root_setter_round_trips() {
3893 let tmp = TempDir::new().unwrap();
3894 let mem_dir = tmp.path().to_path_buf();
3895 let writer = FilesystemMemWriter::new(mem_dir.clone());
3896 let mut engine = Engine::from_mounts(vec![(
3897 folder_mount("specs", mem_dir),
3898 Box::new(writer) as Box<dyn MemBackend>,
3899 )])
3900 .unwrap();
3901 let root = tmp.path().to_path_buf();
3902 engine.set_workspace_root(root.clone());
3903 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3904 }
3905
3906 #[test]
3907 fn export_mem_folder_backend_produces_archive() {
3908 let tmp = TempDir::new().unwrap();
3912 let mem_dir = tmp.path().join("specs");
3913 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3914 let config_body = r#"{
3915 "format": 1,
3916 "schema": "default@1.0.0",
3917 "version": "1.0.0"
3918 }"#;
3919 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3920
3921 let writer = FilesystemMemWriter::new(mem_dir.clone());
3922 let engine = Engine::from_mounts(vec![(
3923 folder_mount("specs", mem_dir.clone()),
3924 Box::new(writer) as Box<dyn MemBackend>,
3925 )])
3926 .unwrap();
3927
3928 let archive_path = tmp.path().join("specs.mem");
3929 let result = engine.export_mem("specs", &archive_path).unwrap();
3930 assert!(archive_path.exists(), "archive must exist on disk");
3931 assert!(result.size_bytes > 0);
3932 assert_eq!(result.entity_count, 0);
3935 }
3936
3937 #[test]
3938 fn export_mem_unknown_mem_returns_unknown_mem() {
3939 let tmp = TempDir::new().unwrap();
3940 let mem_dir = tmp.path().to_path_buf();
3941 let writer = FilesystemMemWriter::new(mem_dir.clone());
3942 let engine = Engine::from_mounts(vec![(
3943 folder_mount("specs", mem_dir),
3944 Box::new(writer) as Box<dyn MemBackend>,
3945 )])
3946 .unwrap();
3947 let output = tmp.path().join("out.mem");
3948 let err = engine.export_mem("missing", &output).unwrap_err();
3949 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3950 }
3951
3952 #[test]
3953 fn export_mem_missing_config_returns_invalid_input() {
3954 let tmp = TempDir::new().unwrap();
3958 let mem_dir = tmp.path().to_path_buf();
3959 let writer = FilesystemMemWriter::new(mem_dir.clone());
3960 let engine = Engine::from_mounts(vec![(
3961 folder_mount("specs", mem_dir),
3962 Box::new(writer) as Box<dyn MemBackend>,
3963 )])
3964 .unwrap();
3965 let output = tmp.path().join("out.mem");
3966 let err = engine.export_mem("specs", &output).unwrap_err();
3967 assert!(matches!(err, EngineError::InvalidInput(_)));
3968 }
3969
3970 #[test]
3971 fn export_mem_archive_backend_returns_sealed() {
3972 let tmp = TempDir::new().unwrap();
3975 let archive_path = build_archive(
3976 tmp.path(),
3977 "ext",
3978 &[(
3979 ".memstead/config.json",
3980 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3981 )],
3982 );
3983 let engine = Engine::from_mounts(vec![(
3984 archive_mount("ext", archive_path.clone()),
3985 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3986 )])
3987 .unwrap();
3988 let output = tmp.path().join("out.mem");
3989 let err = engine.export_mem("ext", &output).unwrap_err();
3990 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3991 }
3992
3993 #[test]
3994 fn export_markdown_writes_unchanged_files_zero_writes() {
3995 let tmp = TempDir::new().unwrap();
4000 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
4001 let result = engine.export_markdown(None, None).unwrap();
4002 assert_eq!(
4003 result.written, 0,
4004 "freshly-created entity's file already matches generated markdown"
4005 );
4006 assert_eq!(
4007 result.unchanged, 1,
4008 "the one seeded entity counts as unchanged"
4009 );
4010 assert!(
4011 result.skipped_mounts.is_empty(),
4012 "folder-only workspace has no skipped mounts"
4013 );
4014 }
4015
4016 #[test]
4017 fn export_markdown_skips_non_folder_mounts() {
4018 let tmp = TempDir::new().unwrap();
4022 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4023 let engine = Engine::from_mounts(vec![(
4024 archive_mount("ext", archive_path.clone()),
4025 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4026 )])
4027 .unwrap();
4028 let result = engine.export_markdown(None, None).unwrap();
4029 assert_eq!(result.written, 0);
4030 assert_eq!(result.unchanged, 0);
4031 assert_eq!(
4032 result.skipped_mounts.len(),
4033 1,
4034 "archive mount is in the skipped list"
4035 );
4036 let entry = &result.skipped_mounts[0];
4037 assert_eq!(entry.mem, "ext");
4038 assert_eq!(entry.active_backend, "archive");
4039 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
4040 }
4041
4042 #[test]
4043 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
4044 let tmp = TempDir::new().unwrap();
4048 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4049 let engine = Engine::from_mounts(vec![(
4050 archive_mount("ext", archive_path.clone()),
4051 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4052 )])
4053 .unwrap();
4054 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
4055 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
4056 let details = err.details();
4057 assert_eq!(details["mem"], "ext");
4058 assert_eq!(details["active_backend"], "archive");
4059 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
4060 }
4061
4062 #[test]
4063 fn register_writable_mem_adds_mount_and_router_entry() {
4064 let tmp = TempDir::new().unwrap();
4067 let mem_a = tmp.path().join("a");
4068 std::fs::create_dir_all(&mem_a).unwrap();
4069 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4070
4071 let mut engine = Engine::from_mounts(vec![(
4072 folder_mount("alpha", mem_a),
4073 Box::new(writer_a) as Box<dyn MemBackend>,
4074 )])
4075 .unwrap();
4076 assert!(engine.mem_router().is_writable("alpha"));
4077
4078 let mem_b = tmp.path().join("b");
4079 std::fs::create_dir_all(&mem_b).unwrap();
4080 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4081
4082 engine
4083 .register_writable_mem(
4084 folder_mount("beta", mem_b.clone()),
4085 Box::new(writer_b) as Box<dyn MemBackend>,
4086 MemOrigin::ExplicitToml,
4087 )
4088 .unwrap();
4089
4090 assert!(engine.mem_router().is_writable("alpha"));
4092 assert!(engine.mem_router().is_writable("beta"));
4093 assert!(engine.mem_router().is_visible("beta"));
4094
4095 assert!(engine.mount("beta").is_some());
4097 assert!(engine.schemas().contains_key("beta"));
4098
4099 assert_eq!(
4101 engine.mem_router().dir_for_mem("beta"),
4102 Some(mem_b.as_path()),
4103 );
4104 }
4105
4106 #[test]
4112 fn register_writable_mem_resolves_schema_from_mem_config() {
4113 let tmp = TempDir::new().unwrap();
4114 let mem_a = tmp.path().join("a");
4115 std::fs::create_dir_all(&mem_a).unwrap();
4116 let mut engine = Engine::from_mounts(vec![(
4117 folder_mount("alpha", mem_a.clone()),
4118 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
4119 )])
4120 .unwrap();
4121
4122 let mem_b = tmp.path().join("b");
4123 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
4124 std::fs::write(
4125 mem_b.join(".memstead").join("config.json"),
4126 r#"{"schema":"software@0.1.0"}"#,
4127 )
4128 .unwrap();
4129 let mount_b = crate::workspace::Mount {
4130 mem: "beta".to_string(),
4131 schema: Some(memstead_schema::SchemaRef::new(
4132 "totally-not-a-schema",
4133 semver::Version::new(9, 9, 9),
4134 )),
4135 storage: crate::workspace::MountStorage::Folder {
4136 path: mem_b.clone(),
4137 },
4138 capability: crate::workspace::MountCapability::Write,
4139 lifecycle: crate::workspace::MountLifecycle::Eager,
4140 cross_linkable: true,
4141 migration_target: None,
4142 };
4143 engine
4144 .register_writable_mem(
4145 mount_b,
4146 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
4147 MemOrigin::ExplicitToml,
4148 )
4149 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
4150
4151 assert!(engine.schemas().contains_key("beta"));
4152 let surfaced = engine.load_warnings().iter().any(|w| {
4153 matches!(
4154 w,
4155 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
4156 if mem == "beta"
4157 && config_pin == "software@0.1.0"
4158 && mount_pin == "totally-not-a-schema@9.9.9"
4159 )
4160 });
4161 assert!(
4162 surfaced,
4163 "SchemaPinMismatch must surface for beta: {:?}",
4164 engine.load_warnings(),
4165 );
4166 }
4167
4168 #[test]
4169 fn register_writable_mem_rejects_existing_name() {
4170 let tmp = TempDir::new().unwrap();
4173 let mem_a = tmp.path().join("a");
4174 std::fs::create_dir_all(&mem_a).unwrap();
4175 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4176
4177 let mut engine = Engine::from_mounts(vec![(
4178 folder_mount("alpha", mem_a),
4179 Box::new(writer_a) as Box<dyn MemBackend>,
4180 )])
4181 .unwrap();
4182 let mount_count_pre = engine.mounts().len();
4183
4184 let mem_collide = tmp.path().join("alpha-2");
4185 std::fs::create_dir_all(&mem_collide).unwrap();
4186 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
4187
4188 let err = engine
4189 .register_writable_mem(
4190 folder_mount("alpha", mem_collide),
4191 Box::new(writer_collide) as Box<dyn MemBackend>,
4192 MemOrigin::ExplicitToml,
4193 )
4194 .unwrap_err();
4195 match err {
4196 EngineError::MemNameCollision {
4197 name,
4198 source_origin,
4199 } => {
4200 assert_eq!(name, "alpha");
4201 assert!(
4206 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
4207 );
4208 }
4209 other => panic!("expected MemNameCollision, got {other:?}"),
4210 }
4211
4212 assert_eq!(engine.mounts().len(), mount_count_pre);
4214 }
4215
4216 #[test]
4217 fn register_writable_mem_loads_entities_into_store() {
4218 let tmp = TempDir::new().unwrap();
4221 let mem_a = tmp.path().join("a");
4222 std::fs::create_dir_all(&mem_a).unwrap();
4223 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4224
4225 let mut engine = Engine::from_mounts(vec![(
4226 folder_mount("alpha", mem_a),
4227 Box::new(writer_a) as Box<dyn MemBackend>,
4228 )])
4229 .unwrap();
4230 let pre_count = engine.store().all_entities().count();
4231
4232 let mem_b = tmp.path().join("b");
4234 std::fs::create_dir_all(&mem_b).unwrap();
4235 std::fs::write(
4236 mem_b.join("b1.md"),
4237 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4238 )
4239 .unwrap();
4240 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4241
4242 engine
4243 .register_writable_mem(
4244 folder_mount("beta", mem_b),
4245 Box::new(writer_b) as Box<dyn MemBackend>,
4246 MemOrigin::ExplicitToml,
4247 )
4248 .unwrap();
4249
4250 let post_count = engine.store().all_entities().count();
4251 assert!(post_count > pre_count, "register must load entities");
4252 let beta_count = engine
4253 .store()
4254 .all_entities()
4255 .filter(|e| e.mem == "beta")
4256 .count();
4257 assert_eq!(beta_count, 1);
4258 }
4259
4260 #[test]
4261 fn register_then_unregister_round_trips() {
4262 let tmp = TempDir::new().unwrap();
4266 let mem_a = tmp.path().join("a");
4267 std::fs::create_dir_all(&mem_a).unwrap();
4268 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4269
4270 let mut engine = Engine::from_mounts(vec![(
4271 folder_mount("alpha", mem_a),
4272 Box::new(writer_a) as Box<dyn MemBackend>,
4273 )])
4274 .unwrap();
4275 let pre_mounts = engine.mounts().len();
4276
4277 let mem_b = tmp.path().join("b");
4278 std::fs::create_dir_all(&mem_b).unwrap();
4279 let writer_b = FilesystemMemWriter::new(mem_b);
4280
4281 engine
4282 .register_writable_mem(
4283 folder_mount("beta", tmp.path().join("b")),
4284 Box::new(writer_b) as Box<dyn MemBackend>,
4285 MemOrigin::ExplicitToml,
4286 )
4287 .unwrap();
4288 assert_eq!(engine.mounts().len(), pre_mounts + 1);
4289
4290 let removed = engine.unregister_writable_mem("beta").unwrap();
4291 assert!(removed.is_some());
4292 assert_eq!(engine.mounts().len(), pre_mounts);
4293 assert!(!engine.mem_router().is_writable("beta"));
4294 }
4295
4296 #[test]
4297 fn unregister_writable_mem_returns_false_for_unknown_name() {
4298 let tmp = TempDir::new().unwrap();
4302 let mem_dir = tmp.path().to_path_buf();
4303 let writer = FilesystemMemWriter::new(mem_dir.clone());
4304 let mut engine = Engine::from_mounts(vec![(
4305 folder_mount("specs", mem_dir),
4306 Box::new(writer) as Box<dyn MemBackend>,
4307 )])
4308 .unwrap();
4309 let removed = engine.unregister_writable_mem("missing").unwrap();
4310 assert!(removed.is_none(), "unknown mem returns Ok(None)");
4311 assert!(engine.mem_router().is_writable("specs"));
4313 }
4314
4315 #[test]
4316 fn unregister_writable_mem_drops_mount_and_router_entry() {
4317 let tmp = TempDir::new().unwrap();
4322 let mem_a = tmp.path().join("a");
4323 std::fs::create_dir_all(&mem_a).unwrap();
4324 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4325 let mem_b = tmp.path().join("b");
4326 std::fs::create_dir_all(&mem_b).unwrap();
4327 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4328
4329 let mut engine = Engine::from_mounts(vec![
4330 (
4331 folder_mount("alpha", mem_a),
4332 Box::new(writer_a) as Box<dyn MemBackend>,
4333 ),
4334 (
4335 folder_mount("beta", mem_b),
4336 Box::new(writer_b) as Box<dyn MemBackend>,
4337 ),
4338 ])
4339 .unwrap();
4340
4341 let removed = engine.unregister_writable_mem("alpha").unwrap();
4342 assert!(removed.is_some());
4343
4344 assert!(!engine.mem_router().is_writable("alpha"));
4346 assert!(!engine.mem_router().is_visible("alpha"));
4347 assert!(engine.mount("alpha").is_none());
4348
4349 assert!(engine.mem_router().is_writable("beta"));
4351 assert!(engine.mount("beta").is_some());
4352 }
4353
4354 #[test]
4355 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
4356 let tmp = TempDir::new().unwrap();
4360 let mem_a = tmp.path().join("a");
4361 std::fs::create_dir_all(&mem_a).unwrap();
4362 std::fs::write(
4363 mem_a.join("a1.md"),
4364 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
4365 )
4366 .unwrap();
4367 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4368
4369 let mem_b = tmp.path().join("b");
4370 std::fs::create_dir_all(&mem_b).unwrap();
4371 std::fs::write(
4372 mem_b.join("b1.md"),
4373 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4374 )
4375 .unwrap();
4376 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4377
4378 let mut engine = Engine::from_mounts(vec![
4379 (
4380 folder_mount("alpha", mem_a),
4381 Box::new(writer_a) as Box<dyn MemBackend>,
4382 ),
4383 (
4384 folder_mount("beta", mem_b),
4385 Box::new(writer_b) as Box<dyn MemBackend>,
4386 ),
4387 ])
4388 .unwrap();
4389
4390 let pre_total = engine.store().all_entities().count();
4391 assert!(pre_total >= 2, "both mems must load entities");
4392
4393 engine.unregister_writable_mem("alpha").unwrap();
4394
4395 let alpha_remaining = engine
4397 .store()
4398 .all_entities()
4399 .filter(|e| e.mem == "alpha")
4400 .count();
4401 assert_eq!(alpha_remaining, 0);
4402
4403 let beta_remaining = engine
4405 .store()
4406 .all_entities()
4407 .filter(|e| e.mem == "beta")
4408 .count();
4409 assert!(beta_remaining > 0, "beta entities must survive");
4410 }
4411 #[test]
4412 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4413 let tmp = TempDir::new().unwrap();
4414 let mut engine = build_demo_engine(&tmp);
4415 let result = engine
4416 .reload_one_mem("specs")
4417 .expect("reload on stable disk must succeed");
4418 assert!(result.added.is_empty(), "added: {:?}", result.added);
4419 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4420 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4421 }
4422
4423 #[test]
4424 fn reload_one_mem_picks_up_external_addition() {
4425 let tmp = TempDir::new().unwrap();
4426 let mut engine = build_demo_engine(&tmp);
4427 std::fs::write(
4430 tmp.path().join("external.md"),
4431 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4432 )
4433 .unwrap();
4434 let result = engine.reload_one_mem("specs").unwrap();
4435 assert_eq!(
4436 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4437 vec!["specs--external"]
4438 );
4439 assert!(result.changed.is_empty());
4440 assert!(result.removed.is_empty());
4441 assert!(
4443 engine
4444 .get_entity(&crate::EntityId::new("specs", "external"))
4445 .is_some()
4446 );
4447 }
4448
4449 #[test]
4450 fn reload_one_mem_picks_up_external_removal() {
4451 let tmp = TempDir::new().unwrap();
4452 let mut engine = build_demo_engine(&tmp);
4453 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4456 let result = engine.reload_one_mem("specs").unwrap();
4457 assert!(result.added.is_empty());
4458 assert!(result.changed.is_empty());
4459 assert_eq!(
4460 result
4461 .removed
4462 .iter()
4463 .map(|i| i.as_ref())
4464 .collect::<Vec<_>>(),
4465 vec!["specs--lonely-three"]
4466 );
4467 }
4468
4469 #[test]
4470 fn reload_one_mem_picks_up_external_change() {
4471 let tmp = TempDir::new().unwrap();
4472 let mut engine = build_demo_engine(&tmp);
4473 std::fs::write(
4476 tmp.path().join("source-one.md"),
4477 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4478 )
4479 .unwrap();
4480 let result = engine.reload_one_mem("specs").unwrap();
4481 assert!(result.added.is_empty());
4482 assert_eq!(
4483 result
4484 .changed
4485 .iter()
4486 .map(|i| i.as_ref())
4487 .collect::<Vec<_>>(),
4488 vec!["specs--source-one"]
4489 );
4490 assert!(result.removed.is_empty());
4491 }
4492
4493 #[test]
4494 fn reload_one_mem_rejects_unknown_mem() {
4495 let tmp = TempDir::new().unwrap();
4496 let mut engine = build_demo_engine(&tmp);
4497 let err = engine.reload_one_mem("nope").unwrap_err();
4498 match err {
4499 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4500 other => panic!("expected UnknownMem, got {other:?}"),
4501 }
4502 }
4503
4504 #[test]
4505 fn reload_each_writable_mem_returns_one_entry_per_mount() {
4506 let tmp = TempDir::new().unwrap();
4507 let mut engine = build_demo_engine(&tmp);
4508 let reports = engine
4509 .reload_each_writable_mem()
4510 .expect("batch reload on stable disk must succeed");
4511 assert_eq!(reports.len(), 1);
4512 assert_eq!(reports[0].0, "specs");
4513 assert!(reports[0].1.added.is_empty());
4514 assert!(reports[0].1.changed.is_empty());
4515 assert!(reports[0].1.removed.is_empty());
4516 }
4517
4518 #[test]
4521 fn settings_default_to_empty_on_fresh_engine() {
4522 let tmp = TempDir::new().unwrap();
4523 let engine = build_demo_engine(&tmp);
4524 let s = engine.settings();
4525 assert!(s.mem_create_rules.is_empty());
4526 assert!(s.mem_delete_rules.is_empty());
4527 assert!(s.cross_mem_links.is_empty());
4528 }
4529
4530 #[test]
4531 fn set_settings_replaces_workspace_policy() {
4532 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4533 let tmp = TempDir::new().unwrap();
4534 let mut engine = build_demo_engine(&tmp);
4535 let mut settings = WorkspaceSettings::default();
4536 settings.mem_create_rules.push(CreateRuleSetting {
4537 pattern: "exec-*".to_string(),
4538 schemas: vec!["default@1.0.0".to_string()],
4539 default_cross_links: None,
4540 });
4541 settings.mem_delete_rules.push(DeleteRuleSetting {
4542 pattern: "exec-*".to_string(),
4543 });
4544 engine.set_settings(settings);
4545 assert_eq!(engine.settings().mem_create_rules.len(), 1);
4546 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4547 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4548 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4549 }
4550
4551 #[test]
4554 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4555 let tmp = TempDir::new().unwrap();
4556 let mut engine = build_demo_engine(&tmp);
4557 std::fs::write(
4559 tmp.path().join("new-via-disk.md"),
4560 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4561 )
4562 .unwrap();
4563 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4564 std::fs::write(
4565 tmp.path().join("source-one.md"),
4566 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4567 )
4568 .unwrap();
4569
4570 let reports = engine.reload_each_writable_mem().unwrap();
4571 assert_eq!(reports.len(), 1);
4572 let (mem, result) = &reports[0];
4573 assert_eq!(mem, "specs");
4574 assert_eq!(
4575 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4576 vec!["specs--new-via-disk"]
4577 );
4578 assert_eq!(
4579 result
4580 .removed
4581 .iter()
4582 .map(|i| i.as_ref())
4583 .collect::<Vec<_>>(),
4584 vec!["specs--lonely-three"]
4585 );
4586 assert_eq!(
4587 result
4588 .changed
4589 .iter()
4590 .map(|i| i.as_ref())
4591 .collect::<Vec<_>>(),
4592 vec!["specs--source-one"]
4593 );
4594 }
4595
4596 #[test]
4599 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4600 let tmp = TempDir::new().unwrap();
4608 let mut engine = build_demo_engine(&tmp);
4609 let report = engine.reload_one_mem_report("specs").unwrap();
4610 assert_eq!(report.mem, "specs");
4611 assert_eq!(
4612 report.head_before, report.head_after,
4613 "unchanged disk → stable cursor"
4614 );
4615 assert!(
4616 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4617 "folder heads carry the changelog-ts cursor, got {}",
4618 report.head_after
4619 );
4620 assert_eq!(report.entities_loaded, 3);
4623 assert!(report.changed_entity_ids.is_empty());
4625 }
4626
4627 #[test]
4628 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4629 let tmp = TempDir::new().unwrap();
4634 let mut engine = build_demo_engine(&tmp);
4635 std::fs::write(
4636 tmp.path().join("new-via-disk.md"),
4637 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4638 )
4639 .unwrap();
4640 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4641 std::fs::write(
4642 tmp.path().join("source-one.md"),
4643 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4644 )
4645 .unwrap();
4646
4647 let report = engine.reload_one_mem_report("specs").unwrap();
4648 assert_eq!(report.mem, "specs");
4649 let ids: Vec<&str> = report
4650 .changed_entity_ids
4651 .iter()
4652 .map(|id| id.as_ref())
4653 .collect();
4654 assert_eq!(
4656 ids,
4657 vec![
4658 "specs--lonely-three",
4659 "specs--new-via-disk",
4660 "specs--source-one",
4661 ]
4662 );
4663 }
4664
4665 #[test]
4666 fn reload_one_mem_report_rejects_unknown_mem() {
4667 let tmp = TempDir::new().unwrap();
4668 let mut engine = build_demo_engine(&tmp);
4669 let err = engine.reload_one_mem_report("missing").unwrap_err();
4670 assert!(matches!(err, EngineError::UnknownMem(_)));
4671 }
4672
4673 #[test]
4674 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4675 let tmp = TempDir::new().unwrap();
4676 let mut engine = build_demo_engine(&tmp);
4677 let reports = engine.reload_each_writable_mem_reports().unwrap();
4678 assert_eq!(reports.len(), 1);
4679 assert_eq!(reports[0].mem, "specs");
4680 assert_eq!(reports[0].entities_loaded, 3);
4681 }
4682
4683 #[test]
4690 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4691 let tmp = TempDir::new().unwrap();
4692
4693 let memstead_dir = tmp.path().join(".memstead");
4696 std::fs::create_dir_all(&memstead_dir).unwrap();
4697 let workspace_toml = memstead_dir.join("workspace.toml");
4698 std::fs::write(
4699 &workspace_toml,
4700 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4701 )
4702 .unwrap();
4703 let mounts_json = memstead_dir.join("state").join("mounts.json");
4704 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4705 let mem_dir = tmp.path().join("specs");
4706 std::fs::create_dir_all(&mem_dir).unwrap();
4707 let mounts_body = format!(
4708 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4709 mem_dir.display(),
4710 );
4711 std::fs::write(&mounts_json, mounts_body).unwrap();
4712
4713 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4714 assert!(
4715 engine.settings().mem_create_rules.is_empty(),
4716 "boot-time settings carry no create rules"
4717 );
4718
4719 std::fs::write(
4721 &workspace_toml,
4722 "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",
4723 )
4724 .unwrap();
4725
4726 engine.reload_each_writable_mem_reports().unwrap();
4727
4728 let rules = &engine.settings().mem_create_rules;
4729 assert_eq!(
4730 rules.len(),
4731 1,
4732 "workspace-wide reload must refresh the policy"
4733 );
4734 assert_eq!(rules[0].pattern, "exec-*");
4735 }
4736
4737 const MIG_TYPE_TAIL: &str = r#"sections:
4742 - key: body
4743 heading: Body
4744 required: true
4745 search_weight: 10.0
4746 catch_all: true
4747 write_rules: []
4748title_weight: 100.0
4749text_fields:
4750 - body
4751hierarchy_relationship: _default
4752no_self_loop_relationships: []
4753updatable_fields: []
4754health_required_fields: []
4755staleness_threshold_days: 90
4756write_rules: []
4757"#;
4758
4759 fn mig_manifest(name: &str, version: &str) -> String {
4764 format!(
4765 r#"name: {name}
4766version: {version}
4767description: migration test schema
4768when_to_use: tests
4769types:
4770 - doc
4771relationships:
4772 mode: strict
4773 definitions:
4774 - name: USES
4775 description: link
4776 default_weight: 1.0
4777 - name: _default
4778 description: fallback
4779 default_weight: 1.0
4780community:
4781 resolution: 1.0
4782 seed: 42
4783"#
4784 )
4785 }
4786
4787 fn mig_type_yaml(with_status: bool) -> String {
4788 let metadata = if with_status {
4789 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4790 } else {
4791 "metadata_fields: []\n"
4792 };
4793 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4794 }
4795
4796 fn write_mig_schema(
4797 root: &std::path::Path,
4798 dir: &str,
4799 name: &str,
4800 version: &str,
4801 with_status: bool,
4802 ) {
4803 let d = root.join(dir);
4804 std::fs::create_dir_all(d.join("types")).unwrap();
4805 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4806 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4807 }
4808
4809 #[test]
4826 fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4827 for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4828 run_mutation_sequence(seed);
4829 }
4830 }
4831
4832 struct Xorshift(u64);
4833 impl Xorshift {
4834 fn next(&mut self) -> u64 {
4835 let mut x = self.0;
4836 x ^= x << 13;
4837 x ^= x >> 7;
4838 x ^= x << 17;
4839 self.0 = x;
4840 x
4841 }
4842 fn pick(&mut self, n: usize) -> usize {
4843 (self.next() % n as u64) as usize
4844 }
4845 }
4846
4847 fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4848 let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4851 let live = engine.search_indexes();
4852 let mut live_mems: Vec<&String> = live.keys().collect();
4853 let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4854 live_mems.sort();
4855 fresh_mems.sort();
4856 assert_eq!(
4857 live_mems, fresh_mems,
4858 "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4859 );
4860 for (mem, idx) in live {
4861 let mut got = idx.stored_ids().unwrap();
4862 let mut want = fresh[mem].stored_ids().unwrap();
4863 got.sort();
4864 want.sort();
4865 assert_eq!(
4866 got, want,
4867 "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4868 );
4869 }
4870 let schema = engine
4873 .schemas
4874 .iter()
4875 .min_by(|a, b| a.0.cmp(b.0))
4876 .map(|(_, s)| s.clone())
4877 .expect("schema present");
4878 let weights_schema = schema.clone();
4879 let fresh_partition = crate::graph::community::detect_communities(
4880 engine.store(),
4881 schema.manifest.community.resolution,
4882 schema.manifest.community.seed,
4883 move |rel_type| {
4884 weights_schema
4885 .manifest
4886 .relationships
4887 .definitions
4888 .iter()
4889 .find(|d| d.name == rel_type)
4890 .map(|d| d.default_weight as f64)
4891 .unwrap_or(1.0)
4892 },
4893 );
4894 assert_eq!(
4895 engine.communities().entity_cluster_map,
4896 fresh_partition.entity_cluster_map,
4897 "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4898 );
4899 }
4900
4901 fn run_mutation_sequence(seed: u64) {
4902 use indexmap::IndexMap;
4903
4904 let (_tmp, mut engine) = migration_engine();
4905 let mut rng = Xorshift(seed);
4906 let mut live: Vec<crate::EntityId> = vec![
4907 crate::EntityId::new("specs", "one"),
4908 crate::EntityId::new("specs", "two"),
4909 ];
4910 let mut counter = 0usize;
4911 let mut kinds_hit: std::collections::HashSet<&'static str> =
4912 std::collections::HashSet::new();
4913
4914 const KINDS: [&str; 7] = [
4915 "create",
4916 "update",
4917 "relate",
4918 "delete",
4919 "rename",
4920 "batch_applied",
4921 "batch_refused",
4922 ];
4923
4924 let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4925 anchors: Vec::new(),
4926 anchors_unset: Vec::new(),
4927 id,
4928 expected_hash: None,
4929 sections: IndexMap::new(),
4930 append_sections: IndexMap::new(),
4931 patch_sections: IndexMap::new(),
4932 sections_unset: Vec::new(),
4933 metadata: IndexMap::new(),
4934 metadata_unset: Vec::new(),
4935 declare_relations: Vec::new(),
4936 dry_run: false,
4937 relations_unset: Vec::new(),
4938 };
4939
4940 for op_i in 0..30usize {
4941 let kind = *KINDS
4944 .get(op_i)
4945 .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4946 match kind {
4947 "create" => {
4948 counter += 1;
4949 let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4950 args.entity_type = "doc".to_string();
4951 args.sections = IndexMap::from_iter([(
4952 "body".to_string(),
4953 format!("generated body {counter}"),
4954 )]);
4955 let out = engine
4956 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4957 .expect("generated create is conformant");
4958 live.push(out.id);
4959 kinds_hit.insert("create");
4960 }
4961 "update" => {
4962 let id = live[rng.pick(live.len())].clone();
4963 let mut args = bare_update(id);
4964 args.append_sections
4965 .insert("body".to_string(), format!("appended at op {op_i}"));
4966 engine
4967 .update_entity(args, crate::vcs::Actor::Cli, None, None)
4968 .expect("append update is conformant");
4969 kinds_hit.insert("update");
4970 }
4971 "relate" => {
4972 if live.len() >= 2 {
4973 let a = rng.pick(live.len());
4974 let mut b = rng.pick(live.len());
4975 if a == b {
4976 b = (b + 1) % live.len();
4977 }
4978 engine
4979 .relate_entity(
4980 crate::engine::RelateEntityArgs {
4981 source: live[a].clone(),
4982 expected_hash: None,
4983 rel_type: "USES".to_string(),
4984 target: live[b].clone(),
4985 remove: false,
4986 description: None,
4987 dry_run: false,
4988 },
4989 crate::vcs::Actor::Cli,
4990 None,
4991 None,
4992 )
4993 .expect("USES relate is legal under mig-a");
4994 kinds_hit.insert("relate");
4995 }
4996 }
4997 "delete" => {
4998 if live.len() > 2
5001 && let Some(pos) = (0..live.len()).find(|&i| {
5002 engine.store().incoming(&live[i]).is_empty()
5003 && engine
5004 .store()
5005 .get(&live[i])
5006 .is_some_and(|e| e.relationships.is_empty())
5007 })
5008 {
5009 let id = live.remove(pos);
5010 engine
5011 .delete_entity(
5012 crate::engine::DeleteEntityArgs {
5013 id: id.clone(),
5014 expected_hash: None,
5015 },
5016 crate::vcs::Actor::Cli,
5017 None,
5018 None,
5019 )
5020 .expect("reference-free delete lands");
5021 kinds_hit.insert("delete");
5022 }
5023 }
5024 "rename" => {
5025 counter += 1;
5026 let pos = rng.pick(live.len());
5027 let old = live[pos].clone();
5028 let out = engine
5029 .rename_entity(
5030 crate::engine::RenameEntityArgs {
5031 id: old,
5032 new_title: format!("Renamed {counter}"),
5033 expected_hash: None,
5034 },
5035 crate::vcs::Actor::Cli,
5036 None,
5037 None,
5038 )
5039 .expect("fresh-slug rename lands");
5040 live[pos] = out.new_id;
5041 kinds_hit.insert("rename");
5042 }
5043 "batch_applied" => {
5044 let id_a = live[rng.pick(live.len())].clone();
5045 let mut a = bare_update(id_a);
5046 a.append_sections
5047 .insert("body".to_string(), format!("batch line {op_i}"));
5048 let result = engine
5049 .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
5050 .expect("batch envelope");
5051 assert!(result.applied, "single-entry append batch applies");
5052 kinds_hit.insert("batch_applied");
5053 }
5054 "batch_refused" => {
5055 let id_a = live[rng.pick(live.len())].clone();
5056 let mut a = bare_update(id_a);
5057 a.append_sections
5058 .insert("body".to_string(), "doomed".to_string());
5059 let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
5060 let result = engine
5061 .batch_update(
5062 vec![(a, None), (missing, None)],
5063 crate::vcs::Actor::Cli,
5064 None,
5065 false,
5066 )
5067 .expect("refused batch returns a report-all envelope");
5068 assert!(!result.applied, "the missing target refuses the batch");
5069 kinds_hit.insert("batch_refused");
5070 }
5071 _ => unreachable!(),
5072 }
5073
5074 if op_i == 14 {
5075 engine
5079 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5080 .expect("integral switch");
5081 kinds_hit.insert("schema_switch");
5082 }
5083 if op_i == 19 {
5084 engine.reload_one_mem("specs").expect("reload lands");
5085 kinds_hit.insert("reload");
5086 }
5087
5088 if op_i % 10 == 9 {
5089 assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
5090 }
5091 }
5092
5093 assert_derived_oracles(&engine, seed, "sequence end");
5094
5095 for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
5096 assert!(
5097 kinds_hit.contains(kind),
5098 "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
5099 );
5100 }
5101 }
5102
5103 fn migration_engine() -> (tempfile::TempDir, Engine) {
5104 let tmp = tempfile::TempDir::new().unwrap();
5105 let schemas_dir = tmp.path().join("schemas");
5106 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5107 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5108 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
5109 let mem_dir = tmp.path().join("mem");
5110 std::fs::create_dir_all(&mem_dir).unwrap();
5111 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5112 let mut mount = folder_mount("specs", mem_dir);
5113 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5114 let mut engine = Engine::from_mounts_with_schemas_dir(
5115 vec![(
5116 mount,
5117 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5118 )],
5119 Some(&schemas_dir),
5120 )
5121 .unwrap();
5122 for title in ["One", "Two"] {
5123 let mut args = empty_create_args("specs", title);
5124 args.entity_type = "doc".to_string();
5125 args.sections =
5126 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
5127 engine
5128 .create_entity(args, crate::vcs::Actor::Cli, None, None)
5129 .expect("conformant create under mig-a");
5130 }
5131 (tmp, engine)
5132 }
5133
5134 fn sref(s: &str) -> memstead_schema::SchemaRef {
5135 s.parse().unwrap()
5136 }
5137
5138 #[test]
5144 fn set_schema_repairs_a_mount_expectation_ahead_of_the_served_pin() {
5145 let (_tmp, mut engine) = migration_engine();
5146 let idx = engine
5149 .mounts
5150 .iter()
5151 .position(|m| m.mount.mem == "specs")
5152 .unwrap();
5153 engine.mounts[idx].mount.schema = Some(sref("mig-b@0.1.0"));
5154 assert_eq!(engine.schemas.get("specs").unwrap().id().0, "mig-a");
5155
5156 let out = engine
5157 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5158 .unwrap();
5159 assert_eq!(
5163 out.outcome,
5164 crate::engine::SetSchemaResult::MigrationStarted,
5165 "a served pin behind the target enters the switch path, never a noop: {out:?}"
5166 );
5167 assert!(!out.findings.is_empty());
5168 assert_eq!(
5169 engine.schemas.get("specs").unwrap().id().0,
5170 "mig-b",
5171 "writes now validate against the target"
5172 );
5173
5174 let (_tmp2, mut clean) = migration_engine();
5177 let again = clean.set_mem_schema("specs", &sref("mig-a@0.1.0")).unwrap();
5178 assert_eq!(again.outcome, crate::engine::SetSchemaResult::Noop);
5179 }
5180
5181 #[test]
5182 fn set_schema_noop_on_current_pin() {
5183 let (_tmp, mut engine) = migration_engine();
5184 let out = engine
5185 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
5186 .unwrap();
5187 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
5188 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5189 assert_eq!(out.migration_target, None);
5190 assert!(out.findings.is_empty());
5191 }
5192
5193 #[test]
5202 fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
5203 let (_tmp, mut engine) = migration_engine();
5204
5205 let _ = engine.communities();
5206 let _ = engine.search_indexes();
5207 assert!(engine.community_memo.get().is_some());
5208 assert!(engine.search_indexes_memo.get().is_some());
5209 let store_gen_before = engine.store().generation();
5210
5211 engine
5212 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5213 .unwrap();
5214
5215 assert_eq!(
5216 engine.store().generation(),
5217 store_gen_before,
5218 "a schema switch mutates no store content"
5219 );
5220 assert!(
5221 engine.community_memo.get().is_none(),
5222 "the community memo must clear on a schema switch (weights derive from the schema)"
5223 );
5224 assert!(
5225 engine.search_indexes_memo.get().is_none(),
5226 "the search memo must clear on a schema switch (the field set derives from the schema)"
5227 );
5228 }
5229
5230 #[test]
5231 fn set_schema_switches_immediately_when_integral() {
5232 let (_tmp, mut engine) = migration_engine();
5235 let out = engine
5236 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5237 .unwrap();
5238 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5239 assert_eq!(out.schema_pin, "mig-a@0.2.0");
5240 assert_eq!(out.migration_target, None);
5241 assert!(out.findings.is_empty());
5242 assert_eq!(
5243 engine.schema_pin("specs").unwrap().as_display(),
5244 "mig-a@0.2.0"
5245 );
5246 assert!(engine.migration_target("specs").is_none());
5247 }
5248
5249 #[test]
5255 fn set_schema_switch_persists_pin_into_backend_config() {
5256 let tmp = tempfile::TempDir::new().unwrap();
5257 let schemas_dir = tmp.path().join("schemas");
5258 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5259 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5260 let mem_dir = tmp.path().join("mem");
5261 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
5262 std::fs::write(
5264 mem_dir.join(".memstead").join("config.json"),
5265 br#"{"schema":"mig-a@0.1.0"}"#,
5266 )
5267 .unwrap();
5268 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5269 let mut mount = folder_mount("specs", mem_dir.clone());
5270 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5271 let mut engine = Engine::from_mounts_with_schemas_dir(
5272 vec![(
5273 mount,
5274 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5275 )],
5276 Some(&schemas_dir),
5277 )
5278 .unwrap();
5279
5280 let out = engine
5281 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5282 .unwrap();
5283 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5284
5285 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
5288 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
5289 assert_eq!(
5290 cfg["schema"], "mig-a@0.2.0",
5291 "atomic switch must update the authoritative backend config"
5292 );
5293 }
5294
5295 #[test]
5300 fn set_schema_completed_switch_restamps_marker_and_dual_pin_leaves_it() {
5301 let tmp = tempfile::TempDir::new().unwrap();
5302 let schemas_dir = tmp.path().join("schemas");
5303 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5304 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5305 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
5306 let mem_dir = tmp.path().join("mem");
5307 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
5308 let config_path = mem_dir.join(".memstead").join("config.json");
5309 std::fs::write(
5311 &config_path,
5312 br#"{"schema":"mig-a@0.1.0","mutationStamp":{"engineVersion":"0.0.1","schema":"mig-a@0.1.0"}}"#,
5313 )
5314 .unwrap();
5315 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5316 let mut mount = folder_mount("specs", mem_dir.clone());
5317 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5318 let mut engine = Engine::from_mounts_with_schemas_dir(
5319 vec![(
5320 mount,
5321 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5322 )],
5323 Some(&schemas_dir),
5324 )
5325 .unwrap();
5326 let stamp_of = |path: &std::path::Path| -> serde_json::Value {
5327 let cfg: serde_json::Value =
5328 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
5329 cfg["mutationStamp"].clone()
5330 };
5331
5332 let before = std::fs::read(&config_path).unwrap();
5334 let out = engine
5335 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
5336 .unwrap();
5337 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
5338 assert_eq!(out.stamped_schema.as_deref(), Some("mig-a@0.1.0"));
5339 assert_eq!(
5340 std::fs::read(&config_path).unwrap(),
5341 before,
5342 "a noop set-schema changes no byte of the mem config"
5343 );
5344
5345 for title in ["One", "Two"] {
5348 let mut args = empty_create_args("specs", title);
5349 args.entity_type = "doc".to_string();
5350 args.sections =
5351 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
5352 engine
5353 .create_entity(args, crate::vcs::Actor::Cli, None, None)
5354 .expect("conformant create under mig-a");
5355 }
5356 let out = engine
5357 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5358 .unwrap();
5359 assert_eq!(
5360 out.outcome,
5361 crate::engine::SetSchemaResult::MigrationStarted
5362 );
5363 assert!(!out.findings.is_empty());
5364 assert_eq!(out.stamped_schema.as_deref(), Some("mig-a@0.1.0"));
5365 assert_eq!(stamp_of(&config_path)["schema"], "mig-a@0.1.0");
5366
5367 let out = engine
5371 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5372 .unwrap();
5373 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5374 assert_eq!(out.stamped_schema.as_deref(), Some("mig-a@0.2.0"));
5375 let stamp = stamp_of(&config_path);
5376 assert_eq!(stamp["schema"], "mig-a@0.2.0");
5377 assert_eq!(stamp["engineVersion"], crate::build_info::full_version());
5378 }
5379
5380 #[test]
5381 fn set_schema_unknown_target_refuses_schema_not_found() {
5382 let (_tmp, mut engine) = migration_engine();
5383 let err = engine
5384 .set_mem_schema("specs", &sref("nope@9.9.9"))
5385 .unwrap_err();
5386 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
5387 assert!(engine.migration_target("specs").is_none());
5389 }
5390
5391 #[test]
5392 fn set_schema_migration_lifecycle_end_to_end() {
5393 let (_tmp, mut engine) = migration_engine();
5394 let target = sref("mig-b@0.1.0");
5395
5396 let out = engine.set_mem_schema("specs", &target).unwrap();
5398 assert_eq!(
5399 out.outcome,
5400 crate::engine::SetSchemaResult::MigrationStarted
5401 );
5402 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5403 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
5404 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
5405 assert!(
5406 out.findings
5407 .iter()
5408 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
5409 );
5410
5411 let one = crate::entity::EntityId::new("specs", "one");
5413 assert!(engine.store().get(&one).is_some());
5414
5415 let out = engine.set_mem_schema("specs", &target).unwrap();
5417 assert_eq!(
5418 out.outcome,
5419 crate::engine::SetSchemaResult::MigrationPending
5420 );
5421 assert_eq!(out.findings.len(), 2);
5422
5423 let mut bad = crate::engine::UpdateEntityArgs {
5427 anchors: Vec::new(),
5428 id: one.clone(),
5429 expected_hash: None,
5430 sections: indexmap::IndexMap::new(),
5431 append_sections: indexmap::IndexMap::new(),
5432 patch_sections: indexmap::IndexMap::new(),
5433 sections_unset: Vec::new(),
5434 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
5435 metadata_unset: Vec::new(),
5436 declare_relations: Vec::new(),
5437 dry_run: false,
5438 relations_unset: Vec::new(),
5439 anchors_unset: Vec::new(),
5440 };
5441 let err = engine
5442 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
5443 .unwrap_err();
5444 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
5445 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
5446 engine
5447 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
5448 .expect("repair write validated against the migration target");
5449
5450 let out = engine.set_mem_schema("specs", &target).unwrap();
5452 assert_eq!(
5453 out.outcome,
5454 crate::engine::SetSchemaResult::MigrationPending
5455 );
5456 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
5457
5458 let two = crate::entity::EntityId::new("specs", "two");
5460 let repair = crate::engine::UpdateEntityArgs {
5461 anchors: Vec::new(),
5462 id: two.clone(),
5463 expected_hash: None,
5464 sections: indexmap::IndexMap::new(),
5465 append_sections: indexmap::IndexMap::new(),
5466 patch_sections: indexmap::IndexMap::new(),
5467 sections_unset: Vec::new(),
5468 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
5469 metadata_unset: Vec::new(),
5470 declare_relations: Vec::new(),
5471 dry_run: false,
5472 relations_unset: Vec::new(),
5473 anchors_unset: Vec::new(),
5474 };
5475 engine
5476 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
5477 .unwrap();
5478 let out = engine.set_mem_schema("specs", &target).unwrap();
5479 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5480 assert_eq!(out.schema_pin, "mig-b@0.1.0");
5481 assert_eq!(out.migration_target, None);
5482 assert!(out.findings.is_empty());
5483 assert_eq!(
5484 engine.schema_pin("specs").unwrap().as_display(),
5485 "mig-b@0.1.0"
5486 );
5487 assert!(engine.migration_target("specs").is_none());
5488 }
5489
5490 #[test]
5495 fn relations_unset_works_during_migration_without_mode_flag() {
5496 let (_tmp, mut engine) = migration_engine();
5497 let one = crate::entity::EntityId::new("specs", "one");
5498 let two = crate::entity::EntityId::new("specs", "two");
5499 engine
5500 .relate_entity(
5501 crate::engine::RelateEntityArgs {
5502 source: one.clone(),
5503 expected_hash: None,
5504 rel_type: "USES".to_string(),
5505 target: two.clone(),
5506 remove: false,
5507 description: None,
5508 dry_run: false,
5509 },
5510 crate::vcs::Actor::Cli,
5511 None,
5512 None,
5513 )
5514 .unwrap();
5515 let shut = engine
5517 .update_entity(
5518 crate::engine::UpdateEntityArgs {
5519 anchors: Vec::new(),
5520 id: one.clone(),
5521 expected_hash: None,
5522 sections: indexmap::IndexMap::new(),
5523 append_sections: indexmap::IndexMap::new(),
5524 patch_sections: indexmap::IndexMap::new(),
5525 sections_unset: Vec::new(),
5526 metadata: indexmap::IndexMap::new(),
5527 metadata_unset: Vec::new(),
5528 declare_relations: Vec::new(),
5529 dry_run: false,
5530 relations_unset: vec![crate::ops::RelationUnsetArg {
5531 rel_type: "USES".to_string(),
5532 target: two.clone(),
5533 }],
5534 anchors_unset: Vec::new(),
5535 },
5536 crate::vcs::Actor::Cli,
5537 None,
5538 None,
5539 )
5540 .unwrap_err();
5541 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5542
5543 engine
5547 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5548 .unwrap();
5549 engine
5550 .update_entity(
5551 crate::engine::UpdateEntityArgs {
5552 anchors: Vec::new(),
5553 id: one.clone(),
5554 expected_hash: None,
5555 sections: indexmap::IndexMap::new(),
5556 append_sections: indexmap::IndexMap::new(),
5557 patch_sections: indexmap::IndexMap::new(),
5558 sections_unset: Vec::new(),
5559 metadata: indexmap::IndexMap::from_iter([(
5560 "status".to_string(),
5561 "open".to_string(),
5562 )]),
5563 metadata_unset: Vec::new(),
5564 declare_relations: Vec::new(),
5565 dry_run: false,
5566 relations_unset: vec![crate::ops::RelationUnsetArg {
5567 rel_type: "USES".to_string(),
5568 target: two.clone(),
5569 }],
5570 anchors_unset: Vec::new(),
5571 },
5572 crate::vcs::Actor::Cli,
5573 None,
5574 None,
5575 )
5576 .expect("repair-shaped update lands during migration without a flag");
5577 let entity = engine.store().get(&one).unwrap();
5578 assert!(entity.relationships.is_empty());
5579 }
5580
5581 #[test]
5586 fn boot_resumes_dual_pin_validation_against_target() {
5587 let (tmp, engine) = migration_engine();
5588 drop(engine);
5589 let schemas_dir = tmp.path().join("schemas");
5590 let mem_dir = tmp.path().join("mem");
5591 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5592 let mut mount = folder_mount("specs", mem_dir);
5593 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5594 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5595 let engine = Engine::from_mounts_with_schemas_dir(
5596 vec![(
5597 mount,
5598 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5599 )],
5600 Some(&schemas_dir),
5601 )
5602 .unwrap();
5603 let (name, version) = {
5605 let s = engine.schema_for("specs").unwrap();
5606 let (n, v) = s.id();
5607 (n.to_string(), v.to_string())
5608 };
5609 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5610 assert_eq!(
5613 engine.schema_pin("specs").unwrap().as_display(),
5614 "mig-a@0.1.0"
5615 );
5616 assert_eq!(
5617 engine.migration_target("specs").unwrap().as_display(),
5618 "mig-b@0.1.0"
5619 );
5620 }
5621
5622 #[test]
5632 fn every_lifecycle_setter_refuses_on_read_only_mount() {
5633 let tmp = TempDir::new().unwrap();
5634 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5635 let mut engine = Engine::from_mounts(vec![(
5636 archive_mount("ext", archive_path.clone()),
5637 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5638 )])
5639 .unwrap();
5640
5641 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5642 let attempts: Vec<(&str, EngineError)> = vec![
5643 (
5644 "set_mem_schema",
5645 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5646 ),
5647 (
5648 "set_mem_version",
5649 engine
5650 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5651 .unwrap_err(),
5652 ),
5653 (
5654 "set_mem_description",
5655 engine
5656 .set_mem_description("ext", Some("x".into()), None)
5657 .unwrap_err(),
5658 ),
5659 (
5660 "set_mem_title",
5661 engine
5662 .set_mem_title("ext", Some("x".into()), None)
5663 .unwrap_err(),
5664 ),
5665 (
5666 "set_mem_subject",
5667 engine.set_mem_subject("ext", None, None).unwrap_err(),
5668 ),
5669 (
5670 "set_mem_internal",
5671 engine.set_mem_internal("ext", true, None).unwrap_err(),
5672 ),
5673 (
5674 "set_mem_sync_state",
5675 engine
5676 .set_mem_sync_state("ext", "k", "t", None)
5677 .unwrap_err(),
5678 ),
5679 ];
5680 for (setter, err) in attempts {
5681 match err {
5682 EngineError::ReadOnlyMount(v) => {
5683 assert_eq!(v, "ext", "{setter} must name the refused mem")
5684 }
5685 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5686 }
5687 }
5688 }
5689
5690 #[test]
5696 fn no_config_setter_reverts_a_siblings_write() {
5697 type Setter = fn(&mut Engine) -> Result<(), EngineError>;
5698 let setters: Vec<(&str, Setter)> = vec![
5699 ("version", |e| {
5700 e.set_mem_version("specs", semver::Version::new(9, 0, 0), None)
5701 .map(|_| ())
5702 }),
5703 ("description", |e| {
5704 e.set_mem_description("specs", Some("mine".into()), None)
5705 .map(|_| ())
5706 }),
5707 ("title", |e| {
5708 e.set_mem_title("specs", Some("Mine".into()), None)
5709 .map(|_| ())
5710 }),
5711 ("internal", |e| {
5712 e.set_mem_internal("specs", true, None).map(|_| ())
5713 }),
5714 ("sync_state", |e| {
5715 e.set_mem_sync_state("specs", "src/facet", "tok", None)
5716 .map(|_| ())
5717 }),
5718 ("review_mark", |e| {
5722 e.set_review_mark("specs", None, None).map(|_| ())
5723 }),
5724 ];
5725
5726 for (name, set) in setters {
5727 let tmp = TempDir::new().unwrap();
5728 let mem_dir = tmp.path().to_path_buf();
5729 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5730 std::fs::create_dir_all(&meta).unwrap();
5731 let path = meta.join("config.json");
5732 std::fs::write(
5733 &path,
5734 br#"{"schema": "default@1.0.0", "version": "0.1.0"}"#.as_slice(),
5735 )
5736 .unwrap();
5737
5738 let writer = FilesystemMemWriter::new(mem_dir.clone());
5739 let mut engine = Engine::from_mounts(vec![(
5740 folder_mount("specs", mem_dir.clone()),
5741 Box::new(writer) as Box<dyn MemBackend>,
5742 )])
5743 .unwrap();
5744 engine
5747 .create_entity(
5748 crate::engine::test_helpers::empty_create_args("specs", "Seed"),
5749 crate::vcs::Actor::Cli,
5750 None,
5751 None,
5752 )
5753 .unwrap();
5754
5755 let mut sibling: memstead_schema::MemConfig =
5757 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5758 sibling
5759 .extra
5760 .insert("siblingMark".into(), serde_json::json!("kept"));
5761 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5762
5763 set(&mut engine).unwrap_or_else(|e| panic!("{name} setter failed: {e}"));
5764
5765 let after: memstead_schema::MemConfig =
5766 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5767 assert_eq!(
5768 after.extra.get("siblingMark"),
5769 Some(&serde_json::json!("kept")),
5770 "the {name} setter reverted a field it never set"
5771 );
5772 }
5773 }
5774
5775 #[test]
5778 fn intervention_is_reported_on_the_response_and_only_when_real() {
5779 let tmp = TempDir::new().unwrap();
5780 let mem_dir = tmp.path().to_path_buf();
5781 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5782 std::fs::create_dir_all(&meta).unwrap();
5783 let path = meta.join("config.json");
5784 std::fs::write(&path, br#"{"schema": "default@1.0.0"}"#.as_slice()).unwrap();
5785 let writer = FilesystemMemWriter::new(mem_dir.clone());
5786 let mut engine = Engine::from_mounts(vec![(
5787 folder_mount("specs", mem_dir.clone()),
5788 Box::new(writer) as Box<dyn MemBackend>,
5789 )])
5790 .unwrap();
5791
5792 let quiet = engine
5795 .set_mem_description("specs", Some("first".into()), None)
5796 .unwrap();
5797 assert!(
5798 !quiet
5799 .warnings
5800 .iter()
5801 .any(|w| w.code() == "CONFIG_WRITE_INTERVENED"),
5802 "single-writer workspace must stay silent: {:?}",
5803 quiet.warnings
5804 );
5805
5806 let mut sibling: memstead_schema::MemConfig =
5808 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5809 sibling.title = Some("theirs".into());
5810 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5811
5812 let loud = engine
5813 .set_mem_description("specs", Some("second".into()), None)
5814 .unwrap();
5815 let hint = loud
5816 .warnings
5817 .iter()
5818 .find(|w| w.code() == "CONFIG_WRITE_INTERVENED")
5819 .expect("intervention must be reported on the response");
5820 assert!(
5821 format!("{hint}").contains("title"),
5822 "the report names what they changed: {hint}"
5823 );
5824 let after: memstead_schema::MemConfig =
5826 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5827 assert_eq!(after.title.as_deref(), Some("theirs"));
5828 assert_eq!(after.description.as_deref(), Some("second"));
5829 }
5830}