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 CarriedByArchive,
51}
52
53impl Engine {
54 pub fn set_settings(&mut self, settings: WorkspaceSettings) {
63 self.settings = settings;
64 self.create_rule_set_memo = OnceCell::new();
65 }
66
67 pub fn set_backend_factory(&mut self, factory: BackendFactory) {
73 self.backend_factory = factory;
74 }
75
76 pub(crate) fn schemas_insert(
82 &mut self,
83 mem: String,
84 schema: std::sync::Arc<memstead_schema::Schema>,
85 ) {
86 self.schemas_epoch += 1;
87 self.schemas.insert(mem, schema);
88 }
89
90 pub(crate) fn schemas_remove(&mut self, mem: &str) {
93 self.schemas_epoch += 1;
94 self.schemas.remove(mem);
95 }
96
97 pub fn set_unmounted_storage_prober(&mut self, prober: super::UnmountedStorageProber) {
101 self.unmounted_storage_prober = Some(prober);
102 }
103
104 pub fn set_mutation_clock(&mut self, clock: crate::engine::MutationClock) {
115 self.mutation_clock = clock;
116 }
117
118 pub fn set_role(&mut self, role: crate::vcs::Role) {
124 self.current_role = role;
125 }
126
127 pub fn current_role(&self) -> crate::vcs::Role {
129 self.current_role
130 }
131
132 pub fn set_identity(&mut self, identity: Option<String>) {
139 self.current_identity = identity;
140 }
141
142 pub fn current_identity(&self) -> Option<&str> {
145 self.current_identity.as_deref()
146 }
147
148 pub(crate) fn now_iso(&self) -> String {
152 crate::engine::mutation::iso_from_system_time((self.mutation_clock)())
153 }
154
155 pub fn set_git_branch_ops(&mut self, ops: GitBranchOps) {
161 self.git_branch_ops = Some(ops);
162 }
163
164 pub fn install_schema(
179 &self,
180 name: &str,
181 version: &str,
182 files: &[(String, Vec<u8>)],
183 ) -> Result<String, EngineError> {
184 Self::validate_schema_package(name, version, files)?;
190 let gitdir = self
196 .mounts
197 .iter()
198 .find_map(|m| match &m.mount.storage {
199 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
200 _ => None,
201 })
202 .or_else(|| {
203 self.workspace_root()
204 .map(|r| r.join("mem-repo").join(".git"))
205 })
206 .ok_or_else(|| {
207 EngineError::Mem(
208 "schema install requires a mem-repo workspace (no git-branch mount and \
209 no workspace root to resolve the mem-repo gitdir)"
210 .to_string(),
211 )
212 })?;
213 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
214 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
215 })?;
216 (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)
224 }
225
226 pub fn stage_sealed_schema(
263 &mut self,
264 mem: &str,
265 pin: &memstead_schema::SchemaRef,
266 files: &[(String, Vec<u8>)],
267 ) -> Result<SchemaStaging, EngineError> {
268 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
269 .workspace_schemas
270 .iter()
271 .chain(self.builtin_schemas.iter())
272 .cloned()
273 .collect();
274 if crate::engine::SchemaResolver::new(&catalogue)
275 .resolve(pin)
276 .is_ok()
277 {
278 return Ok(SchemaStaging::AlreadyResolvable);
279 }
280 if files.is_empty() {
281 return Ok(SchemaStaging::NoEmbeddedSchema);
285 }
286
287 let unloadable = |reason: String| EngineError::EmbeddedSchemaInvalid {
288 mem: mem.to_string(),
289 pin: pin.as_display(),
290 reason,
291 };
292 let schema =
295 memstead_schema::load_sealed_package(files).map_err(|e| unloadable(e.to_string()))?;
296 let (name, version) = schema.id();
297 if name != pin.name || version != pin.version {
298 return Err(unloadable(format!(
299 "the package declares '{name}@{version}' but the mem pins {}",
300 pin.as_display()
301 )));
302 }
303
304 if self.sealed_schema_gitdir().is_none() {
305 return Ok(SchemaStaging::CarriedByArchive);
306 }
307 self.write_to_local_schema_source(&name, &version.to_string(), files)?;
308 self.workspace_schemas.push(std::sync::Arc::new(schema));
309 Ok(SchemaStaging::Staged)
310 }
311
312 fn sealed_schema_gitdir(&self) -> Option<PathBuf> {
318 self.mounts
319 .iter()
320 .find_map(|m| match &m.mount.storage {
321 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
322 _ => None,
323 })
324 .or_else(|| {
325 self.workspace_root()
326 .map(|r| r.join("mem-repo").join(".git"))
327 })
328 .filter(|g| g.is_dir())
329 }
330
331 fn write_to_local_schema_source(
337 &self,
338 name: &str,
339 version: &str,
340 files: &[(String, Vec<u8>)],
341 ) -> Result<(), EngineError> {
342 let gitdir = self.sealed_schema_gitdir().ok_or_else(|| {
343 EngineError::Mem(
344 "staging a schema requires a mem-repo workspace — the folder workspace's \
345 `.memstead/schemas/` is the authoring tier and never holds sealed \
346 third-party packages"
347 .to_string(),
348 )
349 })?;
350 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
351 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
352 })?;
353 (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)?;
354 Ok(())
355 }
356
357 pub fn validate_schema_package(
368 name: &str,
369 version: &str,
370 files: &[(String, Vec<u8>)],
371 ) -> Result<(), EngineError> {
372 let invalid = |message: String| EngineError::SchemaPackageInvalid {
373 name: name.to_string(),
374 version: version.to_string(),
375 message,
376 };
377 let manifest_yaml = files
378 .iter()
379 .find(|(rel, _)| rel == "schema.yaml")
380 .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned())
381 .ok_or_else(|| invalid("package has no schema.yaml".to_string()))?;
382 let types: Vec<(String, String)> = files
383 .iter()
384 .filter_map(|(rel, bytes)| {
385 rel.strip_prefix("types/")
386 .and_then(|f| f.strip_suffix(".yaml"))
387 .map(|stem| {
388 (
389 stem.to_string(),
390 String::from_utf8_lossy(bytes).into_owned(),
391 )
392 })
393 })
394 .collect();
395 let schema = memstead_schema::load_schema_from_memory_with_format(
399 &manifest_yaml,
400 &types,
401 memstead_schema::loader::MetadataPolarityFormat::RequiredOptIn,
402 )
403 .map_err(|e| invalid(e.to_string()))?;
404 memstead_schema::check_section_heading_roundtrip(&schema)
405 .map_err(|e| invalid(e.to_string()))?;
406 memstead_schema::check_reserved_metadata_keys(&schema)
407 .map_err(|e| invalid(e.to_string()))?;
408 memstead_schema::check_section_formats(&schema).map_err(|e| invalid(e.to_string()))?;
409 let (declared_name, declared_version) =
410 (schema.manifest.name.as_str(), schema.version.to_string());
411 if declared_name != name || declared_version != version {
412 return Err(invalid(format!(
413 "manifest declares '{declared_name}@{declared_version}' but the package is \
414 being installed as '{name}@{version}'"
415 )));
416 }
417 Self::validate_schema_exemplars(&std::sync::Arc::new(schema)).map_err(invalid)?;
418 Ok(())
419 }
420
421 pub fn validate_schema_exemplars(
443 schema: &std::sync::Arc<memstead_schema::Schema>,
444 ) -> Result<(), String> {
445 let with_exemplars: Vec<&str> = schema
446 .manifest
447 .types
448 .iter()
449 .filter(|t| {
450 schema
451 .types
452 .get(t.as_str())
453 .is_some_and(|td| td.exemplar.is_some())
454 })
455 .map(String::as_str)
456 .collect();
457 if with_exemplars.is_empty() {
458 return Ok(());
459 }
460
461 let (name, version) = schema.id();
462 let mem = "exemplar";
463 let mount = crate::workspace::Mount {
464 mem: mem.to_string(),
465 schema: Some(memstead_schema::SchemaRef::new(name, version)),
466 storage: crate::workspace::MountStorage::InMemory,
467 capability: crate::workspace::MountCapability::Write,
468 lifecycle: crate::workspace::MountLifecycle::Eager,
469 cross_linkable: true,
470 migration_target: None,
471 };
472 let backend = Box::new(crate::storage::InMemoryBackend::new()) as Box<dyn MemBackend>;
473 let mut engine = Engine::from_mounts_with_schemas_dir_and_extra(
474 vec![(mount, backend)],
475 None,
476 vec![schema.clone()],
477 )
478 .map_err(|e| format!("exemplar validation could not boot: {e}"))?;
479
480 for type_name in with_exemplars {
481 let td = schema
482 .types
483 .get(type_name)
484 .expect("filtered on presence above");
485 let ex = td.exemplar.as_ref().expect("filtered on presence above");
486 let mut relations = Vec::with_capacity(ex.relations.len());
487 for r in &ex.relations {
488 let target = r.target_slug();
489 if target.contains("--") || target.trim().is_empty() {
490 return Err(format!(
491 "type '{type_name}' exemplar relation target '{target}' must be a bare \
492 placeholder slug (no `--`, non-empty) — exemplars live outside \
493 any mem",
494 ));
495 }
496 relations.push(crate::ops::RelateArg {
497 target: crate::entity::EntityId::new(mem, target),
498 rel_type: r.rel_type_name().to_string(),
499 description: r.description.clone(),
500 });
501 }
502 let args = crate::engine::CreateEntityArgs {
503 anchors: Vec::new(),
504 mem: mem.to_string(),
505 title: ex.title.clone(),
506 entity_type: type_name.to_string(),
507 sections: ex.sections.clone(),
508 metadata: ex.metadata.clone(),
509 relations,
510 dry_run: true,
511 };
512 if let Err(e) = engine.create_entity(args, crate::vcs::Actor::Cli, None, None) {
513 return Err(format!(
514 "type '{type_name}' exemplar does not conform: [{}] {e}",
515 e.code()
516 ));
517 }
518 }
519 Ok(())
520 }
521 pub fn unregister_writable_mem(
540 &mut self,
541 mem_name: &str,
542 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
543 let pos = self.mounts.iter().position(|m| m.mount.mem == mem_name);
544 let Some(idx) = pos else {
545 return Ok(None);
546 };
547
548 let mount = self.mounts.remove(idx);
554
555 self.schemas_remove(&mount.mount.mem);
558
559 let _removed = self.store.remove_entities_by_mem(mem_name);
564
565 self.load_warnings
576 .retain(|w| w.source_mem() != Some(mem_name));
577
578 Arc::make_mut(&mut self.mem_router).remove_writable(mem_name);
586
587 self.invalidate_communities();
592 self.invalidate_search_indexes();
593
594 Ok(Some(mount.backend))
595 }
596
597 pub fn register_read_mount(
605 &mut self,
606 mount: Mount,
607 backend: Box<dyn MemBackend>,
608 origin: MemOrigin,
609 ) -> Result<(), EngineError> {
610 self.register_writable_mem_inner(mount, backend, origin, true)
611 }
612
613 pub fn unregister_read_mount(
621 &mut self,
622 mem_name: &str,
623 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
624 let pos = self.mounts.iter().position(|m| {
625 m.mount.mem == mem_name
626 && m.mount.capability == crate::workspace::MountCapability::ReadOnly
627 });
628 let Some(idx) = pos else {
629 return Ok(None);
630 };
631 let mount = self.mounts.remove(idx);
632 self.schemas_remove(&mount.mount.mem);
633 let _removed = self.store.remove_entities_by_mem(mem_name);
634 self.load_warnings
635 .retain(|w| w.source_mem() != Some(mem_name));
636 Arc::make_mut(&mut self.mem_router).remove_read_only(mem_name);
637 self.invalidate_communities();
638 self.invalidate_search_indexes();
639 Ok(Some(mount.backend))
640 }
641
642 pub fn push_load_warning(&mut self, warning: crate::ops::WarningHint) {
647 self.load_warnings.push(warning);
648 }
649
650 pub fn register_writable_mem(
689 &mut self,
690 mount: Mount,
691 backend: Box<dyn MemBackend>,
692 origin: MemOrigin,
693 ) -> Result<(), EngineError> {
694 self.register_writable_mem_inner(mount, backend, origin, true)
695 }
696
697 fn register_writable_mem_inner(
707 &mut self,
708 mount: Mount,
709 backend: Box<dyn MemBackend>,
710 origin: MemOrigin,
711 run_global_passes: bool,
712 ) -> Result<(), EngineError> {
713 if let Some(existing) = self.mem_router.origin_for_mem(&mount.mem) {
715 return Err(EngineError::MemNameCollision {
716 name: mount.mem.clone(),
717 source_origin: existing.render_source(),
718 });
719 }
720 if self.mem_router.archive_path_for_mem(&mount.mem).is_some() {
721 return Err(EngineError::MemNameCollision {
722 name: mount.mem.clone(),
723 source_origin: "attached read mem".to_string(),
724 });
725 }
726
727 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
733 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
734 memstead_schema::config::parse_mem_config(&value).ok()
735 });
736
737 let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
747 self.workspace_schemas().to_vec();
748 builtin_schemas.extend(super::boot::embedded_archive_schemas(&mount));
753 builtin_schemas.extend(
754 memstead_schema::builtins::load_builtin_schemas()
755 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?,
756 );
757 let config_pin = mem_config.as_ref().and_then(|c| c.schema.as_ref());
758 let mount_pin = mount.schema.as_ref();
759 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
760 && cfg != mp
761 {
762 self.load_warnings
763 .push(crate::ops::WarningHint::SchemaPinMismatch {
764 mem: mount.mem.clone(),
765 config_pin: cfg.as_display(),
766 mount_pin: mp.as_display(),
767 });
768 }
769 let settled_pin = config_pin.or(mount_pin);
770 let effective_pin = mount
771 .migration_target
772 .as_ref()
773 .or(settled_pin)
774 .ok_or_else(|| EngineError::MemConfigIncomplete {
775 mem: mount.mem.clone(),
776 missing_fields: vec!["schema".to_string()],
777 })?
778 .clone();
779 let schema = crate::engine::SchemaResolver::new(&builtin_schemas)
780 .resolve(&effective_pin)
781 .map_err(|sources| {
782 EngineError::SchemaNotFound {
783 mem: mount.mem.clone(),
784 pin: effective_pin.as_display(),
785 sources,
786 install_hint: None,
787 }
788 .with_schema_install_probe(self.workspace_root())
789 })?;
790
791 let (entries, read_errors) = collect_source_entries(backend.as_ref())?;
799 if let Some(w) =
800 super::boot::unbacked_mount_warning(&mount, backend.as_ref(), Some(entries.len()))
801 {
802 self.load_warnings.push(w);
803 }
804 let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
805
806 let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
807 mem_names.push(mount.mem.clone());
808 let known_suffixes: Vec<String> = mem_names
809 .iter()
810 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
811 .collect();
812 let fallback = engine_fallback_type();
813 push_entities_into_store(
814 &mut self.store,
815 load_result.entities,
816 fallback.as_ref(),
817 Some(crate::entity::store_builder::LoadCollector {
818 warnings: &mut self.load_warnings,
819 known_suffixes: &known_suffixes,
820 mem_names: &mem_names,
821 }),
822 );
823 self.load_errors.extend(load_result.errors);
824
825 self.schemas_insert(mount.mem.clone(), schema);
827
828 if run_global_passes {
839 let mut mount_caps: std::collections::HashMap<
840 String,
841 crate::workspace::MountCapability,
842 > = self
843 .mounts
844 .iter()
845 .map(|m| (m.mount.mem.clone(), m.mount.capability))
846 .collect();
847 mount_caps.insert(mount.mem.clone(), mount.capability);
848 crate::entity::store_builder::validate_loaded_relations(
849 &mut self.store,
850 &self.schemas,
851 &mount_caps,
852 &mut self.load_warnings,
853 );
854 crate::entity::store_builder::remap_alias_target_edge_sources(
855 &mut self.store,
856 &self.schemas,
857 );
858 }
859
860 let last_known_head = backend.current_head().ok().flatten();
862 let mem_name_for_router = mount.mem.clone();
863 let storage_for_router = mount.storage.clone();
864 let mount_capability_for_router = mount.capability;
865 self.mounts.push(MountedBackend {
866 mount,
867 backend,
868 last_known_head,
869 mem_config,
870 archive_provenance: None,
873 deferred: false,
875 });
876
877 match (&mount_capability_for_router, &storage_for_router) {
885 (crate::workspace::MountCapability::ReadOnly, MountStorage::Archive { path }) => {
886 Arc::make_mut(&mut self.mem_router)
887 .add_read_only(mem_name_for_router, path.clone());
888 }
889 _ => {
890 let dir: Option<PathBuf> = match &storage_for_router {
891 MountStorage::Folder { path } => Some(path.clone()),
892 MountStorage::GitBranch { .. }
893 | MountStorage::Archive { .. }
894 | MountStorage::InMemory => None,
895 };
896 Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
897 }
898 }
899
900 if run_global_passes {
902 self.invalidate_communities();
903 self.invalidate_search_indexes();
904 }
905
906 Ok(())
907 }
908
909 pub(crate) fn register_writable_mem_batched(
917 &mut self,
918 mount: Mount,
919 backend: Box<dyn MemBackend>,
920 origin: MemOrigin,
921 ) -> Result<(), EngineError> {
922 self.register_writable_mem_inner(mount, backend, origin, false)
923 }
924
925 pub(crate) fn finish_batched_registrations(&mut self) {
926 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
927 .mounts
928 .iter()
929 .map(|m| (m.mount.mem.clone(), m.mount.capability))
930 .collect();
931 crate::entity::store_builder::validate_loaded_relations(
932 &mut self.store,
933 &self.schemas,
934 &mount_caps,
935 &mut self.load_warnings,
936 );
937 crate::entity::store_builder::remap_alias_target_edge_sources(
938 &mut self.store,
939 &self.schemas,
940 );
941 self.invalidate_communities();
942 self.invalidate_search_indexes();
943 }
944
945 pub fn full_refresh(&mut self) -> crate::ops::FullRefreshReport {
970 let started = std::time::Instant::now();
971 let mut report = crate::ops::FullRefreshReport::default();
972
973 let Some(_root) = self.workspace_root.clone() else {
974 report.failures.push(crate::ops::RefreshFailure {
975 item: "workspace".to_string(),
976 error: "engine has no workspace root (ad-hoc mount-list construction) — \
977 nothing to re-scan"
978 .to_string(),
979 });
980 report.elapsed_ms = started.elapsed().as_millis() as u64;
981 return report;
982 };
983
984 self.refresh_workspace_settings_if_possible();
987
988 self.refresh_schema_sources(&mut report);
990
991 match self.reconcile_roster_forced() {
995 Ok(change) => {
996 report.mems_mounted = change.added;
997 report.mems_unmounted = change.removed;
998 report.mems_quarantined = change.quarantined;
999 report.failures.extend(change.failures);
1000 }
1001 Err(e) => report.failures.push(crate::ops::RefreshFailure {
1002 item: "mount-manifest".to_string(),
1003 error: e.to_string(),
1004 }),
1005 }
1006 report.mems_mounted.sort();
1007 report.mems_unmounted.sort();
1008 report.mems_quarantined.sort();
1009
1010 report.elapsed_ms = started.elapsed().as_millis() as u64;
1011 report
1012 }
1013
1014 pub(crate) fn refresh_schema_sources(&mut self, report: &mut crate::ops::FullRefreshReport) {
1017 let Some(root) = self.workspace_root.clone() else {
1018 return;
1019 };
1020 use crate::schema_source::SchemaSource as _;
1021 let mut fresh: Vec<std::sync::Arc<memstead_schema::Schema>> = Vec::new();
1022 let mut sources_complete = true;
1023 match crate::schema_source::FolderSchemaSource::for_workspace(&root).read_schemas() {
1024 Ok(mut s) => fresh.append(&mut s),
1025 Err(e) => {
1026 sources_complete = false;
1027 report.failures.push(crate::ops::RefreshFailure {
1028 item: "schema-source:folder".to_string(),
1029 error: e.to_string(),
1030 });
1031 }
1032 }
1033 if let Some(ops) = self.git_branch_ops() {
1034 match (ops.read_ref_schemas)(&root) {
1035 Ok(mut s) => fresh.append(&mut s),
1036 Err(e) => {
1037 sources_complete = false;
1038 report.failures.push(crate::ops::RefreshFailure {
1039 item: "schema-source:memstead-ref".to_string(),
1040 error: e.to_string(),
1041 });
1042 }
1043 }
1044 }
1045 let key = |s: &memstead_schema::Schema| {
1046 let (name, version) = s.id();
1047 format!("{name}@{version}")
1048 };
1049 let existing: std::collections::HashSet<String> =
1050 self.workspace_schemas.iter().map(|s| key(s)).collect();
1051 let fresh_keys: std::collections::HashSet<String> = fresh.iter().map(|s| key(s)).collect();
1052 for schema in fresh {
1053 let k = key(&schema);
1054 if !existing.contains(&k) && !report.schemas_added.contains(&k) {
1055 report.schemas_added.push(k);
1056 self.workspace_schemas.push(schema);
1057 }
1058 }
1059 report.schemas_added.sort();
1060 if sources_complete {
1064 report.schema_removals_skipped = existing
1065 .difference(&fresh_keys)
1066 .cloned()
1067 .collect::<Vec<_>>();
1068 report.schema_removals_skipped.sort();
1069 }
1070 }
1071
1072 pub fn set_workspace_root(&mut self, root: PathBuf) {
1078 self.workspace_root = Some(root);
1079 self.capture_roster_fingerprint();
1080 }
1081
1082 pub fn persist_state(&self) -> Result<(), EngineError> {
1105 let Some(root) = self.workspace_root.as_ref() else {
1106 return Ok(());
1107 };
1108 use crate::workspace_store::WorkspaceStoreAdapter as _;
1109 let store = crate::FileWorkspaceStore::new();
1110 let map = |e: crate::workspace_store::StoreError| {
1111 EngineError::Mem(format!("persist workspace state: {e}"))
1112 };
1113
1114 let ours: Vec<crate::workspace::Mount> = self
1131 .mounts
1132 .iter()
1133 .map(|m| m.mount.clone())
1134 .chain(self.quarantined.iter().map(|q| q.mount.clone()))
1135 .collect();
1136
1137 for attempt in 0..8 {
1138 let expected = store.read_state_bytes(root).map_err(map)?;
1139 let on_disk: Vec<crate::workspace::Mount> = match expected.as_deref() {
1140 Some(bytes) => store.parse_state_bytes(root, bytes).map_err(map)?,
1141 None => Vec::new(),
1142 };
1143
1144 let merged = {
1145 let baseline = self.mounts_baseline.borrow();
1146 merge_mount_rosters(&baseline, &ours, on_disk)
1147 };
1148 let workspace = crate::workspace::Workspace {
1149 mounts: merged,
1150 settings: self.settings.clone(),
1151 };
1152
1153 if store
1154 .save_state_cas(root, &workspace, expected.as_deref())
1155 .map_err(map)?
1156 {
1157 *self.mounts_baseline.borrow_mut() = ours;
1158 return Ok(());
1159 }
1160 if attempt == 7 {
1161 return Err(EngineError::Mem(
1162 "workspace state is being written concurrently: eight compare-and-set \
1163 attempts all lost the race. Retry, or find the writer that is not \
1164 backing off."
1165 .to_string(),
1166 ));
1167 }
1168 }
1169 unreachable!("the loop returns on success and on exhaustion")
1170 }
1171 pub fn set_mem_schema(
1191 &mut self,
1192 mem: &str,
1193 target: &memstead_schema::SchemaRef,
1194 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1195 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1196 if self.quarantine_reason(mem).is_some() {
1202 return self.set_schema_on_quarantined(mem, target);
1203 }
1204 let mount_idx = self
1205 .mounts
1206 .iter()
1207 .position(|m| m.mount.mem == mem)
1208 .ok_or_else(|| self.unknown_mem_error(mem))?;
1209 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1215 return Err(EngineError::ReadOnlyMount(mem.to_string()));
1216 }
1217
1218 let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
1221 let consulted: Vec<_> = self
1224 .workspace_schemas
1225 .iter()
1226 .chain(self.builtin_schemas.iter())
1227 .cloned()
1228 .collect();
1229 EngineError::SchemaNotFound {
1230 mem: mem.to_string(),
1231 pin: target.as_display(),
1232 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1233 &target.name,
1234 &target.version,
1235 &consulted,
1236 ),
1237 install_hint: None,
1238 }
1239 .with_schema_install_probe(self.workspace_root())
1240 })?;
1241
1242 let current_pin = self.mounts[mount_idx].mount.schema.clone();
1247 let current_pin_display = current_pin
1248 .as_ref()
1249 .map(|p| p.as_display())
1250 .unwrap_or_else(|| "<unset>".to_string());
1251 let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1252
1253 let served_pin: Option<memstead_schema::SchemaRef> = self.schemas.get(mem).map(|s| {
1264 let (name, version) = s.id();
1265 memstead_schema::SchemaRef::new(name, version)
1266 });
1267 if served_pin.as_ref() == Some(target) && in_flight.is_none() {
1273 if current_pin.as_ref() == Some(target) {
1274 return Ok(SetSchemaOutcome {
1275 mem: mem.to_string(),
1276 schema_pin: current_pin_display,
1277 migration_target: in_flight.map(|t| t.as_display()),
1278 outcome: SetSchemaResult::Noop,
1279 findings: Vec::new(),
1280 stamped_schema: self.stamped_schema_of(mount_idx),
1281 });
1282 }
1283 self.mounts[mount_idx].mount.schema = Some(target.clone());
1284 self.mounts[mount_idx].mount.migration_target = None;
1285 self.persist_state()?;
1286 return Ok(SetSchemaOutcome {
1287 mem: mem.to_string(),
1288 schema_pin: target.as_display(),
1289 migration_target: None,
1290 outcome: SetSchemaResult::Switched,
1291 findings: Vec::new(),
1292 stamped_schema: self.stamped_schema_of(mount_idx),
1293 });
1294 }
1295
1296 let findings = crate::ops::integrity::conformance_findings(
1302 &self.store,
1303 mem,
1304 target_schema.as_ref(),
1305 &self.schemas,
1306 );
1307
1308 if findings.is_empty() {
1309 self.persist_mem_schema_pin(mount_idx, target)?;
1317 self.mounts[mount_idx].mount.schema = Some(target.clone());
1318 self.mounts[mount_idx].mount.migration_target = None;
1319 self.schemas_insert(mem.to_string(), target_schema);
1320 self.invalidate_communities();
1321 self.invalidate_search_indexes();
1326 self.persist_state()?;
1327 let _stamp_warnings_have_no_channel_here = self.stamp_mutation_versions(mount_idx);
1337 return Ok(SetSchemaOutcome {
1338 mem: mem.to_string(),
1339 schema_pin: target.as_display(),
1340 migration_target: None,
1341 outcome: SetSchemaResult::Switched,
1342 findings: Vec::new(),
1343 stamped_schema: self.stamped_schema_of(mount_idx),
1344 });
1345 }
1346
1347 let outcome = if in_flight.as_ref() == Some(target) {
1348 SetSchemaResult::MigrationPending
1349 } else {
1350 SetSchemaResult::MigrationStarted
1351 };
1352 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1353 self.schemas_insert(mem.to_string(), target_schema);
1356 self.invalidate_communities();
1357 self.invalidate_search_indexes();
1359 self.persist_state()?;
1360 Ok(SetSchemaOutcome {
1364 mem: mem.to_string(),
1365 schema_pin: current_pin_display,
1366 migration_target: Some(target.as_display()),
1367 outcome,
1368 findings,
1369 stamped_schema: self.stamped_schema_of(mount_idx),
1370 })
1371 }
1372
1373 fn stamped_schema_of(&self, mount_idx: usize) -> Option<String> {
1377 self.mounts
1378 .get(mount_idx)
1379 .and_then(|m| m.mem_config.as_ref())
1380 .and_then(|c| c.mutation_stamp.as_ref())
1381 .map(|st| st.schema.clone())
1382 }
1383
1384 pub(crate) fn write_mem_config_merged(
1433 &mut self,
1434 mount_idx: usize,
1435 mem_name: &str,
1436 note: Option<&str>,
1437 apply: &dyn Fn(&mut memstead_schema::config::MemConfig),
1438 ) -> Result<(memstead_schema::config::MemConfig, Vec<String>), EngineError> {
1439 let backend = self.mounts[mount_idx].backend.as_ref();
1440 let read = |b: &dyn crate::backend::MemBackend| -> Result<Vec<u8>, EngineError> {
1441 b.read_mem_config()
1442 .map_err(|e| EngineError::Mem(format!("read mem config for update: {e}")))?
1443 .ok_or_else(|| {
1444 EngineError::InvalidInput(format!(
1445 "mem '{mem_name}' has no stored MemConfig (initialize the mem via \
1446 `memstead init` or `memstead mem create` first)"
1447 ))
1448 })
1449 };
1450
1451 let stored = read(backend)?;
1452 let intervened = match self.mounts[mount_idx].mem_config.as_ref() {
1457 Some(cached) => changed_config_fields(cached, &stored),
1458 None => Vec::new(),
1459 };
1460
1461 let render =
1462 |raw: &[u8]| -> Result<(memstead_schema::config::MemConfig, Vec<u8>), EngineError> {
1463 let value: serde_json::Value = serde_json::from_slice(raw)
1464 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1465 let mut cfg = memstead_schema::config::parse_mem_config(&value)
1466 .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1467 apply(&mut cfg);
1468 let mut bytes = serde_json::to_vec_pretty(&cfg)
1469 .map_err(|e| EngineError::Mem(format!("serialize mem config: {e}")))?;
1470 bytes.push(b'\n');
1471 Ok((cfg, bytes))
1472 };
1473 let (mut parsed, mut bytes) = render(&stored)?;
1474
1475 let mut expected = stored;
1484 for attempt in 0..8 {
1485 let wrote = self.mounts[mount_idx].backend.write_mem_config_cas(
1486 Some(&expected),
1487 &bytes,
1488 note,
1489 )?;
1490 if wrote {
1491 break;
1492 }
1493 if attempt == 7 {
1494 return Err(EngineError::Mem(format!(
1495 "mem '{mem_name}' config is being written concurrently: eight \
1496 compare-and-set attempts all lost the race. Retry, or find the \
1497 writer that is not backing off."
1498 )));
1499 }
1500 expected = read(self.mounts[mount_idx].backend.as_ref())?;
1501 let rendered = render(&expected)?;
1502 parsed = rendered.0;
1503 bytes = rendered.1;
1504 }
1505
1506 let mounted = &mut self.mounts[mount_idx];
1507 mounted.mem_config = Some(parsed.clone());
1508 if let Some(sha) = mounted.backend.current_head().ok().flatten() {
1511 mounted.last_known_head = Some(sha);
1512 }
1513 Ok((parsed, intervened))
1514 }
1515
1516 fn persist_mem_schema_pin(
1517 &mut self,
1518 mount_idx: usize,
1519 target: &memstead_schema::SchemaRef,
1520 ) -> Result<(), EngineError> {
1521 let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1522 if let Some(value) = value
1525 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1526 {
1527 self.mounts[mount_idx].mem_config = Some(cfg);
1528 }
1529 Ok(())
1530 }
1531
1532 pub fn export_markdown(
1561 &self,
1562 mem_filter: Option<&str>,
1563 schema_filter: Option<&str>,
1564 ) -> Result<crate::ops::ExportResult, EngineError> {
1565 use crate::workspace::MountStorage;
1566 let fallback = engine_fallback_type();
1567 let supported_backends = vec!["folder".to_string()];
1568
1569 if let Some(name) = mem_filter {
1570 let mount = self
1571 .mounts
1572 .iter()
1573 .find(|m| m.mount.mem == name)
1574 .ok_or_else(|| self.unknown_mem_error(name))?;
1575 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1576 return Err(EngineError::MarkdownExportUnsupportedBackend {
1577 mem: name.to_string(),
1578 active_backend: mount.mount.storage.backend_id().to_string(),
1579 supported_backends,
1580 });
1581 }
1582 }
1583
1584 let mut total_written = 0;
1585 let mut refused: Vec<crate::ops::RefusedEntity> = Vec::new();
1586 let mut total_unchanged = 0;
1587 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1588
1589 for mount in &self.mounts {
1590 let mem_name = mount.mount.mem.as_str();
1591 if let Some(filter) = mem_filter
1592 && mem_name != filter
1593 {
1594 continue;
1595 }
1596 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1597 skipped_mounts.push(crate::ops::SkippedMount {
1598 mem: mem_name.to_string(),
1599 active_backend: mount.mount.storage.backend_id().to_string(),
1600 reason: "backend_does_not_support_markdown_export".to_string(),
1601 });
1602 continue;
1603 };
1604 let schema = match self.schemas.get(mem_name) {
1605 Some(s) => s,
1606 None => continue,
1607 };
1608
1609 for entity in self.store.all_entities() {
1610 if entity.stub || entity.file_path.is_empty() {
1611 continue;
1612 }
1613 if entity.id.mem() != mem_name {
1614 continue;
1615 }
1616 if let Some(filter) = schema_filter
1617 && entity.entity_type != filter
1618 {
1619 continue;
1620 }
1621 let type_def = schema
1622 .get_type(&entity.entity_type)
1623 .unwrap_or_else(|| fallback.clone());
1624 let generated = generate_markdown(entity, type_def.as_ref());
1625
1626 let full_path = mem_dir.join(&entity.file_path);
1627 let needs_write = match std::fs::read_to_string(&full_path) {
1628 Ok(existing) => existing != generated,
1629 Err(_) => true,
1630 };
1631 if needs_write {
1632 match crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref()) {
1633 Ok(_) => total_written += 1,
1634 Err(e @ crate::entity::writer::WriteError::UnterminatedFence { .. }) => {
1639 refused.push(crate::ops::RefusedEntity {
1640 id: entity.id.to_string(),
1641 reason: "UNTERMINATED_FENCE_IN_STORED_BODY".to_string(),
1642 detail: e.to_string(),
1643 });
1644 }
1645 Err(_) => {}
1646 }
1647 } else {
1648 total_unchanged += 1;
1649 }
1650 }
1651 }
1652
1653 Ok(crate::ops::ExportResult {
1654 refused_entities: refused,
1655 written: total_written,
1656 unchanged: total_unchanged,
1657 skipped_mounts,
1658 })
1659 }
1660
1661 pub(crate) fn ref_schema_source_for(
1688 &self,
1689 config: &memstead_schema::MemConfig,
1690 ) -> Option<Vec<memstead_schema::SchemaSourceFile>> {
1691 let ops = self.git_branch_ops.as_ref()?;
1692 let root = self.workspace_root.as_deref()?;
1693 let pin = config.schema.as_ref()?;
1694 (ops.collect_ref_schema_source)(root, pin).ok().flatten()
1695 }
1696
1697 pub fn export_mem(
1698 &self,
1699 mem_name: &str,
1700 output_path: &std::path::Path,
1701 ) -> Result<crate::ops::MemExportResult, EngineError> {
1702 let mount = self
1703 .mounts
1704 .iter()
1705 .find(|m| m.mount.mem == mem_name)
1706 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1707 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1708 EngineError::InvalidInput(format!(
1709 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1710 ))
1711 })?;
1712 if config.version.is_none() {
1720 return Err(EngineError::MemConfigIncomplete {
1721 mem: mem_name.to_string(),
1722 missing_fields: vec!["version".to_string()],
1723 });
1724 }
1725 let fenced: Vec<String> = self
1731 .store
1732 .all_entities()
1733 .filter(|e| e.mem == mem_name && !e.stub)
1734 .filter(|e| {
1735 e.sections
1736 .values()
1737 .any(|v| crate::markdown::closing_fence_if_unterminated(v.trim()).is_some())
1738 })
1739 .map(|e| e.id.to_string())
1740 .collect();
1741 let workspace_root = self.workspace_root.as_deref();
1742 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1746 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1747 let exported = match &mount.mount.storage {
1748 MountStorage::Folder { path } => crate::ops::export::export_mem(
1749 path,
1750 config,
1751 output_path,
1752 workspace_root,
1753 workspace_schemas_dir,
1754 self.ref_schema_source_for(config),
1755 )
1756 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1757 MountStorage::GitBranch { gitdir, branch } => {
1758 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1759 EngineError::Backend(BackendError::Other(
1760 "git-branch export hook not installed (full flavour not loaded)"
1761 .to_string(),
1762 ))
1763 })?;
1764 let (provenance, redactions) = mount
1768 .backend
1769 .read_provenance(None)
1770 .ok()
1771 .map(|records| crate::ops::export::build_redacted_archive_provenance(&records))
1772 .unwrap_or((None, Vec::new()));
1773 let provenance_bytes = provenance.and_then(|prov| prov.to_archive_bytes().ok());
1774 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1777 (hook.export)(
1778 gitdir,
1779 branch,
1780 mem_name,
1781 config,
1782 output_path,
1783 workspace_root,
1784 workspace_schemas_dir,
1785 provenance_bytes.as_deref(),
1786 anchors_bytes.as_deref(),
1787 )
1788 .map(|mut r| {
1789 r.redactions = redactions;
1790 r
1791 })
1792 .map_err(EngineError::Backend)
1793 }
1794 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1795 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1800 "export not yet supported for in-memory backend".to_string(),
1801 ))),
1802 };
1803 exported.map(|mut r| {
1804 r.unterminated_fence_entities = fenced;
1805 r
1806 })
1807 }
1808
1809 pub fn record_pipeline_edit_provenance(
1833 &self,
1834 mem: &str,
1835 kind: &str,
1836 edits: &[(String, Option<Vec<u8>>)],
1837 note: Option<&str>,
1838 verb: &str,
1839 ) -> Result<(), crate::backend::BackendError> {
1840 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1841 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1842 None => Ok(()),
1843 }
1844 }
1845
1846 pub fn set_mem_version(
1847 &mut self,
1848 mem_name: &str,
1849 new_version: semver::Version,
1850 note: Option<&str>,
1851 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1852 let mount_idx = self
1855 .mounts
1856 .iter()
1857 .position(|m| m.mount.mem == mem_name)
1858 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1859 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1860 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1861 }
1862
1863 let mut warnings = self.reload_if_stale(Some(mem_name));
1871 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1876 warnings.push(w);
1877 }
1878
1879 let old_version = self.mounts[mount_idx]
1883 .mem_config
1884 .as_ref()
1885 .and_then(|c| c.version.clone());
1886 let target = new_version.clone();
1887 let (_, intervened) = self.write_mem_config_merged(
1888 mount_idx,
1889 mem_name,
1890 note,
1891 &move |c: &mut memstead_schema::config::MemConfig| {
1892 c.version = Some(target.clone());
1893 },
1894 )?;
1895 if !intervened.is_empty() {
1896 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1897 mem: mem_name.to_string(),
1898 fields: intervened,
1899 });
1900 }
1901
1902 Ok(crate::ops::SetMemVersionOutcome {
1903 mem: mem_name.to_string(),
1904 old_version,
1905 new_version,
1906 warnings,
1907 })
1908 }
1909
1910 pub fn set_mem_description(
1917 &mut self,
1918 mem_name: &str,
1919 new_description: Option<String>,
1920 note: Option<&str>,
1921 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1922 let mount_idx = self
1923 .mounts
1924 .iter()
1925 .position(|m| m.mount.mem == mem_name)
1926 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1927 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1928 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1929 }
1930
1931 let mut warnings = self.reload_if_stale(Some(mem_name));
1932 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1933 warnings.push(w);
1934 }
1935
1936 let old_description = self.mounts[mount_idx]
1937 .mem_config
1938 .as_ref()
1939 .and_then(|c| c.description.clone());
1940 let target = new_description.clone();
1941 let (_, intervened) = self.write_mem_config_merged(
1942 mount_idx,
1943 mem_name,
1944 note,
1945 &move |c: &mut memstead_schema::config::MemConfig| {
1946 c.description = target.clone();
1947 },
1948 )?;
1949 if !intervened.is_empty() {
1950 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1951 mem: mem_name.to_string(),
1952 fields: intervened,
1953 });
1954 }
1955
1956 Ok(crate::ops::SetMemDescriptionOutcome {
1957 mem: mem_name.to_string(),
1958 old_description,
1959 new_description,
1960 warnings,
1961 })
1962 }
1963
1964 pub fn set_mem_title(
1969 &mut self,
1970 mem_name: &str,
1971 new_title: Option<String>,
1972 note: Option<&str>,
1973 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1974 let mount_idx = self
1975 .mounts
1976 .iter()
1977 .position(|m| m.mount.mem == mem_name)
1978 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1979 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1980 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1981 }
1982
1983 let mut warnings = self.reload_if_stale(Some(mem_name));
1984 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1985 warnings.push(w);
1986 }
1987
1988 let old_title = self.mounts[mount_idx]
1989 .mem_config
1990 .as_ref()
1991 .and_then(|c| c.title.clone());
1992 let target = new_title.clone();
1993 let (_, intervened) = self.write_mem_config_merged(
1994 mount_idx,
1995 mem_name,
1996 note,
1997 &move |c: &mut memstead_schema::config::MemConfig| {
1998 c.title = target.clone();
1999 },
2000 )?;
2001 if !intervened.is_empty() {
2002 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2003 mem: mem_name.to_string(),
2004 fields: intervened,
2005 });
2006 }
2007
2008 Ok(crate::ops::SetMemTitleOutcome {
2009 mem: mem_name.to_string(),
2010 old_title,
2011 new_title,
2012 warnings,
2013 })
2014 }
2015
2016 pub fn set_mem_subject(
2020 &mut self,
2021 mem_name: &str,
2022 new_subject: Option<memstead_schema::MemSubject>,
2023 note: Option<&str>,
2024 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
2025 let mount_idx = self
2026 .mounts
2027 .iter()
2028 .position(|m| m.mount.mem == mem_name)
2029 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2030 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2031 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2032 }
2033
2034 let mut warnings = self.reload_if_stale(Some(mem_name));
2035 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
2036 warnings.push(w);
2037 }
2038
2039 let old_subject = self.mounts[mount_idx]
2040 .mem_config
2041 .as_ref()
2042 .and_then(|c| c.subject.clone());
2043 let target = new_subject.clone();
2044 let (_, intervened) = self.write_mem_config_merged(
2045 mount_idx,
2046 mem_name,
2047 note,
2048 &move |c: &mut memstead_schema::config::MemConfig| {
2049 c.subject = target.clone();
2050 },
2051 )?;
2052 if !intervened.is_empty() {
2053 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2054 mem: mem_name.to_string(),
2055 fields: intervened,
2056 });
2057 }
2058
2059 Ok(crate::ops::SetMemSubjectOutcome {
2060 mem: mem_name.to_string(),
2061 old_subject,
2062 new_subject,
2063 warnings,
2064 })
2065 }
2066
2067 pub fn set_mem_internal(
2079 &mut self,
2080 mem_name: &str,
2081 internal: bool,
2082 note: Option<&str>,
2083 ) -> Result<crate::ops::SetMemInternalOutcome, EngineError> {
2084 let mount_idx = self
2085 .mounts
2086 .iter()
2087 .position(|m| m.mount.mem == mem_name)
2088 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2089 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2090 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2091 }
2092
2093 let _ = self.reload_if_stale(Some(mem_name));
2094
2095 let (_, intervened) = self.write_mem_config_merged(
2096 mount_idx,
2097 mem_name,
2098 note,
2099 &move |c: &mut memstead_schema::config::MemConfig| {
2100 if internal {
2101 c.extra
2102 .insert("internal".to_string(), serde_json::Value::Bool(true));
2103 } else {
2104 c.extra.remove("internal");
2105 }
2106 },
2107 )?;
2108 let mut warnings = Vec::new();
2113 if !intervened.is_empty() {
2114 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2115 mem: mem_name.to_string(),
2116 fields: intervened,
2117 });
2118 }
2119
2120 Ok(crate::ops::SetMemInternalOutcome {
2121 mem: mem_name.to_string(),
2122 internal,
2123 warnings,
2124 })
2125 }
2126
2127 pub fn set_mem_sync_state(
2152 &mut self,
2153 mem_name: &str,
2154 key: &str,
2155 token: &str,
2156 note: Option<&str>,
2157 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
2158 let mount_idx = self
2161 .mounts
2162 .iter()
2163 .position(|m| m.mount.mem == mem_name)
2164 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2165 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2166 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2167 }
2168
2169 let mut warnings = self.reload_if_stale(Some(mem_name));
2173 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
2174 warnings.push(w);
2175 }
2176
2177 let seen: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
2184 let key_owned = key.to_string();
2185 let token_owned = token.to_string();
2186 let (_, intervened) = self.write_mem_config_merged(
2187 mount_idx,
2188 mem_name,
2189 note,
2190 &|c: &mut memstead_schema::config::MemConfig| {
2191 *seen.borrow_mut() = if token_owned.is_empty() {
2192 c.sync_state.remove(&key_owned)
2193 } else {
2194 c.sync_state.insert(key_owned.clone(), token_owned.clone())
2195 };
2196 },
2197 )?;
2198 let previous = seen.into_inner();
2199 let removed = token.is_empty() && previous.is_some();
2202 if !intervened.is_empty() {
2203 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2204 mem: mem_name.to_string(),
2205 fields: intervened,
2206 });
2207 }
2208
2209 Ok(crate::ops::SetMemSyncStateOutcome {
2210 mem: mem_name.to_string(),
2211 key: key.to_string(),
2212 previous,
2213 removed,
2214 warnings,
2215 })
2216 }
2217
2218 pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
2265 let pending: Vec<String> = self
2266 .mounts
2267 .iter()
2268 .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
2269 .map(|m| m.mount.mem.clone())
2270 .collect();
2271 for name in pending {
2272 match self.reload_one_mem(&name) {
2273 Ok(_) => {
2274 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
2275 state.deferred = false;
2276 }
2277 let store = &self.store;
2283 self.load_warnings.retain(|w| match w {
2284 crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
2285 store.get(resolved_id).is_none_or(|e| e.stub)
2286 }
2287 _ => true,
2288 });
2289 }
2290 Err(e) => {
2291 let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
2296 continue;
2297 };
2298 let removed = self.mounts.remove(idx);
2299 self.schemas_remove(&name);
2300 self.quarantined.push(crate::engine::QuarantinedMem {
2301 mount: removed.mount,
2302 reason_code: e.code().to_string(),
2303 reason_message: e.to_string(),
2304 });
2305 self.mem_router = std::sync::Arc::new(
2306 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2307 );
2308 self.invalidate_communities();
2309 self.invalidate_search_indexes();
2315 }
2316 }
2317 }
2318 }
2319
2320 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2321 if self.quarantine_reason(mem).is_some() {
2344 return self.reattach_quarantined_mem(mem);
2345 }
2346 let mut sink: Vec<WarningHint> = Vec::new();
2347 let result = self.reload_one_mem_inner(mem, &mut sink)?;
2348 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2349 self.load_warnings
2350 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2351 Ok(result)
2352 }
2353
2354 fn set_schema_on_quarantined(
2364 &mut self,
2365 mem: &str,
2366 target: &memstead_schema::SchemaRef,
2367 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2368 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2369 if self.resolve_schema_by_ref(target).is_none() {
2371 let consulted: Vec<_> = self
2372 .workspace_schemas
2373 .iter()
2374 .chain(self.builtin_schemas.iter())
2375 .cloned()
2376 .collect();
2377 return Err(EngineError::SchemaNotFound {
2378 mem: mem.to_string(),
2379 pin: target.as_display(),
2380 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2381 &target.name,
2382 &target.version,
2383 &consulted,
2384 ),
2385 install_hint: None,
2386 }
2387 .with_schema_install_probe(self.workspace_root()));
2388 }
2389 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2390 return Err(self.unknown_mem_error(mem));
2391 };
2392 self.quarantined[q_idx].mount.schema = Some(target.clone());
2396 self.quarantined[q_idx].mount.migration_target = None;
2397 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2398 let _ = bump_backend_schema_pin(backend.as_ref(), target);
2399 }
2400 self.persist_state()?;
2401 let _ = self.reattach_quarantined_mem(mem);
2406 let stamped_schema = self
2411 .mounts
2412 .iter()
2413 .position(|m| m.mount.mem == mem)
2414 .and_then(|idx| self.stamped_schema_of(idx));
2415 Ok(SetSchemaOutcome {
2416 mem: mem.to_string(),
2417 schema_pin: target.as_display(),
2418 migration_target: None,
2419 outcome: SetSchemaResult::Switched,
2420 findings: Vec::new(),
2421 stamped_schema,
2422 })
2423 }
2424
2425 fn reattach_quarantined_mem(
2433 &mut self,
2434 mem: &str,
2435 ) -> Result<crate::ops::ReloadResult, EngineError> {
2436 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2437 return Err(self.unknown_mem_error(mem));
2438 };
2439 let mount = self.quarantined[q_idx].mount.clone();
2440
2441 let requarantine = |this: &mut Self, e: &EngineError| {
2442 this.quarantined[q_idx].reason_code = e.code().to_string();
2443 this.quarantined[q_idx].reason_message = e.to_string();
2444 };
2445
2446 let backend = match (self.backend_factory)(&mount) {
2447 Ok(b) => b,
2448 Err(e) => {
2449 let err = EngineError::Mem(e.to_string());
2450 requarantine(self, &err);
2451 return Err(self.unknown_mem_error(mem));
2452 }
2453 };
2454
2455 let last_known_head = backend.current_head().ok().flatten();
2457 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2458 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2459 memstead_schema::config::parse_mem_config(&value).ok()
2460 });
2461 let archive_provenance = backend
2462 .read_archive_provenance()
2463 .ok()
2464 .flatten()
2465 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2466 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2467 let effective_pin = mount
2468 .migration_target
2469 .clone()
2470 .or(config_pin)
2471 .or(mount.schema.clone());
2472 let Some(effective_pin) = effective_pin else {
2473 let err = EngineError::MemConfigIncomplete {
2474 mem: mem.to_string(),
2475 missing_fields: vec!["schema".to_string()],
2476 };
2477 requarantine(self, &err);
2478 return Err(self.unknown_mem_error(mem));
2479 };
2480 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2484 .workspace_schemas
2485 .iter()
2486 .cloned()
2487 .chain(super::boot::embedded_archive_schemas(&mount))
2488 .chain(self.builtin_schemas.iter().cloned())
2489 .collect();
2490 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2491 Ok(s) => s,
2492 Err(sources) => {
2493 let err = EngineError::SchemaNotFound {
2494 mem: mem.to_string(),
2495 pin: effective_pin.as_display(),
2496 sources,
2497 install_hint: None,
2498 }
2499 .with_schema_install_probe(self.workspace_root());
2500 requarantine(self, &err);
2501 return Err(self.unknown_mem_error(mem));
2502 }
2503 };
2504
2505 self.quarantined.remove(q_idx);
2509 self.schemas_insert(mem.to_string(), schema);
2510 self.mounts.push(crate::engine::MountedBackend {
2511 mount,
2512 backend,
2513 last_known_head,
2514 mem_config,
2515 archive_provenance,
2516 deferred: false,
2519 });
2520 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2521 &self.mounts,
2522 ));
2523 let mut sink: Vec<WarningHint> = Vec::new();
2524 match self.reload_one_mem_inner(mem, &mut sink) {
2525 Ok(result) => {
2526 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2527 self.load_warnings
2528 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2529 self.invalidate_communities();
2530 Ok(result)
2531 }
2532 Err(e) => {
2533 let mount_idx = self.mounts.len() - 1;
2534 let mounted = self.mounts.remove(mount_idx);
2535 self.schemas_remove(mem);
2536 self.mem_router = std::sync::Arc::new(
2537 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2538 );
2539 self.quarantined.push(crate::engine::QuarantinedMem {
2540 mount: mounted.mount,
2541 reason_code: e.code().to_string(),
2542 reason_message: e.to_string(),
2543 });
2544 self.invalidate_communities();
2548 self.invalidate_search_indexes();
2549 Err(e)
2550 }
2551 }
2552 }
2553
2554 fn reload_one_mem_inner(
2560 &mut self,
2561 mem: &str,
2562 warnings_sink: &mut Vec<WarningHint>,
2563 ) -> Result<crate::ops::ReloadResult, EngineError> {
2564 let mount_idx = self
2567 .mounts
2568 .iter()
2569 .position(|m| m.mount.mem == mem)
2570 .ok_or_else(|| self.unknown_mem_error(mem))?;
2571 let schema = self
2572 .schemas
2573 .get(mem)
2574 .cloned()
2575 .ok_or_else(|| self.unknown_mem_error(mem))?;
2576
2577 let pre: HashMap<EntityId, String> = self
2579 .store
2580 .all_entities()
2581 .filter(|e| !e.stub && e.mem == mem)
2582 .map(|e| (e.id.clone(), e.content_hash.clone()))
2583 .collect();
2584 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2585
2586 let backend = self.mounts[mount_idx].backend.as_ref();
2589 let (entries, read_errors) = collect_source_entries(backend)?;
2590 let unbacked = super::boot::unbacked_mount_warning(
2595 &self.mounts[mount_idx].mount,
2596 backend,
2597 Some(entries.len()),
2598 );
2599 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2600
2601 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2605 let known_suffixes: Vec<String> = mem_names
2606 .iter()
2607 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2608 .collect();
2609
2610 self.store.remove_entities_by_mem(mem);
2612 if let Some(w) = unbacked {
2613 warnings_sink.push(w);
2614 }
2615 let fallback = engine_fallback_type();
2616 push_entities_into_store(
2617 &mut self.store,
2618 load_result.entities,
2619 fallback.as_ref(),
2620 Some(crate::entity::store_builder::LoadCollector {
2621 warnings: warnings_sink,
2622 known_suffixes: &known_suffixes,
2623 mem_names: &mem_names,
2624 }),
2625 );
2626 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2633 .mounts
2634 .iter()
2635 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2636 .collect();
2637 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2645 crate::entity::store_builder::validate_loaded_relations(
2646 &mut self.store,
2647 &self.schemas,
2648 &mount_caps,
2649 warnings_sink,
2650 );
2651 crate::entity::store_builder::remap_alias_target_edge_sources(
2652 &mut self.store,
2653 &self.schemas,
2654 );
2655 if let crate::workspace::MountStorage::Folder { path } =
2665 &self.mounts[mount_idx].mount.storage
2666 {
2667 let root = path.clone();
2668 self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2669 self.load_errors
2673 .extend(load_result.errors.into_iter().map(|(p, m)| {
2674 let abs = if p.is_relative() { root.join(&p) } else { p };
2675 (abs, m)
2676 }));
2677 } else {
2678 self.load_errors.extend(load_result.errors);
2679 }
2680
2681 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2689 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2690 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2691 {
2692 self.mounts[mount_idx].mem_config = Some(cfg);
2693 }
2694
2695 let mut added: Vec<EntityId> = Vec::new();
2697 let mut changed: Vec<EntityId> = Vec::new();
2698 for entity in self.store.all_entities() {
2699 if entity.stub || entity.mem != mem {
2700 continue;
2701 }
2702 match pre.get(&entity.id) {
2703 None => added.push(entity.id.clone()),
2704 Some(prev_hash) if prev_hash != &entity.content_hash => {
2705 changed.push(entity.id.clone());
2706 }
2707 Some(_) => {}
2708 }
2709 }
2710 let post_ids: std::collections::HashSet<EntityId> = self
2711 .store
2712 .all_entities()
2713 .filter(|e| !e.stub && e.mem == mem)
2714 .map(|e| e.id.clone())
2715 .collect();
2716 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2717 added.sort_by(|a, b| a.0.cmp(&b.0));
2718 changed.sort_by(|a, b| a.0.cmp(&b.0));
2719 removed.sort_by(|a, b| a.0.cmp(&b.0));
2720
2721 self.invalidate_communities();
2722 self.invalidate_search_indexes();
2723
2724 Ok(crate::ops::ReloadResult {
2725 added,
2726 changed,
2727 removed,
2728 })
2729 }
2730
2731 pub fn reload_one_mem_report(
2759 &mut self,
2760 mem: &str,
2761 ) -> Result<crate::ops::ReloadReport, EngineError> {
2762 let tracks_head = self
2773 .mounts
2774 .iter()
2775 .find(|m| m.mount.mem == mem)
2776 .and_then(|m| m.backend.current_head().ok().flatten())
2777 .is_some();
2778 let head_before = if tracks_head {
2779 self.mounts
2780 .iter()
2781 .find(|m| m.mount.mem == mem)
2782 .and_then(|m| m.last_known_head.clone())
2783 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2784 } else {
2785 crate::ops::EMPTY_TREE_SHA.to_string()
2786 };
2787
2788 let result = self.reload_one_mem(mem)?;
2789
2790 let head_after_raw = self
2797 .mounts
2798 .iter()
2799 .find(|m| m.mount.mem == mem)
2800 .and_then(|m| m.backend.current_head().ok().flatten());
2801 if let Some(new_head) = head_after_raw.clone()
2802 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2803 {
2804 m.last_known_head = Some(new_head);
2805 }
2806 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2807
2808 let entities_loaded = self
2809 .store
2810 .all_entities()
2811 .filter(|e| !e.stub && e.mem == mem)
2812 .count();
2813
2814 let mut changed_entity_ids: Vec<EntityId> = result
2819 .added
2820 .into_iter()
2821 .chain(result.changed)
2822 .chain(result.removed)
2823 .collect();
2824 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2825
2826 Ok(crate::ops::ReloadReport {
2827 mem: mem.to_string(),
2828 head_before,
2829 head_after,
2830 entities_loaded,
2831 changed_entity_ids,
2832 })
2833 }
2834
2835 pub fn reload_each_writable_mem_reports(
2875 &mut self,
2876 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2877 self.refresh_workspace_settings_if_possible();
2878 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2879 let mut out = Vec::with_capacity(names.len());
2880 for name in names {
2881 let report = self.reload_one_mem_report(&name)?;
2882 out.push(report);
2883 }
2884 Ok(out)
2885 }
2886
2887 fn refresh_workspace_settings_if_possible(&mut self) {
2899 let Some(root) = self.workspace_root.clone() else {
2900 return;
2901 };
2902 let store = crate::workspace_store::FileWorkspaceStore::new();
2903 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2904 Ok(w) => w,
2905 Err(_) => return,
2906 };
2907 self.set_settings(workspace.settings);
2908 }
2909
2910 pub fn reload_each_writable_mem(
2926 &mut self,
2927 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2928 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2929 let mut sink = std::mem::take(&mut self.load_warnings);
2935 sink.clear();
2936 let mut out = Vec::with_capacity(names.len());
2937 let mut loop_err = None;
2938 for name in names {
2939 match self.reload_one_mem_inner(&name, &mut sink) {
2940 Ok(result) => out.push((name, result)),
2941 Err(e) => {
2942 loop_err = Some(e);
2943 break;
2944 }
2945 }
2946 }
2947 self.load_warnings = sink;
2948 if let Some(e) = loop_err {
2949 return Err(e);
2950 }
2951 Ok(out)
2952 }
2953}
2954
2955fn changed_config_fields(
2974 cached: &memstead_schema::config::MemConfig,
2975 stored_bytes: &[u8],
2976) -> Vec<String> {
2977 let (Ok(a), Ok(b)) = (
2978 serde_json::to_value(cached),
2979 serde_json::from_slice::<serde_json::Value>(stored_bytes),
2980 ) else {
2981 return Vec::new();
2982 };
2983 let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
2984 return Vec::new();
2985 };
2986 let mut keys: std::collections::BTreeSet<&String> = a.keys().collect();
2987 keys.extend(b.keys());
2988 keys.into_iter()
2989 .filter(|k| a.get(*k) != b.get(*k))
2990 .map(|k| k.to_string())
2991 .collect()
2992}
2993
2994pub fn bump_backend_schema_pin(
2995 backend: &dyn crate::backend::MemBackend,
2996 target: &memstead_schema::SchemaRef,
2997) -> Result<Option<serde_json::Value>, EngineError> {
2998 let Some(bytes) = backend
2999 .read_mem_config()
3000 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
3001 else {
3002 return Ok(None);
3003 };
3004 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
3005 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
3006 value["schema"] = serde_json::Value::String(target.as_display());
3007 let new_bytes = serde_json::to_vec_pretty(&value)
3008 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
3009 backend
3010 .write_mem_config(&new_bytes)
3011 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
3012 Ok(Some(value))
3013}
3014
3015fn merge_mount_rosters(
3029 baseline: &[crate::workspace::Mount],
3030 ours: &[crate::workspace::Mount],
3031 on_disk: Vec<crate::workspace::Mount>,
3032) -> Vec<crate::workspace::Mount> {
3033 use std::collections::{HashMap, HashSet};
3034
3035 let ours_names: HashSet<&str> = ours.iter().map(|m| m.mem.as_str()).collect();
3036 let removed_by_us: HashSet<&str> = baseline
3037 .iter()
3038 .map(|m| m.mem.as_str())
3039 .filter(|n| !ours_names.contains(n))
3040 .collect();
3041 let baseline_by_name: HashMap<&str, &crate::workspace::Mount> =
3042 baseline.iter().map(|m| (m.mem.as_str(), m)).collect();
3043
3044 let mut merged: Vec<crate::workspace::Mount> = on_disk
3045 .into_iter()
3046 .filter(|m| !removed_by_us.contains(m.mem.as_str()))
3047 .collect();
3048
3049 for mount in ours {
3050 let untouched_by_us = baseline_by_name
3051 .get(mount.mem.as_str())
3052 .is_some_and(|b| *b == mount);
3053 match merged.iter_mut().find(|d| d.mem == mount.mem) {
3054 Some(slot) => {
3055 if !untouched_by_us {
3056 *slot = mount.clone();
3057 }
3058 }
3059 None => merged.push(mount.clone()),
3060 }
3061 }
3062 merged
3063}
3064
3065#[cfg(test)]
3066mod tests {
3067
3068 use tempfile::TempDir;
3069
3070 use crate::backend::{BackendError, MemBackend};
3071 use crate::engine::test_helpers::*;
3072 use crate::engine::{Engine, EngineError};
3073 use crate::mem::MemOrigin;
3074 use crate::ops::WarningHint;
3075 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
3076
3077 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
3078 let manifest = format!(
3079 r#"name: {manifest_name}
3080version: 1.0.0
3081description: Install-gate test schema
3082when_to_use: Tests
3083types:
3084 - sample
3085relationships:
3086 mode: strict
3087 definitions:
3088 - name: PART_OF
3089 description: hier
3090 default_weight: 3.0
3091 - name: _default
3092 description: fallback
3093 default_weight: 1.0
3094community:
3095 resolution: 1.0
3096 seed: 42
3097"#
3098 );
3099 let type_yaml = format!(
3100 r#"name: sample
3101description: t
3102when_to_use: tests
3103sections:
3104 - key: body
3105 heading: {heading}
3106 required: true
3107 search_weight: 10.0
3108 catch_all: true
3109 write_rules: []
3110metadata_fields: []
3111title_weight: 100.0
3112text_fields:
3113 - body
3114hierarchy_relationship: PART_OF
3115no_self_loop_relationships: []
3116updatable_fields:
3117 - title
3118 - body
3119health_required_fields:
3120 - body
3121staleness_threshold_days: 90
3122write_rules: []
3123"#
3124 );
3125 vec![
3126 ("schema.yaml".to_string(), manifest.into_bytes()),
3127 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3128 ]
3129 }
3130
3131 #[test]
3135 fn install_gate_refuses_non_roundtrip_heading() {
3136 let ok =
3137 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
3138 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
3139
3140 let err = Engine::validate_schema_package(
3141 "gate",
3142 "1.0.0",
3143 &schema_package_files("Body Text", "gate"),
3144 )
3145 .expect_err("non-deriving heading must refuse install");
3146 match &err {
3147 EngineError::SchemaPackageInvalid { name, message, .. } => {
3148 assert_eq!(name, "gate");
3149 assert!(
3150 message.contains("'body'") && message.contains("'Body Text'"),
3151 "message names the offending tuple: {message}"
3152 );
3153 }
3154 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3155 }
3156 }
3157
3158 fn exemplar_package_files(
3165 section_key: &str,
3166 status_value: &str,
3167 rel_type: &str,
3168 ) -> Vec<(String, Vec<u8>)> {
3169 let manifest = r#"name: gate
3170version: 1.0.0
3171description: exemplar gate fixture
3172when_to_use: tests
3173types:
3174 - sample
3175 - other
3176relationships:
3177 mode: strict
3178 definitions:
3179 - name: PART_OF
3180 description: hier
3181 default_weight: 3.0
3182 - name: REFINES
3183 description: pinned
3184 default_weight: 1.0
3185 source_types: [other]
3186 - name: _default
3187 description: fallback
3188 default_weight: 1.0
3189community:
3190 resolution: 1.0
3191 seed: 42
3192"#
3193 .to_string();
3194 let type_yaml = format!(
3195 r#"name: sample
3196description: t
3197when_to_use: tests
3198sections:
3199 - key: body
3200 heading: Body
3201 required: true
3202 search_weight: 10.0
3203 catch_all: true
3204 write_rules: []
3205metadata_fields:
3206 - key: status
3207 description: workflow state
3208 field_type: string
3209 enum_values: [draft, final]
3210title_weight: 100.0
3211text_fields:
3212 - body
3213hierarchy_relationship: PART_OF
3214no_self_loop_relationships: []
3215updatable_fields:
3216 - title
3217 - body
3218health_required_fields:
3219 - body
3220staleness_threshold_days: 90
3221write_rules: []
3222exemplar:
3223 title: A Conforming Sample
3224 metadata:
3225 status: "{status_value}"
3226 sections:
3227 {section_key}: "One canonical body paragraph."
3228 relations:
3229 - to: parent-placeholder
3230 type: {rel_type}
3231"#
3232 );
3233 let other_yaml = r#"name: other
3234description: shape-pin partner
3235when_to_use: tests
3236sections:
3237 - key: body
3238 heading: Body
3239 required: true
3240 search_weight: 10.0
3241 catch_all: true
3242 write_rules: []
3243metadata_fields: []
3244title_weight: 100.0
3245text_fields:
3246 - body
3247hierarchy_relationship: PART_OF
3248no_self_loop_relationships: []
3249updatable_fields:
3250 - title
3251 - body
3252health_required_fields:
3253 - body
3254staleness_threshold_days: 90
3255write_rules: []
3256"#
3257 .to_string();
3258 vec![
3259 ("schema.yaml".to_string(), manifest.into_bytes()),
3260 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3261 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
3262 ]
3263 }
3264
3265 #[test]
3272 fn install_gate_validates_exemplars_through_the_real_create_path() {
3273 let ok = Engine::validate_schema_package(
3275 "gate",
3276 "1.0.0",
3277 &exemplar_package_files("body", "draft", "PART_OF"),
3278 );
3279 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
3280
3281 let err = Engine::validate_schema_package(
3283 "gate",
3284 "1.0.0",
3285 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
3286 )
3287 .expect_err("wrong section key must refuse");
3288 match &err {
3289 EngineError::SchemaPackageInvalid { message, .. } => {
3290 assert!(
3291 message.contains("'sample'") && message.contains("exemplar"),
3292 "names type and calls out the exemplar: {message}"
3293 );
3294 assert!(
3295 message.contains("UNKNOWN_SECTION")
3296 || message.contains("MISSING_REQUIRED_SECTION"),
3297 "carries the typed defect code: {message}"
3298 );
3299 }
3300 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3301 }
3302
3303 let err = Engine::validate_schema_package(
3305 "gate",
3306 "1.0.0",
3307 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
3308 )
3309 .expect_err("illegal enum value must refuse");
3310 assert!(
3311 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3312 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
3313 "got {err:?}"
3314 );
3315
3316 let err = Engine::validate_schema_package(
3319 "gate",
3320 "1.0.0",
3321 &exemplar_package_files("body", "draft", "REFINES"),
3322 )
3323 .expect_err("relationship shape violation must refuse");
3324 assert!(
3325 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3326 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
3327 "got {err:?}"
3328 );
3329 }
3330
3331 #[test]
3336 fn worked_example_package_exemplars_validate() {
3337 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3338 .join("../memstead-schema/examples/minimal");
3339 let schema = std::sync::Arc::new(
3340 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
3341 );
3342 assert!(
3343 schema.types.values().all(|td| td.exemplar.is_some()),
3344 "every worked-example type models the exemplar practice"
3345 );
3346 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
3347 }
3348
3349 #[test]
3356 fn builtin_exemplars_validate_through_the_install_gate() {
3357 let schemas = memstead_schema::builtins::load_builtin_schemas()
3358 .expect("built-in schemas always load");
3359 for schema in &schemas {
3361 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
3362 let (name, version) = schema.id();
3363 panic!("built-in {name}@{version}: {defect}");
3364 }
3365 }
3366 let mut newest: std::collections::HashMap<
3368 String,
3369 &std::sync::Arc<memstead_schema::Schema>,
3370 > = std::collections::HashMap::new();
3371 for schema in &schemas {
3372 let name = schema.manifest.name.clone();
3373 match newest.get(&name) {
3374 Some(cur) if cur.version >= schema.version => {}
3375 _ => {
3376 newest.insert(name, schema);
3377 }
3378 }
3379 }
3380 for (name, schema) in &newest {
3381 for (type_name, td) in &schema.types {
3382 assert!(
3383 td.exemplar.is_some(),
3384 "built-in {name}@{} type '{type_name}' has no exemplar — the \
3385 reference schemas model the practice completely",
3386 schema.version
3387 );
3388 }
3389 }
3390 }
3391
3392 #[test]
3397 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
3398 let mut files = exemplar_package_files("body", "draft", "PART_OF");
3399 let patched = String::from_utf8(files[1].1.clone())
3400 .unwrap()
3401 .replace("to: parent-placeholder", "to: other--real-entity");
3402 files[1].1 = patched.into_bytes();
3403 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3404 .expect_err("mem-prefixed exemplar target must refuse");
3405 assert!(
3406 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3407 if message.contains("bare") && message.contains("'sample'")),
3408 "got {err:?}"
3409 );
3410 }
3411
3412 #[test]
3416 fn install_gate_refuses_manifest_identity_mismatch() {
3417 let err = Engine::validate_schema_package(
3418 "gate",
3419 "1.0.0",
3420 &schema_package_files("Body", "other"),
3421 )
3422 .expect_err("identity mismatch must refuse install");
3423 assert!(
3424 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3425 if message.contains("other@1.0.0")),
3426 "got {err:?}"
3427 );
3428 }
3429
3430 #[test]
3431 fn reload_each_writable_mem_repopulates_load_warnings() {
3432 let tmp = TempDir::new().unwrap();
3436 let mem_dir = tmp.path().to_path_buf();
3437 let writer = FilesystemMemWriter::new(mem_dir.clone());
3438 let mut mount = folder_mount("specs", mem_dir.clone());
3441 mount.schema = Some("default@1.3.0".parse().unwrap());
3442 let mut engine =
3443 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3444 assert!(
3447 engine
3448 .load_warnings()
3449 .iter()
3450 .all(|w| w.code() == "MOUNT_UNBACKED"),
3451 "clean boot has no warnings beyond the empty-mount one: {:?}",
3452 engine.load_warnings()
3453 );
3454
3455 let body =
3457 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3458 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3459
3460 engine.reload_each_writable_mem().unwrap();
3461 let warnings = engine.load_warnings();
3462 assert!(
3463 warnings
3464 .iter()
3465 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3466 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3467 );
3468 }
3469
3470 #[test]
3477 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3478 let tmp = TempDir::new().unwrap();
3479 let mem_dir = tmp.path().to_path_buf();
3480 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3482 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3483 let writer = FilesystemMemWriter::new(mem_dir.clone());
3484 let mut engine = Engine::from_mounts(vec![(
3485 folder_mount("specs", mem_dir.clone()),
3486 Box::new(writer) as Box<dyn MemBackend>,
3487 )])
3488 .unwrap();
3489 assert!(
3491 !engine
3492 .load_warnings()
3493 .iter()
3494 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3495 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3496 engine.load_warnings()
3497 );
3498
3499 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";
3501 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3502
3503 engine.reload_each_writable_mem().unwrap();
3504
3505 let invalid: Vec<_> = engine
3506 .load_warnings()
3507 .iter()
3508 .filter_map(|w| match w {
3509 WarningHint::ParsedRelationInvalid {
3510 rel_type,
3511 reason,
3512 origin,
3513 ..
3514 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3515 _ => None,
3516 })
3517 .collect();
3518 assert_eq!(
3519 invalid.len(),
3520 1,
3521 "reload must surface the parse-time drift, got: {invalid:?}",
3522 );
3523 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3524 assert_eq!(invalid[0].1, "unknown_rel_type");
3525 assert_eq!(invalid[0].2, "writable");
3526 }
3527
3528 #[test]
3529 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3530 let tmp = TempDir::new().unwrap();
3537 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3538 let a_dir = tmp.path().join("a");
3539 std::fs::create_dir_all(&a_dir).unwrap();
3540 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3541 let b_dir = tmp.path().join("b");
3542 std::fs::create_dir_all(&b_dir).unwrap();
3543 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3544 let mut engine = Engine::from_mounts(vec![
3545 (
3546 folder_mount("alpha", a_dir.clone()),
3547 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3548 ),
3549 (
3550 folder_mount("beta", b_dir.clone()),
3551 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3552 ),
3553 ])
3554 .unwrap();
3555 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3556 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3557 assert!(
3558 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3559 "boot must populate one warning per mem: {pre:?}"
3560 );
3561
3562 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3564 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3565 engine.reload_one_mem("alpha").unwrap();
3566
3567 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3568 assert!(
3569 !post.contains(&"alpha".to_string()),
3570 "reload must drop the healed mem's stale warning: {post:?}"
3571 );
3572 assert!(
3573 post.contains(&"beta".to_string()),
3574 "reload of alpha must not clear beta's slice: {post:?}"
3575 );
3576 }
3577
3578 #[test]
3579 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3580 let tmp = TempDir::new().unwrap();
3585 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3586 let a_dir = tmp.path().join("a");
3587 std::fs::create_dir_all(&a_dir).unwrap();
3588 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3589 let b_dir = tmp.path().join("b");
3590 std::fs::create_dir_all(&b_dir).unwrap();
3591 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3592 let mut engine = Engine::from_mounts(vec![
3593 (
3594 folder_mount("alpha", a_dir.clone()),
3595 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3596 ),
3597 (
3598 folder_mount("beta", b_dir.clone()),
3599 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3600 ),
3601 ])
3602 .unwrap();
3603 assert!(
3604 engine
3605 .load_warnings()
3606 .iter()
3607 .any(|w| w.source_mem() == Some("alpha")),
3608 "boot must carry alpha-sourced warnings"
3609 );
3610
3611 engine.unregister_writable_mem("alpha").unwrap();
3612
3613 let post = engine.load_warnings();
3614 assert!(
3615 !post.iter().any(|w| w.source_mem() == Some("alpha")),
3616 "delete must purge the removed mem's warnings: {post:?}"
3617 );
3618 assert!(
3619 post.iter().any(|w| w.source_mem() == Some("beta")),
3620 "delete of alpha must keep beta's warnings: {post:?}"
3621 );
3622 }
3623
3624 #[test]
3625 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3626 let tmp = TempDir::new().unwrap();
3632 let a_dir = tmp.path().join("a");
3633 std::fs::create_dir_all(&a_dir).unwrap();
3634 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";
3635 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3636 let b_dir = tmp.path().join("b");
3637 std::fs::create_dir_all(&b_dir).unwrap();
3638 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3639 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3640 let mut engine = Engine::from_mounts(vec![
3641 (
3642 folder_mount("alpha", a_dir.clone()),
3643 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3644 ),
3645 (
3646 folder_mount("beta", b_dir.clone()),
3647 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3648 ),
3649 ])
3650 .unwrap();
3651 let alpha_sourced = |engine: &Engine| {
3652 engine
3653 .load_warnings()
3654 .iter()
3655 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3656 };
3657 assert!(
3658 alpha_sourced(&engine),
3659 "boot must flag alpha's invalid row: {:?}",
3660 engine.load_warnings()
3661 );
3662
3663 engine.unregister_writable_mem("beta").unwrap();
3664
3665 assert!(
3666 alpha_sourced(&engine),
3667 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3668 engine.load_warnings()
3669 );
3670 }
3671
3672 #[test]
3680 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3681 let tmp = TempDir::new().unwrap();
3682 let mem_dir = tmp.path().to_path_buf();
3683 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3684 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3685 let writer = FilesystemMemWriter::new(mem_dir.clone());
3686 let mut engine = Engine::from_mounts(vec![(
3687 folder_mount("specs", mem_dir.clone()),
3688 Box::new(writer) as Box<dyn MemBackend>,
3689 )])
3690 .unwrap();
3691 assert!(
3692 !engine.load_warnings().is_empty(),
3693 "boot must populate load_warnings"
3694 );
3695
3696 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3699 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3700 engine.reload_each_writable_mem_reports().unwrap();
3701 assert!(
3702 engine.load_warnings().is_empty(),
3703 "reports sweep must drop healed warnings: {:?}",
3704 engine.load_warnings()
3705 );
3706
3707 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3709 engine.reload_each_writable_mem_reports().unwrap();
3710 assert!(
3711 engine
3712 .load_warnings()
3713 .iter()
3714 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3715 "reports sweep must surface fresh drift: {:?}",
3716 engine.load_warnings()
3717 );
3718 }
3719
3720 #[test]
3728 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3729 let tmp = TempDir::new().unwrap();
3730 let a_dir = tmp.path().join("a");
3731 let b_dir = tmp.path().join("b");
3732 std::fs::create_dir_all(&a_dir).unwrap();
3733 std::fs::create_dir_all(&b_dir).unwrap();
3734 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3735 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3736 let mut engine = Engine::from_mounts(vec![
3737 (
3738 folder_mount("specs", a_dir),
3739 Box::new(a_writer) as Box<dyn MemBackend>,
3740 ),
3741 (
3742 folder_mount("memos", b_dir),
3743 Box::new(b_writer) as Box<dyn MemBackend>,
3744 ),
3745 ])
3746 .unwrap();
3747
3748 let mut settings = crate::workspace::WorkspaceSettings::default();
3750 settings.cross_mem_links.insert(
3751 "specs".to_string(),
3752 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3753 );
3754 engine.set_settings(settings);
3755
3756 let (actor, client) = cli_actor();
3757 let source = engine
3758 .create_entity(
3759 empty_create_args("specs", "Source"),
3760 actor,
3761 Some(&client),
3762 None,
3763 )
3764 .unwrap();
3765 let target = engine
3766 .create_entity(
3767 empty_create_args("memos", "Target"),
3768 actor,
3769 Some(&client),
3770 None,
3771 )
3772 .unwrap();
3773 engine
3774 .relate_entity(
3775 crate::engine::RelateEntityArgs {
3776 source: source.id.clone(),
3777 expected_hash: Some(source.content_hash.clone()),
3778 rel_type: "USES".to_string(),
3779 target: target.id.clone(),
3780 remove: false,
3781 description: None,
3782 dry_run: false,
3783 },
3784 actor,
3785 Some(&client),
3786 None,
3787 )
3788 .unwrap();
3789
3790 let has_edge = |e: &Engine| {
3792 let out = e
3793 .store()
3794 .outgoing(&source.id)
3795 .iter()
3796 .any(|edge| edge.target == target.id);
3797 let inc = e
3798 .store()
3799 .incoming(&target.id)
3800 .iter()
3801 .any(|edge| edge.from == source.id);
3802 (out, inc)
3803 };
3804
3805 assert_eq!(
3806 has_edge(&engine),
3807 (true, true),
3808 "edge must be indexed in both directions after relate",
3809 );
3810
3811 engine.reload_one_mem("memos").unwrap();
3813 assert_eq!(
3814 has_edge(&engine),
3815 (true, true),
3816 "cross-mem edge into B must survive a per-mem reload of B",
3817 );
3818
3819 engine.reload_each_writable_mem().unwrap();
3822 assert_eq!(
3823 has_edge(&engine),
3824 (true, true),
3825 "per-mem and workspace reload converge on the same edge",
3826 );
3827
3828 assert!(
3831 engine
3832 .store()
3833 .get(&source.id)
3834 .unwrap()
3835 .relationships
3836 .iter()
3837 .any(|r| r.target == target.id),
3838 "source record must retain the relationship throughout",
3839 );
3840 }
3841
3842 #[test]
3848 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3849 let tmp = TempDir::new().unwrap();
3850 let a_dir = tmp.path().join("a");
3851 let b_dir = tmp.path().join("b");
3852 std::fs::create_dir_all(&a_dir).unwrap();
3853 std::fs::create_dir_all(&b_dir).unwrap();
3854 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3855 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3856 let mut engine = Engine::from_mounts(vec![
3857 (
3858 folder_mount("specs", a_dir),
3859 Box::new(a_writer) as Box<dyn MemBackend>,
3860 ),
3861 (
3862 folder_mount("memos", b_dir),
3863 Box::new(b_writer) as Box<dyn MemBackend>,
3864 ),
3865 ])
3866 .unwrap();
3867 let mut settings = crate::workspace::WorkspaceSettings::default();
3868 settings.cross_mem_links.insert(
3869 "specs".to_string(),
3870 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3871 );
3872 engine.set_settings(settings);
3873
3874 let (actor, client) = cli_actor();
3875 let source = engine
3876 .create_entity(
3877 empty_create_args("specs", "Source"),
3878 actor,
3879 Some(&client),
3880 None,
3881 )
3882 .unwrap();
3883 let target = engine
3884 .create_entity(
3885 empty_create_args("memos", "Target"),
3886 actor,
3887 Some(&client),
3888 None,
3889 )
3890 .unwrap();
3891 engine
3892 .relate_entity(
3893 crate::engine::RelateEntityArgs {
3894 source: source.id.clone(),
3895 expected_hash: Some(source.content_hash.clone()),
3896 rel_type: "USES".to_string(),
3897 target: target.id.clone(),
3898 remove: false,
3899 description: None,
3900 dry_run: false,
3901 },
3902 actor,
3903 Some(&client),
3904 None,
3905 )
3906 .unwrap();
3907
3908 engine.reload_one_mem("specs").unwrap();
3909
3910 let out = engine
3911 .store()
3912 .outgoing(&source.id)
3913 .iter()
3914 .any(|edge| edge.target == target.id);
3915 let inc = engine
3916 .store()
3917 .incoming(&target.id)
3918 .iter()
3919 .any(|edge| edge.from == source.id);
3920 assert!(
3921 out && inc,
3922 "outgoing cross-mem edge must survive a source-mem reload"
3923 );
3924 }
3925
3926 #[test]
3927 fn workspace_root_setter_round_trips() {
3928 let tmp = TempDir::new().unwrap();
3929 let mem_dir = tmp.path().to_path_buf();
3930 let writer = FilesystemMemWriter::new(mem_dir.clone());
3931 let mut engine = Engine::from_mounts(vec![(
3932 folder_mount("specs", mem_dir),
3933 Box::new(writer) as Box<dyn MemBackend>,
3934 )])
3935 .unwrap();
3936 let root = tmp.path().to_path_buf();
3937 engine.set_workspace_root(root.clone());
3938 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3939 }
3940
3941 #[test]
3942 fn export_mem_folder_backend_produces_archive() {
3943 let tmp = TempDir::new().unwrap();
3947 let mem_dir = tmp.path().join("specs");
3948 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3949 let config_body = r#"{
3950 "format": 1,
3951 "schema": "default@1.0.0",
3952 "version": "1.0.0"
3953 }"#;
3954 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3955
3956 let writer = FilesystemMemWriter::new(mem_dir.clone());
3957 let engine = Engine::from_mounts(vec![(
3958 folder_mount("specs", mem_dir.clone()),
3959 Box::new(writer) as Box<dyn MemBackend>,
3960 )])
3961 .unwrap();
3962
3963 let archive_path = tmp.path().join("specs.mem");
3964 let result = engine.export_mem("specs", &archive_path).unwrap();
3965 assert!(archive_path.exists(), "archive must exist on disk");
3966 assert!(result.size_bytes > 0);
3967 assert_eq!(result.entity_count, 0);
3970 }
3971
3972 #[test]
3973 fn export_mem_unknown_mem_returns_unknown_mem() {
3974 let tmp = TempDir::new().unwrap();
3975 let mem_dir = tmp.path().to_path_buf();
3976 let writer = FilesystemMemWriter::new(mem_dir.clone());
3977 let engine = Engine::from_mounts(vec![(
3978 folder_mount("specs", mem_dir),
3979 Box::new(writer) as Box<dyn MemBackend>,
3980 )])
3981 .unwrap();
3982 let output = tmp.path().join("out.mem");
3983 let err = engine.export_mem("missing", &output).unwrap_err();
3984 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3985 }
3986
3987 #[test]
3988 fn export_mem_missing_config_returns_invalid_input() {
3989 let tmp = TempDir::new().unwrap();
3993 let mem_dir = tmp.path().to_path_buf();
3994 let writer = FilesystemMemWriter::new(mem_dir.clone());
3995 let engine = Engine::from_mounts(vec![(
3996 folder_mount("specs", mem_dir),
3997 Box::new(writer) as Box<dyn MemBackend>,
3998 )])
3999 .unwrap();
4000 let output = tmp.path().join("out.mem");
4001 let err = engine.export_mem("specs", &output).unwrap_err();
4002 assert!(matches!(err, EngineError::InvalidInput(_)));
4003 }
4004
4005 #[test]
4006 fn export_mem_archive_backend_returns_sealed() {
4007 let tmp = TempDir::new().unwrap();
4010 let archive_path = build_archive(
4011 tmp.path(),
4012 "ext",
4013 &[(
4014 ".memstead/config.json",
4015 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
4016 )],
4017 );
4018 let engine = Engine::from_mounts(vec![(
4019 archive_mount("ext", archive_path.clone()),
4020 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4021 )])
4022 .unwrap();
4023 let output = tmp.path().join("out.mem");
4024 let err = engine.export_mem("ext", &output).unwrap_err();
4025 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
4026 }
4027
4028 #[test]
4029 fn export_markdown_writes_unchanged_files_zero_writes() {
4030 let tmp = TempDir::new().unwrap();
4035 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
4036 let result = engine.export_markdown(None, None).unwrap();
4037 assert_eq!(
4038 result.written, 0,
4039 "freshly-created entity's file already matches generated markdown"
4040 );
4041 assert_eq!(
4042 result.unchanged, 1,
4043 "the one seeded entity counts as unchanged"
4044 );
4045 assert!(
4046 result.skipped_mounts.is_empty(),
4047 "folder-only workspace has no skipped mounts"
4048 );
4049 }
4050
4051 #[test]
4052 fn export_markdown_skips_non_folder_mounts() {
4053 let tmp = TempDir::new().unwrap();
4057 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4058 let engine = Engine::from_mounts(vec![(
4059 archive_mount("ext", archive_path.clone()),
4060 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4061 )])
4062 .unwrap();
4063 let result = engine.export_markdown(None, None).unwrap();
4064 assert_eq!(result.written, 0);
4065 assert_eq!(result.unchanged, 0);
4066 assert_eq!(
4067 result.skipped_mounts.len(),
4068 1,
4069 "archive mount is in the skipped list"
4070 );
4071 let entry = &result.skipped_mounts[0];
4072 assert_eq!(entry.mem, "ext");
4073 assert_eq!(entry.active_backend, "archive");
4074 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
4075 }
4076
4077 #[test]
4078 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
4079 let tmp = TempDir::new().unwrap();
4083 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4084 let engine = Engine::from_mounts(vec![(
4085 archive_mount("ext", archive_path.clone()),
4086 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4087 )])
4088 .unwrap();
4089 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
4090 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
4091 let details = err.details();
4092 assert_eq!(details["mem"], "ext");
4093 assert_eq!(details["active_backend"], "archive");
4094 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
4095 }
4096
4097 #[test]
4098 fn register_writable_mem_adds_mount_and_router_entry() {
4099 let tmp = TempDir::new().unwrap();
4102 let mem_a = tmp.path().join("a");
4103 std::fs::create_dir_all(&mem_a).unwrap();
4104 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4105
4106 let mut engine = Engine::from_mounts(vec![(
4107 folder_mount("alpha", mem_a),
4108 Box::new(writer_a) as Box<dyn MemBackend>,
4109 )])
4110 .unwrap();
4111 assert!(engine.mem_router().is_writable("alpha"));
4112
4113 let mem_b = tmp.path().join("b");
4114 std::fs::create_dir_all(&mem_b).unwrap();
4115 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4116
4117 engine
4118 .register_writable_mem(
4119 folder_mount("beta", mem_b.clone()),
4120 Box::new(writer_b) as Box<dyn MemBackend>,
4121 MemOrigin::ExplicitToml,
4122 )
4123 .unwrap();
4124
4125 assert!(engine.mem_router().is_writable("alpha"));
4127 assert!(engine.mem_router().is_writable("beta"));
4128 assert!(engine.mem_router().is_visible("beta"));
4129
4130 assert!(engine.mount("beta").is_some());
4132 assert!(engine.schemas().contains_key("beta"));
4133
4134 assert_eq!(
4136 engine.mem_router().dir_for_mem("beta"),
4137 Some(mem_b.as_path()),
4138 );
4139 }
4140
4141 #[test]
4147 fn register_writable_mem_resolves_schema_from_mem_config() {
4148 let tmp = TempDir::new().unwrap();
4149 let mem_a = tmp.path().join("a");
4150 std::fs::create_dir_all(&mem_a).unwrap();
4151 let mut engine = Engine::from_mounts(vec![(
4152 folder_mount("alpha", mem_a.clone()),
4153 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
4154 )])
4155 .unwrap();
4156
4157 let mem_b = tmp.path().join("b");
4158 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
4159 std::fs::write(
4160 mem_b.join(".memstead").join("config.json"),
4161 r#"{"schema":"software@0.1.0"}"#,
4162 )
4163 .unwrap();
4164 let mount_b = crate::workspace::Mount {
4165 mem: "beta".to_string(),
4166 schema: Some(memstead_schema::SchemaRef::new(
4167 "totally-not-a-schema",
4168 semver::Version::new(9, 9, 9),
4169 )),
4170 storage: crate::workspace::MountStorage::Folder {
4171 path: mem_b.clone(),
4172 },
4173 capability: crate::workspace::MountCapability::Write,
4174 lifecycle: crate::workspace::MountLifecycle::Eager,
4175 cross_linkable: true,
4176 migration_target: None,
4177 };
4178 engine
4179 .register_writable_mem(
4180 mount_b,
4181 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
4182 MemOrigin::ExplicitToml,
4183 )
4184 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
4185
4186 assert!(engine.schemas().contains_key("beta"));
4187 let surfaced = engine.load_warnings().iter().any(|w| {
4188 matches!(
4189 w,
4190 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
4191 if mem == "beta"
4192 && config_pin == "software@0.1.0"
4193 && mount_pin == "totally-not-a-schema@9.9.9"
4194 )
4195 });
4196 assert!(
4197 surfaced,
4198 "SchemaPinMismatch must surface for beta: {:?}",
4199 engine.load_warnings(),
4200 );
4201 }
4202
4203 #[test]
4204 fn register_writable_mem_rejects_existing_name() {
4205 let tmp = TempDir::new().unwrap();
4208 let mem_a = tmp.path().join("a");
4209 std::fs::create_dir_all(&mem_a).unwrap();
4210 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4211
4212 let mut engine = Engine::from_mounts(vec![(
4213 folder_mount("alpha", mem_a),
4214 Box::new(writer_a) as Box<dyn MemBackend>,
4215 )])
4216 .unwrap();
4217 let mount_count_pre = engine.mounts().len();
4218
4219 let mem_collide = tmp.path().join("alpha-2");
4220 std::fs::create_dir_all(&mem_collide).unwrap();
4221 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
4222
4223 let err = engine
4224 .register_writable_mem(
4225 folder_mount("alpha", mem_collide),
4226 Box::new(writer_collide) as Box<dyn MemBackend>,
4227 MemOrigin::ExplicitToml,
4228 )
4229 .unwrap_err();
4230 match err {
4231 EngineError::MemNameCollision {
4232 name,
4233 source_origin,
4234 } => {
4235 assert_eq!(name, "alpha");
4236 assert!(
4241 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
4242 );
4243 }
4244 other => panic!("expected MemNameCollision, got {other:?}"),
4245 }
4246
4247 assert_eq!(engine.mounts().len(), mount_count_pre);
4249 }
4250
4251 #[test]
4252 fn register_writable_mem_loads_entities_into_store() {
4253 let tmp = TempDir::new().unwrap();
4256 let mem_a = tmp.path().join("a");
4257 std::fs::create_dir_all(&mem_a).unwrap();
4258 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4259
4260 let mut engine = Engine::from_mounts(vec![(
4261 folder_mount("alpha", mem_a),
4262 Box::new(writer_a) as Box<dyn MemBackend>,
4263 )])
4264 .unwrap();
4265 let pre_count = engine.store().all_entities().count();
4266
4267 let mem_b = tmp.path().join("b");
4269 std::fs::create_dir_all(&mem_b).unwrap();
4270 std::fs::write(
4271 mem_b.join("b1.md"),
4272 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4273 )
4274 .unwrap();
4275 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4276
4277 engine
4278 .register_writable_mem(
4279 folder_mount("beta", mem_b),
4280 Box::new(writer_b) as Box<dyn MemBackend>,
4281 MemOrigin::ExplicitToml,
4282 )
4283 .unwrap();
4284
4285 let post_count = engine.store().all_entities().count();
4286 assert!(post_count > pre_count, "register must load entities");
4287 let beta_count = engine
4288 .store()
4289 .all_entities()
4290 .filter(|e| e.mem == "beta")
4291 .count();
4292 assert_eq!(beta_count, 1);
4293 }
4294
4295 #[test]
4296 fn register_then_unregister_round_trips() {
4297 let tmp = TempDir::new().unwrap();
4301 let mem_a = tmp.path().join("a");
4302 std::fs::create_dir_all(&mem_a).unwrap();
4303 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4304
4305 let mut engine = Engine::from_mounts(vec![(
4306 folder_mount("alpha", mem_a),
4307 Box::new(writer_a) as Box<dyn MemBackend>,
4308 )])
4309 .unwrap();
4310 let pre_mounts = engine.mounts().len();
4311
4312 let mem_b = tmp.path().join("b");
4313 std::fs::create_dir_all(&mem_b).unwrap();
4314 let writer_b = FilesystemMemWriter::new(mem_b);
4315
4316 engine
4317 .register_writable_mem(
4318 folder_mount("beta", tmp.path().join("b")),
4319 Box::new(writer_b) as Box<dyn MemBackend>,
4320 MemOrigin::ExplicitToml,
4321 )
4322 .unwrap();
4323 assert_eq!(engine.mounts().len(), pre_mounts + 1);
4324
4325 let removed = engine.unregister_writable_mem("beta").unwrap();
4326 assert!(removed.is_some());
4327 assert_eq!(engine.mounts().len(), pre_mounts);
4328 assert!(!engine.mem_router().is_writable("beta"));
4329 }
4330
4331 #[test]
4332 fn unregister_writable_mem_returns_false_for_unknown_name() {
4333 let tmp = TempDir::new().unwrap();
4337 let mem_dir = tmp.path().to_path_buf();
4338 let writer = FilesystemMemWriter::new(mem_dir.clone());
4339 let mut engine = Engine::from_mounts(vec![(
4340 folder_mount("specs", mem_dir),
4341 Box::new(writer) as Box<dyn MemBackend>,
4342 )])
4343 .unwrap();
4344 let removed = engine.unregister_writable_mem("missing").unwrap();
4345 assert!(removed.is_none(), "unknown mem returns Ok(None)");
4346 assert!(engine.mem_router().is_writable("specs"));
4348 }
4349
4350 #[test]
4351 fn unregister_writable_mem_drops_mount_and_router_entry() {
4352 let tmp = TempDir::new().unwrap();
4357 let mem_a = tmp.path().join("a");
4358 std::fs::create_dir_all(&mem_a).unwrap();
4359 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4360 let mem_b = tmp.path().join("b");
4361 std::fs::create_dir_all(&mem_b).unwrap();
4362 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4363
4364 let mut engine = Engine::from_mounts(vec![
4365 (
4366 folder_mount("alpha", mem_a),
4367 Box::new(writer_a) as Box<dyn MemBackend>,
4368 ),
4369 (
4370 folder_mount("beta", mem_b),
4371 Box::new(writer_b) as Box<dyn MemBackend>,
4372 ),
4373 ])
4374 .unwrap();
4375
4376 let removed = engine.unregister_writable_mem("alpha").unwrap();
4377 assert!(removed.is_some());
4378
4379 assert!(!engine.mem_router().is_writable("alpha"));
4381 assert!(!engine.mem_router().is_visible("alpha"));
4382 assert!(engine.mount("alpha").is_none());
4383
4384 assert!(engine.mem_router().is_writable("beta"));
4386 assert!(engine.mount("beta").is_some());
4387 }
4388
4389 #[test]
4390 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
4391 let tmp = TempDir::new().unwrap();
4395 let mem_a = tmp.path().join("a");
4396 std::fs::create_dir_all(&mem_a).unwrap();
4397 std::fs::write(
4398 mem_a.join("a1.md"),
4399 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
4400 )
4401 .unwrap();
4402 let writer_a = FilesystemMemWriter::new(mem_a.clone());
4403
4404 let mem_b = tmp.path().join("b");
4405 std::fs::create_dir_all(&mem_b).unwrap();
4406 std::fs::write(
4407 mem_b.join("b1.md"),
4408 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4409 )
4410 .unwrap();
4411 let writer_b = FilesystemMemWriter::new(mem_b.clone());
4412
4413 let mut engine = Engine::from_mounts(vec![
4414 (
4415 folder_mount("alpha", mem_a),
4416 Box::new(writer_a) as Box<dyn MemBackend>,
4417 ),
4418 (
4419 folder_mount("beta", mem_b),
4420 Box::new(writer_b) as Box<dyn MemBackend>,
4421 ),
4422 ])
4423 .unwrap();
4424
4425 let pre_total = engine.store().all_entities().count();
4426 assert!(pre_total >= 2, "both mems must load entities");
4427
4428 engine.unregister_writable_mem("alpha").unwrap();
4429
4430 let alpha_remaining = engine
4432 .store()
4433 .all_entities()
4434 .filter(|e| e.mem == "alpha")
4435 .count();
4436 assert_eq!(alpha_remaining, 0);
4437
4438 let beta_remaining = engine
4440 .store()
4441 .all_entities()
4442 .filter(|e| e.mem == "beta")
4443 .count();
4444 assert!(beta_remaining > 0, "beta entities must survive");
4445 }
4446 #[test]
4447 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4448 let tmp = TempDir::new().unwrap();
4449 let mut engine = build_demo_engine(&tmp);
4450 let result = engine
4451 .reload_one_mem("specs")
4452 .expect("reload on stable disk must succeed");
4453 assert!(result.added.is_empty(), "added: {:?}", result.added);
4454 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4455 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4456 }
4457
4458 #[test]
4459 fn reload_one_mem_picks_up_external_addition() {
4460 let tmp = TempDir::new().unwrap();
4461 let mut engine = build_demo_engine(&tmp);
4462 std::fs::write(
4465 tmp.path().join("external.md"),
4466 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4467 )
4468 .unwrap();
4469 let result = engine.reload_one_mem("specs").unwrap();
4470 assert_eq!(
4471 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4472 vec!["specs--external"]
4473 );
4474 assert!(result.changed.is_empty());
4475 assert!(result.removed.is_empty());
4476 assert!(
4478 engine
4479 .get_entity(&crate::EntityId::new("specs", "external"))
4480 .is_some()
4481 );
4482 }
4483
4484 #[test]
4485 fn reload_one_mem_picks_up_external_removal() {
4486 let tmp = TempDir::new().unwrap();
4487 let mut engine = build_demo_engine(&tmp);
4488 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4491 let result = engine.reload_one_mem("specs").unwrap();
4492 assert!(result.added.is_empty());
4493 assert!(result.changed.is_empty());
4494 assert_eq!(
4495 result
4496 .removed
4497 .iter()
4498 .map(|i| i.as_ref())
4499 .collect::<Vec<_>>(),
4500 vec!["specs--lonely-three"]
4501 );
4502 }
4503
4504 #[test]
4505 fn reload_one_mem_picks_up_external_change() {
4506 let tmp = TempDir::new().unwrap();
4507 let mut engine = build_demo_engine(&tmp);
4508 std::fs::write(
4511 tmp.path().join("source-one.md"),
4512 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4513 )
4514 .unwrap();
4515 let result = engine.reload_one_mem("specs").unwrap();
4516 assert!(result.added.is_empty());
4517 assert_eq!(
4518 result
4519 .changed
4520 .iter()
4521 .map(|i| i.as_ref())
4522 .collect::<Vec<_>>(),
4523 vec!["specs--source-one"]
4524 );
4525 assert!(result.removed.is_empty());
4526 }
4527
4528 #[test]
4529 fn reload_one_mem_rejects_unknown_mem() {
4530 let tmp = TempDir::new().unwrap();
4531 let mut engine = build_demo_engine(&tmp);
4532 let err = engine.reload_one_mem("nope").unwrap_err();
4533 match err {
4534 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4535 other => panic!("expected UnknownMem, got {other:?}"),
4536 }
4537 }
4538
4539 #[test]
4540 fn reload_each_writable_mem_returns_one_entry_per_mount() {
4541 let tmp = TempDir::new().unwrap();
4542 let mut engine = build_demo_engine(&tmp);
4543 let reports = engine
4544 .reload_each_writable_mem()
4545 .expect("batch reload on stable disk must succeed");
4546 assert_eq!(reports.len(), 1);
4547 assert_eq!(reports[0].0, "specs");
4548 assert!(reports[0].1.added.is_empty());
4549 assert!(reports[0].1.changed.is_empty());
4550 assert!(reports[0].1.removed.is_empty());
4551 }
4552
4553 #[test]
4556 fn settings_default_to_empty_on_fresh_engine() {
4557 let tmp = TempDir::new().unwrap();
4558 let engine = build_demo_engine(&tmp);
4559 let s = engine.settings();
4560 assert!(s.mem_create_rules.is_empty());
4561 assert!(s.mem_delete_rules.is_empty());
4562 assert!(s.cross_mem_links.is_empty());
4563 }
4564
4565 #[test]
4566 fn set_settings_replaces_workspace_policy() {
4567 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4568 let tmp = TempDir::new().unwrap();
4569 let mut engine = build_demo_engine(&tmp);
4570 let mut settings = WorkspaceSettings::default();
4571 settings.mem_create_rules.push(CreateRuleSetting {
4572 pattern: "exec-*".to_string(),
4573 schemas: vec!["default@1.0.0".to_string()],
4574 default_cross_links: None,
4575 });
4576 settings.mem_delete_rules.push(DeleteRuleSetting {
4577 pattern: "exec-*".to_string(),
4578 });
4579 engine.set_settings(settings);
4580 assert_eq!(engine.settings().mem_create_rules.len(), 1);
4581 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4582 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4583 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4584 }
4585
4586 #[test]
4589 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4590 let tmp = TempDir::new().unwrap();
4591 let mut engine = build_demo_engine(&tmp);
4592 std::fs::write(
4594 tmp.path().join("new-via-disk.md"),
4595 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4596 )
4597 .unwrap();
4598 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4599 std::fs::write(
4600 tmp.path().join("source-one.md"),
4601 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4602 )
4603 .unwrap();
4604
4605 let reports = engine.reload_each_writable_mem().unwrap();
4606 assert_eq!(reports.len(), 1);
4607 let (mem, result) = &reports[0];
4608 assert_eq!(mem, "specs");
4609 assert_eq!(
4610 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4611 vec!["specs--new-via-disk"]
4612 );
4613 assert_eq!(
4614 result
4615 .removed
4616 .iter()
4617 .map(|i| i.as_ref())
4618 .collect::<Vec<_>>(),
4619 vec!["specs--lonely-three"]
4620 );
4621 assert_eq!(
4622 result
4623 .changed
4624 .iter()
4625 .map(|i| i.as_ref())
4626 .collect::<Vec<_>>(),
4627 vec!["specs--source-one"]
4628 );
4629 }
4630
4631 #[test]
4634 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4635 let tmp = TempDir::new().unwrap();
4643 let mut engine = build_demo_engine(&tmp);
4644 let report = engine.reload_one_mem_report("specs").unwrap();
4645 assert_eq!(report.mem, "specs");
4646 assert_eq!(
4647 report.head_before, report.head_after,
4648 "unchanged disk → stable cursor"
4649 );
4650 assert!(
4651 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4652 "folder heads carry the changelog-ts cursor, got {}",
4653 report.head_after
4654 );
4655 assert_eq!(report.entities_loaded, 3);
4658 assert!(report.changed_entity_ids.is_empty());
4660 }
4661
4662 #[test]
4663 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4664 let tmp = TempDir::new().unwrap();
4669 let mut engine = build_demo_engine(&tmp);
4670 std::fs::write(
4671 tmp.path().join("new-via-disk.md"),
4672 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4673 )
4674 .unwrap();
4675 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4676 std::fs::write(
4677 tmp.path().join("source-one.md"),
4678 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4679 )
4680 .unwrap();
4681
4682 let report = engine.reload_one_mem_report("specs").unwrap();
4683 assert_eq!(report.mem, "specs");
4684 let ids: Vec<&str> = report
4685 .changed_entity_ids
4686 .iter()
4687 .map(|id| id.as_ref())
4688 .collect();
4689 assert_eq!(
4691 ids,
4692 vec![
4693 "specs--lonely-three",
4694 "specs--new-via-disk",
4695 "specs--source-one",
4696 ]
4697 );
4698 }
4699
4700 #[test]
4701 fn reload_one_mem_report_rejects_unknown_mem() {
4702 let tmp = TempDir::new().unwrap();
4703 let mut engine = build_demo_engine(&tmp);
4704 let err = engine.reload_one_mem_report("missing").unwrap_err();
4705 assert!(matches!(err, EngineError::UnknownMem(_)));
4706 }
4707
4708 #[test]
4709 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4710 let tmp = TempDir::new().unwrap();
4711 let mut engine = build_demo_engine(&tmp);
4712 let reports = engine.reload_each_writable_mem_reports().unwrap();
4713 assert_eq!(reports.len(), 1);
4714 assert_eq!(reports[0].mem, "specs");
4715 assert_eq!(reports[0].entities_loaded, 3);
4716 }
4717
4718 #[test]
4725 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4726 let tmp = TempDir::new().unwrap();
4727
4728 let memstead_dir = tmp.path().join(".memstead");
4731 std::fs::create_dir_all(&memstead_dir).unwrap();
4732 let workspace_toml = memstead_dir.join("workspace.toml");
4733 std::fs::write(
4734 &workspace_toml,
4735 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4736 )
4737 .unwrap();
4738 let mounts_json = memstead_dir.join("state").join("mounts.json");
4739 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4740 let mem_dir = tmp.path().join("specs");
4741 std::fs::create_dir_all(&mem_dir).unwrap();
4742 let mounts_body = format!(
4743 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4744 mem_dir.display(),
4745 );
4746 std::fs::write(&mounts_json, mounts_body).unwrap();
4747
4748 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4749 assert!(
4750 engine.settings().mem_create_rules.is_empty(),
4751 "boot-time settings carry no create rules"
4752 );
4753
4754 std::fs::write(
4756 &workspace_toml,
4757 "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",
4758 )
4759 .unwrap();
4760
4761 engine.reload_each_writable_mem_reports().unwrap();
4762
4763 let rules = &engine.settings().mem_create_rules;
4764 assert_eq!(
4765 rules.len(),
4766 1,
4767 "workspace-wide reload must refresh the policy"
4768 );
4769 assert_eq!(rules[0].pattern, "exec-*");
4770 }
4771
4772 const MIG_TYPE_TAIL: &str = r#"sections:
4777 - key: body
4778 heading: Body
4779 required: true
4780 search_weight: 10.0
4781 catch_all: true
4782 write_rules: []
4783title_weight: 100.0
4784text_fields:
4785 - body
4786hierarchy_relationship: _default
4787no_self_loop_relationships: []
4788updatable_fields: []
4789health_required_fields: []
4790staleness_threshold_days: 90
4791write_rules: []
4792"#;
4793
4794 fn mig_manifest(name: &str, version: &str) -> String {
4799 format!(
4800 r#"name: {name}
4801version: {version}
4802description: migration test schema
4803when_to_use: tests
4804types:
4805 - doc
4806relationships:
4807 mode: strict
4808 definitions:
4809 - name: USES
4810 description: link
4811 default_weight: 1.0
4812 - name: _default
4813 description: fallback
4814 default_weight: 1.0
4815community:
4816 resolution: 1.0
4817 seed: 42
4818"#
4819 )
4820 }
4821
4822 fn mig_type_yaml(with_status: bool) -> String {
4823 let metadata = if with_status {
4824 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4825 } else {
4826 "metadata_fields: []\n"
4827 };
4828 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4829 }
4830
4831 fn write_mig_schema(
4832 root: &std::path::Path,
4833 dir: &str,
4834 name: &str,
4835 version: &str,
4836 with_status: bool,
4837 ) {
4838 let d = root.join(dir);
4839 std::fs::create_dir_all(d.join("types")).unwrap();
4840 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4841 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4842 }
4843
4844 #[test]
4861 fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4862 for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4863 run_mutation_sequence(seed);
4864 }
4865 }
4866
4867 struct Xorshift(u64);
4868 impl Xorshift {
4869 fn next(&mut self) -> u64 {
4870 let mut x = self.0;
4871 x ^= x << 13;
4872 x ^= x >> 7;
4873 x ^= x << 17;
4874 self.0 = x;
4875 x
4876 }
4877 fn pick(&mut self, n: usize) -> usize {
4878 (self.next() % n as u64) as usize
4879 }
4880 }
4881
4882 fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4883 let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4886 let live = engine.search_indexes();
4887 let mut live_mems: Vec<&String> = live.keys().collect();
4888 let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4889 live_mems.sort();
4890 fresh_mems.sort();
4891 assert_eq!(
4892 live_mems, fresh_mems,
4893 "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4894 );
4895 for (mem, idx) in live {
4896 let mut got = idx.stored_ids().unwrap();
4897 let mut want = fresh[mem].stored_ids().unwrap();
4898 got.sort();
4899 want.sort();
4900 assert_eq!(
4901 got, want,
4902 "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4903 );
4904 }
4905 let schema = engine
4908 .schemas
4909 .iter()
4910 .min_by(|a, b| a.0.cmp(b.0))
4911 .map(|(_, s)| s.clone())
4912 .expect("schema present");
4913 let weights_schema = schema.clone();
4914 let fresh_partition = crate::graph::community::detect_communities(
4915 engine.store(),
4916 schema.manifest.community.resolution,
4917 schema.manifest.community.seed,
4918 move |rel_type| {
4919 weights_schema
4920 .manifest
4921 .relationships
4922 .definitions
4923 .iter()
4924 .find(|d| d.name == rel_type)
4925 .map(|d| d.default_weight as f64)
4926 .unwrap_or(1.0)
4927 },
4928 );
4929 assert_eq!(
4930 engine.communities().entity_cluster_map,
4931 fresh_partition.entity_cluster_map,
4932 "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4933 );
4934 }
4935
4936 fn run_mutation_sequence(seed: u64) {
4937 use indexmap::IndexMap;
4938
4939 let (_tmp, mut engine) = migration_engine();
4940 let mut rng = Xorshift(seed);
4941 let mut live: Vec<crate::EntityId> = vec![
4942 crate::EntityId::new("specs", "one"),
4943 crate::EntityId::new("specs", "two"),
4944 ];
4945 let mut counter = 0usize;
4946 let mut kinds_hit: std::collections::HashSet<&'static str> =
4947 std::collections::HashSet::new();
4948
4949 const KINDS: [&str; 7] = [
4950 "create",
4951 "update",
4952 "relate",
4953 "delete",
4954 "rename",
4955 "batch_applied",
4956 "batch_refused",
4957 ];
4958
4959 let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4960 anchors: Vec::new(),
4961 anchors_unset: Vec::new(),
4962 id,
4963 expected_hash: None,
4964 sections: IndexMap::new(),
4965 append_sections: IndexMap::new(),
4966 patch_sections: IndexMap::new(),
4967 sections_unset: Vec::new(),
4968 metadata: IndexMap::new(),
4969 metadata_unset: Vec::new(),
4970 declare_relations: Vec::new(),
4971 dry_run: false,
4972 relations_unset: Vec::new(),
4973 };
4974
4975 for op_i in 0..30usize {
4976 let kind = *KINDS
4979 .get(op_i)
4980 .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4981 match kind {
4982 "create" => {
4983 counter += 1;
4984 let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4985 args.entity_type = "doc".to_string();
4986 args.sections = IndexMap::from_iter([(
4987 "body".to_string(),
4988 format!("generated body {counter}"),
4989 )]);
4990 let out = engine
4991 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4992 .expect("generated create is conformant");
4993 live.push(out.id);
4994 kinds_hit.insert("create");
4995 }
4996 "update" => {
4997 let id = live[rng.pick(live.len())].clone();
4998 let mut args = bare_update(id);
4999 args.append_sections
5000 .insert("body".to_string(), format!("appended at op {op_i}"));
5001 engine
5002 .update_entity(args, crate::vcs::Actor::Cli, None, None)
5003 .expect("append update is conformant");
5004 kinds_hit.insert("update");
5005 }
5006 "relate" => {
5007 if live.len() >= 2 {
5008 let a = rng.pick(live.len());
5009 let mut b = rng.pick(live.len());
5010 if a == b {
5011 b = (b + 1) % live.len();
5012 }
5013 engine
5014 .relate_entity(
5015 crate::engine::RelateEntityArgs {
5016 source: live[a].clone(),
5017 expected_hash: None,
5018 rel_type: "USES".to_string(),
5019 target: live[b].clone(),
5020 remove: false,
5021 description: None,
5022 dry_run: false,
5023 },
5024 crate::vcs::Actor::Cli,
5025 None,
5026 None,
5027 )
5028 .expect("USES relate is legal under mig-a");
5029 kinds_hit.insert("relate");
5030 }
5031 }
5032 "delete" => {
5033 if live.len() > 2
5036 && let Some(pos) = (0..live.len()).find(|&i| {
5037 engine.store().incoming(&live[i]).is_empty()
5038 && engine
5039 .store()
5040 .get(&live[i])
5041 .is_some_and(|e| e.relationships.is_empty())
5042 })
5043 {
5044 let id = live.remove(pos);
5045 engine
5046 .delete_entity(
5047 crate::engine::DeleteEntityArgs {
5048 id: id.clone(),
5049 expected_hash: None,
5050 },
5051 crate::vcs::Actor::Cli,
5052 None,
5053 None,
5054 )
5055 .expect("reference-free delete lands");
5056 kinds_hit.insert("delete");
5057 }
5058 }
5059 "rename" => {
5060 counter += 1;
5061 let pos = rng.pick(live.len());
5062 let old = live[pos].clone();
5063 let out = engine
5064 .rename_entity(
5065 crate::engine::RenameEntityArgs {
5066 id: old,
5067 new_title: format!("Renamed {counter}"),
5068 expected_hash: None,
5069 },
5070 crate::vcs::Actor::Cli,
5071 None,
5072 None,
5073 )
5074 .expect("fresh-slug rename lands");
5075 live[pos] = out.new_id;
5076 kinds_hit.insert("rename");
5077 }
5078 "batch_applied" => {
5079 let id_a = live[rng.pick(live.len())].clone();
5080 let mut a = bare_update(id_a);
5081 a.append_sections
5082 .insert("body".to_string(), format!("batch line {op_i}"));
5083 let result = engine
5084 .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
5085 .expect("batch envelope");
5086 assert!(result.applied, "single-entry append batch applies");
5087 kinds_hit.insert("batch_applied");
5088 }
5089 "batch_refused" => {
5090 let id_a = live[rng.pick(live.len())].clone();
5091 let mut a = bare_update(id_a);
5092 a.append_sections
5093 .insert("body".to_string(), "doomed".to_string());
5094 let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
5095 let result = engine
5096 .batch_update(
5097 vec![(a, None), (missing, None)],
5098 crate::vcs::Actor::Cli,
5099 None,
5100 false,
5101 )
5102 .expect("refused batch returns a report-all envelope");
5103 assert!(!result.applied, "the missing target refuses the batch");
5104 kinds_hit.insert("batch_refused");
5105 }
5106 _ => unreachable!(),
5107 }
5108
5109 if op_i == 14 {
5110 engine
5114 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5115 .expect("integral switch");
5116 kinds_hit.insert("schema_switch");
5117 }
5118 if op_i == 19 {
5119 engine.reload_one_mem("specs").expect("reload lands");
5120 kinds_hit.insert("reload");
5121 }
5122
5123 if op_i % 10 == 9 {
5124 assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
5125 }
5126 }
5127
5128 assert_derived_oracles(&engine, seed, "sequence end");
5129
5130 for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
5131 assert!(
5132 kinds_hit.contains(kind),
5133 "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
5134 );
5135 }
5136 }
5137
5138 fn migration_engine() -> (tempfile::TempDir, Engine) {
5139 let tmp = tempfile::TempDir::new().unwrap();
5140 let schemas_dir = tmp.path().join("schemas");
5141 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5142 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5143 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
5144 let mem_dir = tmp.path().join("mem");
5145 std::fs::create_dir_all(&mem_dir).unwrap();
5146 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5147 let mut mount = folder_mount("specs", mem_dir);
5148 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5149 let mut engine = Engine::from_mounts_with_schemas_dir(
5150 vec![(
5151 mount,
5152 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5153 )],
5154 Some(&schemas_dir),
5155 )
5156 .unwrap();
5157 for title in ["One", "Two"] {
5158 let mut args = empty_create_args("specs", title);
5159 args.entity_type = "doc".to_string();
5160 args.sections =
5161 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
5162 engine
5163 .create_entity(args, crate::vcs::Actor::Cli, None, None)
5164 .expect("conformant create under mig-a");
5165 }
5166 (tmp, engine)
5167 }
5168
5169 fn sref(s: &str) -> memstead_schema::SchemaRef {
5170 s.parse().unwrap()
5171 }
5172
5173 #[test]
5179 fn set_schema_repairs_a_mount_expectation_ahead_of_the_served_pin() {
5180 let (_tmp, mut engine) = migration_engine();
5181 let idx = engine
5184 .mounts
5185 .iter()
5186 .position(|m| m.mount.mem == "specs")
5187 .unwrap();
5188 engine.mounts[idx].mount.schema = Some(sref("mig-b@0.1.0"));
5189 assert_eq!(engine.schemas.get("specs").unwrap().id().0, "mig-a");
5190
5191 let out = engine
5192 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5193 .unwrap();
5194 assert_eq!(
5198 out.outcome,
5199 crate::engine::SetSchemaResult::MigrationStarted,
5200 "a served pin behind the target enters the switch path, never a noop: {out:?}"
5201 );
5202 assert!(!out.findings.is_empty());
5203 assert_eq!(
5204 engine.schemas.get("specs").unwrap().id().0,
5205 "mig-b",
5206 "writes now validate against the target"
5207 );
5208
5209 let (_tmp2, mut clean) = migration_engine();
5212 let again = clean.set_mem_schema("specs", &sref("mig-a@0.1.0")).unwrap();
5213 assert_eq!(again.outcome, crate::engine::SetSchemaResult::Noop);
5214 }
5215
5216 #[test]
5217 fn set_schema_noop_on_current_pin() {
5218 let (_tmp, mut engine) = migration_engine();
5219 let out = engine
5220 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
5221 .unwrap();
5222 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
5223 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5224 assert_eq!(out.migration_target, None);
5225 assert!(out.findings.is_empty());
5226 }
5227
5228 #[test]
5237 fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
5238 let (_tmp, mut engine) = migration_engine();
5239
5240 let _ = engine.communities();
5241 let _ = engine.search_indexes();
5242 assert!(engine.community_memo.get().is_some());
5243 assert!(engine.search_indexes_memo.get().is_some());
5244 let store_gen_before = engine.store().generation();
5245
5246 engine
5247 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5248 .unwrap();
5249
5250 assert_eq!(
5251 engine.store().generation(),
5252 store_gen_before,
5253 "a schema switch mutates no store content"
5254 );
5255 assert!(
5256 engine.community_memo.get().is_none(),
5257 "the community memo must clear on a schema switch (weights derive from the schema)"
5258 );
5259 assert!(
5260 engine.search_indexes_memo.get().is_none(),
5261 "the search memo must clear on a schema switch (the field set derives from the schema)"
5262 );
5263 }
5264
5265 #[test]
5266 fn set_schema_switches_immediately_when_integral() {
5267 let (_tmp, mut engine) = migration_engine();
5270 let out = engine
5271 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5272 .unwrap();
5273 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5274 assert_eq!(out.schema_pin, "mig-a@0.2.0");
5275 assert_eq!(out.migration_target, None);
5276 assert!(out.findings.is_empty());
5277 assert_eq!(
5278 engine.schema_pin("specs").unwrap().as_display(),
5279 "mig-a@0.2.0"
5280 );
5281 assert!(engine.migration_target("specs").is_none());
5282 }
5283
5284 #[test]
5290 fn set_schema_switch_persists_pin_into_backend_config() {
5291 let tmp = tempfile::TempDir::new().unwrap();
5292 let schemas_dir = tmp.path().join("schemas");
5293 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5294 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5295 let mem_dir = tmp.path().join("mem");
5296 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
5297 std::fs::write(
5299 mem_dir.join(".memstead").join("config.json"),
5300 br#"{"schema":"mig-a@0.1.0"}"#,
5301 )
5302 .unwrap();
5303 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5304 let mut mount = folder_mount("specs", mem_dir.clone());
5305 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5306 let mut engine = Engine::from_mounts_with_schemas_dir(
5307 vec![(
5308 mount,
5309 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5310 )],
5311 Some(&schemas_dir),
5312 )
5313 .unwrap();
5314
5315 let out = engine
5316 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5317 .unwrap();
5318 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5319
5320 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
5323 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
5324 assert_eq!(
5325 cfg["schema"], "mig-a@0.2.0",
5326 "atomic switch must update the authoritative backend config"
5327 );
5328 }
5329
5330 #[test]
5335 fn set_schema_completed_switch_restamps_marker_and_dual_pin_leaves_it() {
5336 let tmp = tempfile::TempDir::new().unwrap();
5337 let schemas_dir = tmp.path().join("schemas");
5338 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5339 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5340 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
5341 let mem_dir = tmp.path().join("mem");
5342 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
5343 let config_path = mem_dir.join(".memstead").join("config.json");
5344 std::fs::write(
5346 &config_path,
5347 br#"{"schema":"mig-a@0.1.0","mutationStamp":{"engineVersion":"0.0.1","schema":"mig-a@0.1.0"}}"#,
5348 )
5349 .unwrap();
5350 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5351 let mut mount = folder_mount("specs", mem_dir.clone());
5352 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5353 let mut engine = Engine::from_mounts_with_schemas_dir(
5354 vec![(
5355 mount,
5356 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5357 )],
5358 Some(&schemas_dir),
5359 )
5360 .unwrap();
5361 let stamp_of = |path: &std::path::Path| -> serde_json::Value {
5362 let cfg: serde_json::Value =
5363 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
5364 cfg["mutationStamp"].clone()
5365 };
5366
5367 let before = std::fs::read(&config_path).unwrap();
5369 let out = engine
5370 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
5371 .unwrap();
5372 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
5373 assert_eq!(out.stamped_schema.as_deref(), Some("mig-a@0.1.0"));
5374 assert_eq!(
5375 std::fs::read(&config_path).unwrap(),
5376 before,
5377 "a noop set-schema changes no byte of the mem config"
5378 );
5379
5380 for title in ["One", "Two"] {
5383 let mut args = empty_create_args("specs", title);
5384 args.entity_type = "doc".to_string();
5385 args.sections =
5386 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
5387 engine
5388 .create_entity(args, crate::vcs::Actor::Cli, None, None)
5389 .expect("conformant create under mig-a");
5390 }
5391 let out = engine
5392 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5393 .unwrap();
5394 assert_eq!(
5395 out.outcome,
5396 crate::engine::SetSchemaResult::MigrationStarted
5397 );
5398 assert!(!out.findings.is_empty());
5399 assert_eq!(out.stamped_schema.as_deref(), Some("mig-a@0.1.0"));
5400 assert_eq!(stamp_of(&config_path)["schema"], "mig-a@0.1.0");
5401
5402 let out = engine
5406 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5407 .unwrap();
5408 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5409 assert_eq!(out.stamped_schema.as_deref(), Some("mig-a@0.2.0"));
5410 let stamp = stamp_of(&config_path);
5411 assert_eq!(stamp["schema"], "mig-a@0.2.0");
5412 assert_eq!(stamp["engineVersion"], crate::build_info::full_version());
5413 }
5414
5415 #[test]
5416 fn set_schema_unknown_target_refuses_schema_not_found() {
5417 let (_tmp, mut engine) = migration_engine();
5418 let err = engine
5419 .set_mem_schema("specs", &sref("nope@9.9.9"))
5420 .unwrap_err();
5421 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
5422 assert!(engine.migration_target("specs").is_none());
5424 }
5425
5426 #[test]
5427 fn set_schema_migration_lifecycle_end_to_end() {
5428 let (_tmp, mut engine) = migration_engine();
5429 let target = sref("mig-b@0.1.0");
5430
5431 let out = engine.set_mem_schema("specs", &target).unwrap();
5433 assert_eq!(
5434 out.outcome,
5435 crate::engine::SetSchemaResult::MigrationStarted
5436 );
5437 assert_eq!(out.schema_pin, "mig-a@0.1.0");
5438 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
5439 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
5440 assert!(
5441 out.findings
5442 .iter()
5443 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
5444 );
5445
5446 let one = crate::entity::EntityId::new("specs", "one");
5448 assert!(engine.store().get(&one).is_some());
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(), 2);
5457
5458 let mut bad = crate::engine::UpdateEntityArgs {
5462 anchors: Vec::new(),
5463 id: one.clone(),
5464 expected_hash: None,
5465 sections: indexmap::IndexMap::new(),
5466 append_sections: indexmap::IndexMap::new(),
5467 patch_sections: indexmap::IndexMap::new(),
5468 sections_unset: Vec::new(),
5469 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
5470 metadata_unset: Vec::new(),
5471 declare_relations: Vec::new(),
5472 dry_run: false,
5473 relations_unset: Vec::new(),
5474 anchors_unset: Vec::new(),
5475 };
5476 let err = engine
5477 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
5478 .unwrap_err();
5479 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
5480 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
5481 engine
5482 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
5483 .expect("repair write validated against the migration target");
5484
5485 let out = engine.set_mem_schema("specs", &target).unwrap();
5487 assert_eq!(
5488 out.outcome,
5489 crate::engine::SetSchemaResult::MigrationPending
5490 );
5491 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
5492
5493 let two = crate::entity::EntityId::new("specs", "two");
5495 let repair = crate::engine::UpdateEntityArgs {
5496 anchors: Vec::new(),
5497 id: two.clone(),
5498 expected_hash: None,
5499 sections: indexmap::IndexMap::new(),
5500 append_sections: indexmap::IndexMap::new(),
5501 patch_sections: indexmap::IndexMap::new(),
5502 sections_unset: Vec::new(),
5503 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
5504 metadata_unset: Vec::new(),
5505 declare_relations: Vec::new(),
5506 dry_run: false,
5507 relations_unset: Vec::new(),
5508 anchors_unset: Vec::new(),
5509 };
5510 engine
5511 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
5512 .unwrap();
5513 let out = engine.set_mem_schema("specs", &target).unwrap();
5514 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5515 assert_eq!(out.schema_pin, "mig-b@0.1.0");
5516 assert_eq!(out.migration_target, None);
5517 assert!(out.findings.is_empty());
5518 assert_eq!(
5519 engine.schema_pin("specs").unwrap().as_display(),
5520 "mig-b@0.1.0"
5521 );
5522 assert!(engine.migration_target("specs").is_none());
5523 }
5524
5525 #[test]
5530 fn relations_unset_works_during_migration_without_mode_flag() {
5531 let (_tmp, mut engine) = migration_engine();
5532 let one = crate::entity::EntityId::new("specs", "one");
5533 let two = crate::entity::EntityId::new("specs", "two");
5534 engine
5535 .relate_entity(
5536 crate::engine::RelateEntityArgs {
5537 source: one.clone(),
5538 expected_hash: None,
5539 rel_type: "USES".to_string(),
5540 target: two.clone(),
5541 remove: false,
5542 description: None,
5543 dry_run: false,
5544 },
5545 crate::vcs::Actor::Cli,
5546 None,
5547 None,
5548 )
5549 .unwrap();
5550 let shut = engine
5552 .update_entity(
5553 crate::engine::UpdateEntityArgs {
5554 anchors: Vec::new(),
5555 id: one.clone(),
5556 expected_hash: None,
5557 sections: indexmap::IndexMap::new(),
5558 append_sections: indexmap::IndexMap::new(),
5559 patch_sections: indexmap::IndexMap::new(),
5560 sections_unset: Vec::new(),
5561 metadata: indexmap::IndexMap::new(),
5562 metadata_unset: Vec::new(),
5563 declare_relations: Vec::new(),
5564 dry_run: false,
5565 relations_unset: vec![crate::ops::RelationUnsetArg {
5566 rel_type: "USES".to_string(),
5567 target: two.clone(),
5568 }],
5569 anchors_unset: Vec::new(),
5570 },
5571 crate::vcs::Actor::Cli,
5572 None,
5573 None,
5574 )
5575 .unwrap_err();
5576 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5577
5578 engine
5582 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5583 .unwrap();
5584 engine
5585 .update_entity(
5586 crate::engine::UpdateEntityArgs {
5587 anchors: Vec::new(),
5588 id: one.clone(),
5589 expected_hash: None,
5590 sections: indexmap::IndexMap::new(),
5591 append_sections: indexmap::IndexMap::new(),
5592 patch_sections: indexmap::IndexMap::new(),
5593 sections_unset: Vec::new(),
5594 metadata: indexmap::IndexMap::from_iter([(
5595 "status".to_string(),
5596 "open".to_string(),
5597 )]),
5598 metadata_unset: Vec::new(),
5599 declare_relations: Vec::new(),
5600 dry_run: false,
5601 relations_unset: vec![crate::ops::RelationUnsetArg {
5602 rel_type: "USES".to_string(),
5603 target: two.clone(),
5604 }],
5605 anchors_unset: Vec::new(),
5606 },
5607 crate::vcs::Actor::Cli,
5608 None,
5609 None,
5610 )
5611 .expect("repair-shaped update lands during migration without a flag");
5612 let entity = engine.store().get(&one).unwrap();
5613 assert!(entity.relationships.is_empty());
5614 }
5615
5616 #[test]
5621 fn boot_resumes_dual_pin_validation_against_target() {
5622 let (tmp, engine) = migration_engine();
5623 drop(engine);
5624 let schemas_dir = tmp.path().join("schemas");
5625 let mem_dir = tmp.path().join("mem");
5626 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5627 let mut mount = folder_mount("specs", mem_dir);
5628 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5629 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5630 let engine = Engine::from_mounts_with_schemas_dir(
5631 vec![(
5632 mount,
5633 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5634 )],
5635 Some(&schemas_dir),
5636 )
5637 .unwrap();
5638 let (name, version) = {
5640 let s = engine.schema_for("specs").unwrap();
5641 let (n, v) = s.id();
5642 (n.to_string(), v.to_string())
5643 };
5644 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5645 assert_eq!(
5648 engine.schema_pin("specs").unwrap().as_display(),
5649 "mig-a@0.1.0"
5650 );
5651 assert_eq!(
5652 engine.migration_target("specs").unwrap().as_display(),
5653 "mig-b@0.1.0"
5654 );
5655 }
5656
5657 #[test]
5667 fn every_lifecycle_setter_refuses_on_read_only_mount() {
5668 let tmp = TempDir::new().unwrap();
5669 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5670 let mut engine = Engine::from_mounts(vec![(
5671 archive_mount("ext", archive_path.clone()),
5672 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5673 )])
5674 .unwrap();
5675
5676 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5677 let attempts: Vec<(&str, EngineError)> = vec![
5678 (
5679 "set_mem_schema",
5680 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5681 ),
5682 (
5683 "set_mem_version",
5684 engine
5685 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5686 .unwrap_err(),
5687 ),
5688 (
5689 "set_mem_description",
5690 engine
5691 .set_mem_description("ext", Some("x".into()), None)
5692 .unwrap_err(),
5693 ),
5694 (
5695 "set_mem_title",
5696 engine
5697 .set_mem_title("ext", Some("x".into()), None)
5698 .unwrap_err(),
5699 ),
5700 (
5701 "set_mem_subject",
5702 engine.set_mem_subject("ext", None, None).unwrap_err(),
5703 ),
5704 (
5705 "set_mem_internal",
5706 engine.set_mem_internal("ext", true, None).unwrap_err(),
5707 ),
5708 (
5709 "set_mem_sync_state",
5710 engine
5711 .set_mem_sync_state("ext", "k", "t", None)
5712 .unwrap_err(),
5713 ),
5714 ];
5715 for (setter, err) in attempts {
5716 match err {
5717 EngineError::ReadOnlyMount(v) => {
5718 assert_eq!(v, "ext", "{setter} must name the refused mem")
5719 }
5720 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5721 }
5722 }
5723 }
5724
5725 #[test]
5731 fn no_config_setter_reverts_a_siblings_write() {
5732 type Setter = fn(&mut Engine) -> Result<(), EngineError>;
5733 let setters: Vec<(&str, Setter)> = vec![
5734 ("version", |e| {
5735 e.set_mem_version("specs", semver::Version::new(9, 0, 0), None)
5736 .map(|_| ())
5737 }),
5738 ("description", |e| {
5739 e.set_mem_description("specs", Some("mine".into()), None)
5740 .map(|_| ())
5741 }),
5742 ("title", |e| {
5743 e.set_mem_title("specs", Some("Mine".into()), None)
5744 .map(|_| ())
5745 }),
5746 ("internal", |e| {
5747 e.set_mem_internal("specs", true, None).map(|_| ())
5748 }),
5749 ("sync_state", |e| {
5750 e.set_mem_sync_state("specs", "src/facet", "tok", None)
5751 .map(|_| ())
5752 }),
5753 ("review_mark", |e| {
5757 e.set_review_mark("specs", None, None).map(|_| ())
5758 }),
5759 ];
5760
5761 for (name, set) in setters {
5762 let tmp = TempDir::new().unwrap();
5763 let mem_dir = tmp.path().to_path_buf();
5764 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5765 std::fs::create_dir_all(&meta).unwrap();
5766 let path = meta.join("config.json");
5767 std::fs::write(
5768 &path,
5769 br#"{"schema": "default@1.0.0", "version": "0.1.0"}"#.as_slice(),
5770 )
5771 .unwrap();
5772
5773 let writer = FilesystemMemWriter::new(mem_dir.clone());
5774 let mut engine = Engine::from_mounts(vec![(
5775 folder_mount("specs", mem_dir.clone()),
5776 Box::new(writer) as Box<dyn MemBackend>,
5777 )])
5778 .unwrap();
5779 engine
5782 .create_entity(
5783 crate::engine::test_helpers::empty_create_args("specs", "Seed"),
5784 crate::vcs::Actor::Cli,
5785 None,
5786 None,
5787 )
5788 .unwrap();
5789
5790 let mut sibling: memstead_schema::MemConfig =
5792 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5793 sibling
5794 .extra
5795 .insert("siblingMark".into(), serde_json::json!("kept"));
5796 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5797
5798 set(&mut engine).unwrap_or_else(|e| panic!("{name} setter failed: {e}"));
5799
5800 let after: memstead_schema::MemConfig =
5801 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5802 assert_eq!(
5803 after.extra.get("siblingMark"),
5804 Some(&serde_json::json!("kept")),
5805 "the {name} setter reverted a field it never set"
5806 );
5807 }
5808 }
5809
5810 #[test]
5813 fn intervention_is_reported_on_the_response_and_only_when_real() {
5814 let tmp = TempDir::new().unwrap();
5815 let mem_dir = tmp.path().to_path_buf();
5816 let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5817 std::fs::create_dir_all(&meta).unwrap();
5818 let path = meta.join("config.json");
5819 std::fs::write(&path, br#"{"schema": "default@1.0.0"}"#.as_slice()).unwrap();
5820 let writer = FilesystemMemWriter::new(mem_dir.clone());
5821 let mut engine = Engine::from_mounts(vec![(
5822 folder_mount("specs", mem_dir.clone()),
5823 Box::new(writer) as Box<dyn MemBackend>,
5824 )])
5825 .unwrap();
5826
5827 let quiet = engine
5830 .set_mem_description("specs", Some("first".into()), None)
5831 .unwrap();
5832 assert!(
5833 !quiet
5834 .warnings
5835 .iter()
5836 .any(|w| w.code() == "CONFIG_WRITE_INTERVENED"),
5837 "single-writer workspace must stay silent: {:?}",
5838 quiet.warnings
5839 );
5840
5841 let mut sibling: memstead_schema::MemConfig =
5843 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5844 sibling.title = Some("theirs".into());
5845 std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5846
5847 let loud = engine
5848 .set_mem_description("specs", Some("second".into()), None)
5849 .unwrap();
5850 let hint = loud
5851 .warnings
5852 .iter()
5853 .find(|w| w.code() == "CONFIG_WRITE_INTERVENED")
5854 .expect("intervention must be reported on the response");
5855 assert!(
5856 format!("{hint}").contains("title"),
5857 "the report names what they changed: {hint}"
5858 );
5859 let after: memstead_schema::MemConfig =
5861 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5862 assert_eq!(after.title.as_deref(), Some("theirs"));
5863 assert_eq!(after.description.as_deref(), Some("second"));
5864 }
5865}