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
31impl Engine {
32 pub fn set_settings(&mut self, settings: WorkspaceSettings) {
41 self.settings = settings;
42 self.create_rule_set_memo = OnceCell::new();
43 }
44
45 pub fn set_backend_factory(&mut self, factory: BackendFactory) {
51 self.backend_factory = factory;
52 }
53
54 pub fn set_git_branch_ops(&mut self, ops: GitBranchOps) {
60 self.git_branch_ops = Some(ops);
61 }
62
63 pub fn install_schema(
78 &self,
79 name: &str,
80 version: &str,
81 files: &[(String, Vec<u8>)],
82 ) -> Result<String, EngineError> {
83 let gitdir = self
89 .mounts
90 .iter()
91 .find_map(|m| match &m.mount.storage {
92 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
93 _ => None,
94 })
95 .or_else(|| {
96 self.workspace_root()
97 .map(|r| r.join("mem-repo").join(".git"))
98 })
99 .ok_or_else(|| {
100 EngineError::Mem(
101 "schema install requires a mem-repo workspace (no git-branch mount and \
102 no workspace root to resolve the mem-repo gitdir)"
103 .to_string(),
104 )
105 })?;
106 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
107 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
108 })?;
109 (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)
110 }
111 pub fn unregister_writable_mem(
130 &mut self,
131 mem_name: &str,
132 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
133 let pos = self.mounts.iter().position(|m| m.mount.mem == mem_name);
134 let Some(idx) = pos else {
135 return Ok(None);
136 };
137
138 let mount = self.mounts.remove(idx);
144
145 self.schemas.remove(&mount.mount.mem);
148
149 let _removed = self.store.remove_entities_by_mem(mem_name);
154
155 Arc::make_mut(&mut self.mem_router).remove_writable(mem_name);
163
164 self.invalidate_communities();
169 self.invalidate_search_indexes();
170
171 Ok(Some(mount.backend))
172 }
173
174 pub fn register_writable_mem(
213 &mut self,
214 mount: Mount,
215 backend: Box<dyn MemBackend>,
216 origin: MemOrigin,
217 ) -> Result<(), EngineError> {
218 if let Some(existing) = self.mem_router.origin_for_mem(&mount.mem) {
220 return Err(EngineError::MemNameCollision {
221 name: mount.mem.clone(),
222 source_origin: existing.render_source(),
223 });
224 }
225 if self.mem_router.archive_path_for_mem(&mount.mem).is_some() {
226 return Err(EngineError::MemNameCollision {
227 name: mount.mem.clone(),
228 source_origin: "attached read mem".to_string(),
229 });
230 }
231
232 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
238 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
239 memstead_schema::config::parse_mem_config(&value).ok()
240 });
241
242 let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
252 self.workspace_schemas().to_vec();
253 builtin_schemas.extend(
254 memstead_schema::builtins::load_builtin_schemas()
255 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?,
256 );
257 let config_pin = mem_config.as_ref().and_then(|c| c.schema.as_ref());
258 let mount_pin = mount.schema.as_ref();
259 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
260 && cfg != mp
261 {
262 self.load_warnings
263 .push(crate::ops::WarningHint::SchemaPinMismatch {
264 mem: mount.mem.clone(),
265 config_pin: cfg.as_display(),
266 mount_pin: mp.as_display(),
267 });
268 }
269 let settled_pin = config_pin.or(mount_pin);
270 let effective_pin = mount
271 .migration_target
272 .as_ref()
273 .or(settled_pin)
274 .ok_or_else(|| EngineError::MemConfigIncomplete {
275 mem: mount.mem.clone(),
276 missing_fields: vec!["schema".to_string()],
277 })?
278 .clone();
279 let schema = crate::engine::SchemaResolver::new(&builtin_schemas)
280 .resolve(&effective_pin)
281 .map_err(|sources| EngineError::SchemaNotFound {
282 mem: mount.mem.clone(),
283 pin: effective_pin.as_display(),
284 sources,
285 })?;
286
287 let (entries, read_errors) = collect_source_entries(backend.as_ref())?;
295 let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
296
297 let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
298 mem_names.push(mount.mem.clone());
299 let known_suffixes: Vec<String> = mem_names
300 .iter()
301 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
302 .collect();
303 let fallback = engine_fallback_type();
304 push_entities_into_store(
305 &mut self.store,
306 load_result.entities,
307 fallback.as_ref(),
308 Some(crate::entity::store_builder::LoadCollector {
309 warnings: &mut self.load_warnings,
310 known_suffixes: &known_suffixes,
311 mem_names: &mem_names,
312 }),
313 );
314 self.load_errors.extend(load_result.errors);
315
316 self.schemas.insert(mount.mem.clone(), schema);
318
319 let mut mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
330 self.mounts
331 .iter()
332 .map(|m| (m.mount.mem.clone(), m.mount.capability))
333 .collect();
334 mount_caps.insert(mount.mem.clone(), mount.capability);
335 crate::entity::store_builder::validate_loaded_relations(
336 &mut self.store,
337 &self.schemas,
338 &mount_caps,
339 &mut self.load_warnings,
340 );
341 crate::entity::store_builder::remap_alias_target_edge_sources(
342 &mut self.store,
343 &self.schemas,
344 );
345
346 let last_known_head = backend.current_head().ok().flatten();
348 let mem_name_for_router = mount.mem.clone();
349 let storage_for_router = mount.storage.clone();
350 self.mounts.push(MountedBackend {
351 mount,
352 backend,
353 last_known_head,
354 mem_config,
355 archive_provenance: None,
358 });
359
360 let dir: Option<PathBuf> = match &storage_for_router {
364 MountStorage::Folder { path } => Some(path.clone()),
365 MountStorage::GitBranch { .. }
366 | MountStorage::Archive { .. }
367 | MountStorage::InMemory => None,
368 };
369 Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
370
371 self.invalidate_communities();
373 self.invalidate_search_indexes();
374
375 Ok(())
376 }
377
378 pub fn set_workspace_root(&mut self, root: PathBuf) {
384 self.workspace_root = Some(root);
385 }
386
387 pub fn persist_state(&self) -> Result<(), EngineError> {
410 let Some(root) = self.workspace_root.as_ref() else {
411 return Ok(());
412 };
413 let workspace = crate::workspace::Workspace {
414 mounts: self.mounts.iter().map(|m| m.mount.clone()).collect(),
415 settings: self.settings.clone(),
416 };
417 let store = crate::FileWorkspaceStore::new();
418 crate::workspace_store::WorkspaceStoreAdapter::save_state(&store, root, &workspace)
419 .map_err(|e| EngineError::Mem(format!("persist workspace state: {e}")))
420 }
421 pub fn set_mem_schema(
441 &mut self,
442 mem: &str,
443 target: &memstead_schema::SchemaRef,
444 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
445 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
446 let mount_idx = self
447 .mounts
448 .iter()
449 .position(|m| m.mount.mem == mem)
450 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
451
452 let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
455 let consulted: Vec<_> = self
458 .workspace_schemas
459 .iter()
460 .chain(self.builtin_schemas.iter())
461 .cloned()
462 .collect();
463 EngineError::SchemaNotFound {
464 mem: mem.to_string(),
465 pin: target.as_display(),
466 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
467 &target.name,
468 &target.version,
469 &consulted,
470 ),
471 }
472 })?;
473
474 let current_pin = self.mounts[mount_idx].mount.schema.clone();
479 let current_pin_display = current_pin
480 .as_ref()
481 .map(|p| p.as_display())
482 .unwrap_or_else(|| "<unset>".to_string());
483 let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
484
485 if current_pin.as_ref() == Some(target) {
486 return Ok(SetSchemaOutcome {
487 mem: mem.to_string(),
488 schema_pin: current_pin_display,
489 migration_target: in_flight.map(|t| t.as_display()),
490 outcome: SetSchemaResult::Noop,
491 findings: Vec::new(),
492 });
493 }
494
495 let findings = crate::ops::integrity::conformance_findings(
501 &self.store,
502 mem,
503 target_schema.as_ref(),
504 &self.schemas,
505 );
506
507 if findings.is_empty() {
508 self.persist_mem_schema_pin(mount_idx, target)?;
516 self.mounts[mount_idx].mount.schema = Some(target.clone());
517 self.mounts[mount_idx].mount.migration_target = None;
518 self.schemas.insert(mem.to_string(), target_schema);
519 self.invalidate_communities();
520 self.persist_state()?;
521 return Ok(SetSchemaOutcome {
522 mem: mem.to_string(),
523 schema_pin: target.as_display(),
524 migration_target: None,
525 outcome: SetSchemaResult::Switched,
526 findings: Vec::new(),
527 });
528 }
529
530 let outcome = if in_flight.as_ref() == Some(target) {
531 SetSchemaResult::MigrationPending
532 } else {
533 SetSchemaResult::MigrationStarted
534 };
535 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
536 self.schemas.insert(mem.to_string(), target_schema);
539 self.invalidate_communities();
540 self.persist_state()?;
541 Ok(SetSchemaOutcome {
542 mem: mem.to_string(),
543 schema_pin: current_pin_display,
544 migration_target: Some(target.as_display()),
545 outcome,
546 findings,
547 })
548 }
549
550 fn persist_mem_schema_pin(
567 &mut self,
568 mount_idx: usize,
569 target: &memstead_schema::SchemaRef,
570 ) -> Result<(), EngineError> {
571 let Some(bytes) = self.mounts[mount_idx]
572 .backend
573 .read_mem_config()
574 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
575 else {
576 return Ok(());
577 };
578 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
579 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
580 value["schema"] = serde_json::Value::String(target.as_display());
581 let new_bytes = serde_json::to_vec_pretty(&value)
582 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
583 self.mounts[mount_idx]
584 .backend
585 .write_mem_config(&new_bytes)
586 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
587 if let Ok(cfg) = memstead_schema::config::parse_mem_config(&value) {
590 self.mounts[mount_idx].mem_config = Some(cfg);
591 }
592 Ok(())
593 }
594
595 pub fn export_markdown(
624 &self,
625 mem_filter: Option<&str>,
626 schema_filter: Option<&str>,
627 ) -> Result<crate::ops::ExportResult, EngineError> {
628 use crate::workspace::MountStorage;
629 let fallback = engine_fallback_type();
630 let supported_backends = vec!["folder".to_string()];
631
632 if let Some(name) = mem_filter {
633 let mount = self
634 .mounts
635 .iter()
636 .find(|m| m.mount.mem == name)
637 .ok_or_else(|| EngineError::UnknownMem(name.to_string()))?;
638 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
639 return Err(EngineError::MarkdownExportUnsupportedBackend {
640 mem: name.to_string(),
641 active_backend: mount.mount.storage.backend_id().to_string(),
642 supported_backends,
643 });
644 }
645 }
646
647 let mut total_written = 0;
648 let mut total_unchanged = 0;
649 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
650
651 for mount in &self.mounts {
652 let mem_name = mount.mount.mem.as_str();
653 if let Some(filter) = mem_filter
654 && mem_name != filter
655 {
656 continue;
657 }
658 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
659 skipped_mounts.push(crate::ops::SkippedMount {
660 mem: mem_name.to_string(),
661 active_backend: mount.mount.storage.backend_id().to_string(),
662 reason: "backend_does_not_support_markdown_export".to_string(),
663 });
664 continue;
665 };
666 let schema = match self.schemas.get(mem_name) {
667 Some(s) => s,
668 None => continue,
669 };
670
671 for entity in self.store.all_entities() {
672 if entity.stub || entity.file_path.is_empty() {
673 continue;
674 }
675 if entity.id.mem() != mem_name {
676 continue;
677 }
678 if let Some(filter) = schema_filter
679 && entity.entity_type != filter
680 {
681 continue;
682 }
683 let type_def = schema
684 .get_type(&entity.entity_type)
685 .unwrap_or_else(|| fallback.clone());
686 let generated = generate_markdown(entity, type_def.as_ref());
687
688 let full_path = mem_dir.join(&entity.file_path);
689 let needs_write = match std::fs::read_to_string(&full_path) {
690 Ok(existing) => existing != generated,
691 Err(_) => true,
692 };
693 if needs_write {
694 let _ = crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref());
695 total_written += 1;
696 } else {
697 total_unchanged += 1;
698 }
699 }
700 }
701
702 Ok(crate::ops::ExportResult {
703 written: total_written,
704 unchanged: total_unchanged,
705 skipped_mounts,
706 })
707 }
708
709 pub fn export_mem(
727 &self,
728 mem_name: &str,
729 output_path: &std::path::Path,
730 ) -> Result<crate::ops::MemExportResult, EngineError> {
731 let mount = self
732 .mounts
733 .iter()
734 .find(|m| m.mount.mem == mem_name)
735 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
736 let config = self.mem_config_for(mem_name).ok_or_else(|| {
737 EngineError::InvalidInput(format!(
738 "mem '{mem_name}' has no loaded MemConfig — cannot export"
739 ))
740 })?;
741 if config.version.is_none() {
749 return Err(EngineError::MemConfigIncomplete {
750 mem: mem_name.to_string(),
751 missing_fields: vec!["version".to_string()],
752 });
753 }
754 let workspace_root = self.workspace_root.as_deref();
755 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
759 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
760 match &mount.mount.storage {
761 MountStorage::Folder { path } => crate::ops::export::export_mem(
762 path,
763 config,
764 output_path,
765 workspace_root,
766 workspace_schemas_dir,
767 )
768 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
769 MountStorage::GitBranch { gitdir, branch } => {
770 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
771 EngineError::Backend(BackendError::Other(
772 "git-branch export hook not installed (full flavour not loaded)"
773 .to_string(),
774 ))
775 })?;
776 let provenance_bytes = mount
780 .backend
781 .read_provenance(None)
782 .ok()
783 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
784 .and_then(|prov| prov.to_archive_bytes().ok());
785 (hook.export)(
786 gitdir,
787 branch,
788 mem_name,
789 config,
790 output_path,
791 workspace_root,
792 workspace_schemas_dir,
793 provenance_bytes.as_deref(),
794 )
795 .map_err(EngineError::Backend)
796 }
797 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
798 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
803 "export not yet supported for in-memory backend".to_string(),
804 ))),
805 }
806 }
807
808 pub fn set_mem_version(
827 &mut self,
828 mem_name: &str,
829 new_version: semver::Version,
830 note: Option<&str>,
831 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
832 let mount_idx = self
835 .mounts
836 .iter()
837 .position(|m| m.mount.mem == mem_name)
838 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
839 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
840 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
841 }
842
843 let mut warnings = self.reload_if_stale(Some(mem_name));
851 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
856 warnings.push(w);
857 }
858
859 let mounted = &mut self.mounts[mount_idx];
860 let mut config = mounted.mem_config.clone().ok_or_else(|| {
861 EngineError::InvalidInput(format!(
862 "mem '{mem_name}' has no loaded MemConfig — \
863 cannot set version (initialize the mem via `memstead init` \
864 or `memstead mem create` first)"
865 ))
866 })?;
867 let old_version = config.version.clone();
868 config.version = Some(new_version.clone());
869
870 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
871 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
872 })?;
873 bytes.push(b'\n');
874 mounted.backend.write_mem_config_with_note(&bytes, note)?;
875 mounted.mem_config = Some(config);
876
877 let new_head = mounted.backend.current_head().ok().flatten();
883 if let Some(sha) = new_head {
884 mounted.last_known_head = Some(sha);
885 }
886
887 Ok(crate::ops::SetMemVersionOutcome {
888 mem: mem_name.to_string(),
889 old_version,
890 new_version,
891 warnings,
892 })
893 }
894
895 pub fn set_mem_description(
902 &mut self,
903 mem_name: &str,
904 new_description: Option<String>,
905 note: Option<&str>,
906 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
907 let mount_idx = self
908 .mounts
909 .iter()
910 .position(|m| m.mount.mem == mem_name)
911 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
912 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
913 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
914 }
915
916 let mut warnings = self.reload_if_stale(Some(mem_name));
917 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
918 warnings.push(w);
919 }
920
921 let mounted = &mut self.mounts[mount_idx];
922 let mut config = mounted.mem_config.clone().ok_or_else(|| {
923 EngineError::InvalidInput(format!(
924 "mem '{mem_name}' has no loaded MemConfig — \
925 cannot set description (initialize the mem via `memstead init` \
926 or `memstead mem create` first)"
927 ))
928 })?;
929 let old_description = config.description.clone();
930 config.description = new_description.clone();
931
932 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
933 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
934 })?;
935 bytes.push(b'\n');
936 mounted.backend.write_mem_config_with_note(&bytes, note)?;
937 mounted.mem_config = Some(config);
938
939 let new_head = mounted.backend.current_head().ok().flatten();
940 if let Some(sha) = new_head {
941 mounted.last_known_head = Some(sha);
942 }
943
944 Ok(crate::ops::SetMemDescriptionOutcome {
945 mem: mem_name.to_string(),
946 old_description,
947 new_description,
948 warnings,
949 })
950 }
951
952 pub fn set_mem_sync_state(
977 &mut self,
978 mem_name: &str,
979 key: &str,
980 token: &str,
981 note: Option<&str>,
982 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
983 let mount_idx = self
986 .mounts
987 .iter()
988 .position(|m| m.mount.mem == mem_name)
989 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
990 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
991 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
992 }
993
994 let mut warnings = self.reload_if_stale(Some(mem_name));
998 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
999 warnings.push(w);
1000 }
1001
1002 let mounted = &mut self.mounts[mount_idx];
1003 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1004 EngineError::InvalidInput(format!(
1005 "mem '{mem_name}' has no loaded MemConfig — \
1006 cannot set sync state (initialize the mem via `memstead init` \
1007 or `memstead mem create` first)"
1008 ))
1009 })?;
1010
1011 let removed;
1015 let previous;
1016 if token.is_empty() {
1017 previous = config.sync_state.remove(key);
1018 removed = previous.is_some();
1019 } else {
1020 previous = config.sync_state.insert(key.to_string(), token.to_string());
1021 removed = false;
1022 }
1023
1024 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1025 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1026 })?;
1027 bytes.push(b'\n');
1028 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1029 mounted.mem_config = Some(config);
1030
1031 let new_head = mounted.backend.current_head().ok().flatten();
1034 if let Some(sha) = new_head {
1035 mounted.last_known_head = Some(sha);
1036 }
1037
1038 Ok(crate::ops::SetMemSyncStateOutcome {
1039 mem: mem_name.to_string(),
1040 key: key.to_string(),
1041 previous,
1042 removed,
1043 warnings,
1044 })
1045 }
1046
1047 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
1067 let mut sink: Vec<WarningHint> = Vec::new();
1074 self.reload_one_mem_inner(mem, &mut sink)
1075 }
1076
1077 fn reload_one_mem_inner(
1083 &mut self,
1084 mem: &str,
1085 warnings_sink: &mut Vec<WarningHint>,
1086 ) -> Result<crate::ops::ReloadResult, EngineError> {
1087 let mount_idx = self
1090 .mounts
1091 .iter()
1092 .position(|m| m.mount.mem == mem)
1093 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
1094 let schema = self
1095 .schemas
1096 .get(mem)
1097 .cloned()
1098 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
1099
1100 let pre: HashMap<EntityId, String> = self
1102 .store
1103 .all_entities()
1104 .filter(|e| !e.stub && e.mem == mem)
1105 .map(|e| (e.id.clone(), e.content_hash.clone()))
1106 .collect();
1107 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
1108
1109 let backend = self.mounts[mount_idx].backend.as_ref();
1112 let (entries, read_errors) = collect_source_entries(backend)?;
1113 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
1114
1115 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1119 let known_suffixes: Vec<String> = mem_names
1120 .iter()
1121 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
1122 .collect();
1123
1124 self.store.remove_entities_by_mem(mem);
1126 let fallback = engine_fallback_type();
1127 push_entities_into_store(
1128 &mut self.store,
1129 load_result.entities,
1130 fallback.as_ref(),
1131 Some(crate::entity::store_builder::LoadCollector {
1132 warnings: warnings_sink,
1133 known_suffixes: &known_suffixes,
1134 mem_names: &mem_names,
1135 }),
1136 );
1137 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
1144 .mounts
1145 .iter()
1146 .map(|m| (m.mount.mem.clone(), m.mount.capability))
1147 .collect();
1148 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
1156 crate::entity::store_builder::validate_loaded_relations(
1157 &mut self.store,
1158 &self.schemas,
1159 &mount_caps,
1160 warnings_sink,
1161 );
1162 crate::entity::store_builder::remap_alias_target_edge_sources(
1163 &mut self.store,
1164 &self.schemas,
1165 );
1166 self.load_errors.extend(load_result.errors);
1172
1173 let mut added: Vec<EntityId> = Vec::new();
1175 let mut changed: Vec<EntityId> = Vec::new();
1176 for entity in self.store.all_entities() {
1177 if entity.stub || entity.mem != mem {
1178 continue;
1179 }
1180 match pre.get(&entity.id) {
1181 None => added.push(entity.id.clone()),
1182 Some(prev_hash) if prev_hash != &entity.content_hash => {
1183 changed.push(entity.id.clone());
1184 }
1185 Some(_) => {}
1186 }
1187 }
1188 let post_ids: std::collections::HashSet<EntityId> = self
1189 .store
1190 .all_entities()
1191 .filter(|e| !e.stub && e.mem == mem)
1192 .map(|e| e.id.clone())
1193 .collect();
1194 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
1195 added.sort_by(|a, b| a.0.cmp(&b.0));
1196 changed.sort_by(|a, b| a.0.cmp(&b.0));
1197 removed.sort_by(|a, b| a.0.cmp(&b.0));
1198
1199 self.invalidate_communities();
1200 self.invalidate_search_indexes();
1201
1202 Ok(crate::ops::ReloadResult {
1203 added,
1204 changed,
1205 removed,
1206 })
1207 }
1208
1209 pub fn reload_one_mem_report(
1237 &mut self,
1238 mem: &str,
1239 ) -> Result<crate::ops::ReloadReport, EngineError> {
1240 let tracks_head = self
1250 .mounts
1251 .iter()
1252 .find(|m| m.mount.mem == mem)
1253 .and_then(|m| m.backend.current_head().ok().flatten())
1254 .is_some();
1255 let head_before = if tracks_head {
1256 self.mounts
1257 .iter()
1258 .find(|m| m.mount.mem == mem)
1259 .and_then(|m| m.last_known_head.clone())
1260 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
1261 } else {
1262 crate::ops::EMPTY_TREE_SHA.to_string()
1263 };
1264
1265 let result = self.reload_one_mem(mem)?;
1266
1267 let head_after_raw = self
1274 .mounts
1275 .iter()
1276 .find(|m| m.mount.mem == mem)
1277 .and_then(|m| m.backend.current_head().ok().flatten());
1278 if let Some(new_head) = head_after_raw.clone()
1279 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
1280 {
1281 m.last_known_head = Some(new_head);
1282 }
1283 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
1284
1285 let entities_loaded = self
1286 .store
1287 .all_entities()
1288 .filter(|e| !e.stub && e.mem == mem)
1289 .count();
1290
1291 let mut changed_entity_ids: Vec<EntityId> = result
1296 .added
1297 .into_iter()
1298 .chain(result.changed)
1299 .chain(result.removed)
1300 .collect();
1301 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
1302
1303 Ok(crate::ops::ReloadReport {
1304 mem: mem.to_string(),
1305 head_before,
1306 head_after,
1307 entities_loaded,
1308 changed_entity_ids,
1309 })
1310 }
1311
1312 pub fn reload_each_writable_mem_reports(
1342 &mut self,
1343 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
1344 self.refresh_workspace_settings_if_possible();
1345 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1346 let mut out = Vec::with_capacity(names.len());
1347 for name in names {
1348 let report = self.reload_one_mem_report(&name)?;
1349 out.push(report);
1350 }
1351 Ok(out)
1352 }
1353
1354 fn refresh_workspace_settings_if_possible(&mut self) {
1366 let Some(root) = self.workspace_root.clone() else {
1367 return;
1368 };
1369 let store = crate::workspace_store::FileWorkspaceStore::new();
1370 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
1371 Ok(w) => w,
1372 Err(_) => return,
1373 };
1374 self.set_settings(workspace.settings);
1375 }
1376
1377 pub fn reload_each_writable_mem(
1393 &mut self,
1394 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
1395 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1396 let mut sink = std::mem::take(&mut self.load_warnings);
1402 sink.clear();
1403 let mut out = Vec::with_capacity(names.len());
1404 let mut loop_err = None;
1405 for name in names {
1406 match self.reload_one_mem_inner(&name, &mut sink) {
1407 Ok(result) => out.push((name, result)),
1408 Err(e) => {
1409 loop_err = Some(e);
1410 break;
1411 }
1412 }
1413 }
1414 self.load_warnings = sink;
1415 if let Some(e) = loop_err {
1416 return Err(e);
1417 }
1418 Ok(out)
1419 }
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424
1425 use tempfile::TempDir;
1426
1427 use crate::backend::{BackendError, MemBackend};
1428 use crate::engine::test_helpers::*;
1429 use crate::engine::{Engine, EngineError};
1430 use crate::mem::MemOrigin;
1431 use crate::ops::WarningHint;
1432 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1433
1434 #[test]
1435 fn reload_each_writable_mem_repopulates_load_warnings() {
1436 let tmp = TempDir::new().unwrap();
1440 let mem_dir = tmp.path().to_path_buf();
1441 let writer = FilesystemMemWriter::new(mem_dir.clone());
1442 let mut engine = Engine::from_mounts(vec![(
1443 folder_mount("specs", mem_dir.clone()),
1444 Box::new(writer) as Box<dyn MemBackend>,
1445 )])
1446 .unwrap();
1447 assert!(
1448 engine.load_warnings().is_empty(),
1449 "clean boot has no warnings"
1450 );
1451
1452 let body =
1454 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1455 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1456
1457 engine.reload_each_writable_mem().unwrap();
1458 let warnings = engine.load_warnings();
1459 assert!(
1460 warnings
1461 .iter()
1462 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1463 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
1464 );
1465 }
1466
1467 #[test]
1474 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
1475 let tmp = TempDir::new().unwrap();
1476 let mem_dir = tmp.path().to_path_buf();
1477 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1479 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
1480 let writer = FilesystemMemWriter::new(mem_dir.clone());
1481 let mut engine = Engine::from_mounts(vec![(
1482 folder_mount("specs", mem_dir.clone()),
1483 Box::new(writer) as Box<dyn MemBackend>,
1484 )])
1485 .unwrap();
1486 assert!(
1488 !engine
1489 .load_warnings()
1490 .iter()
1491 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
1492 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
1493 engine.load_warnings()
1494 );
1495
1496 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";
1498 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
1499
1500 engine.reload_each_writable_mem().unwrap();
1501
1502 let invalid: Vec<_> = engine
1503 .load_warnings()
1504 .iter()
1505 .filter_map(|w| match w {
1506 WarningHint::ParsedRelationInvalid {
1507 rel_type,
1508 reason,
1509 origin,
1510 ..
1511 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
1512 _ => None,
1513 })
1514 .collect();
1515 assert_eq!(
1516 invalid.len(),
1517 1,
1518 "reload must surface the parse-time drift, got: {invalid:?}",
1519 );
1520 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1521 assert_eq!(invalid[0].1, "unknown_rel_type");
1522 assert_eq!(invalid[0].2, "writable");
1523 }
1524
1525 #[test]
1526 fn reload_one_mem_keeps_engine_load_warnings_pristine() {
1527 let tmp = TempDir::new().unwrap();
1533 let mem_dir = tmp.path().to_path_buf();
1534 let body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
1535 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1536 let writer = FilesystemMemWriter::new(mem_dir.clone());
1537 let mut engine = Engine::from_mounts(vec![(
1538 folder_mount("specs", mem_dir.clone()),
1539 Box::new(writer) as Box<dyn MemBackend>,
1540 )])
1541 .unwrap();
1542 let pre = engine.load_warnings().to_vec();
1543 assert!(!pre.is_empty(), "boot must populate load_warnings");
1544
1545 engine.reload_one_mem("specs").unwrap();
1546 let post = engine.load_warnings();
1547 assert_eq!(
1549 post.len(),
1550 pre.len(),
1551 "single-mem reload must not touch sink"
1552 );
1553 }
1554
1555 #[test]
1563 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
1564 let tmp = TempDir::new().unwrap();
1565 let a_dir = tmp.path().join("a");
1566 let b_dir = tmp.path().join("b");
1567 std::fs::create_dir_all(&a_dir).unwrap();
1568 std::fs::create_dir_all(&b_dir).unwrap();
1569 let a_writer = FilesystemMemWriter::new(a_dir.clone());
1570 let b_writer = FilesystemMemWriter::new(b_dir.clone());
1571 let mut engine = Engine::from_mounts(vec![
1572 (
1573 folder_mount("specs", a_dir),
1574 Box::new(a_writer) as Box<dyn MemBackend>,
1575 ),
1576 (
1577 folder_mount("memos", b_dir),
1578 Box::new(b_writer) as Box<dyn MemBackend>,
1579 ),
1580 ])
1581 .unwrap();
1582
1583 let mut settings = crate::workspace::WorkspaceSettings::default();
1585 settings.cross_mem_links.insert(
1586 "specs".to_string(),
1587 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
1588 );
1589 engine.set_settings(settings);
1590
1591 let (actor, client) = cli_actor();
1592 let source = engine
1593 .create_entity(
1594 empty_create_args("specs", "Source"),
1595 actor,
1596 Some(&client),
1597 None,
1598 )
1599 .unwrap();
1600 let target = engine
1601 .create_entity(
1602 empty_create_args("memos", "Target"),
1603 actor,
1604 Some(&client),
1605 None,
1606 )
1607 .unwrap();
1608 engine
1609 .relate_entity(
1610 crate::engine::RelateEntityArgs {
1611 source: source.id.clone(),
1612 expected_hash: Some(source.content_hash.clone()),
1613 rel_type: "USES".to_string(),
1614 target: target.id.clone(),
1615 remove: false,
1616 description: None,
1617 },
1618 actor,
1619 Some(&client),
1620 None,
1621 )
1622 .unwrap();
1623
1624 let has_edge = |e: &Engine| {
1626 let out = e
1627 .store()
1628 .outgoing(&source.id)
1629 .iter()
1630 .any(|edge| edge.target == target.id);
1631 let inc = e
1632 .store()
1633 .incoming(&target.id)
1634 .iter()
1635 .any(|edge| edge.from == source.id);
1636 (out, inc)
1637 };
1638
1639 assert_eq!(
1640 has_edge(&engine),
1641 (true, true),
1642 "edge must be indexed in both directions after relate",
1643 );
1644
1645 engine.reload_one_mem("memos").unwrap();
1647 assert_eq!(
1648 has_edge(&engine),
1649 (true, true),
1650 "cross-mem edge into B must survive a per-mem reload of B",
1651 );
1652
1653 engine.reload_each_writable_mem().unwrap();
1656 assert_eq!(
1657 has_edge(&engine),
1658 (true, true),
1659 "per-mem and workspace reload converge on the same edge",
1660 );
1661
1662 assert!(
1665 engine
1666 .store()
1667 .get(&source.id)
1668 .unwrap()
1669 .relationships
1670 .iter()
1671 .any(|r| r.target == target.id),
1672 "source record must retain the relationship throughout",
1673 );
1674 }
1675
1676 #[test]
1682 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
1683 let tmp = TempDir::new().unwrap();
1684 let a_dir = tmp.path().join("a");
1685 let b_dir = tmp.path().join("b");
1686 std::fs::create_dir_all(&a_dir).unwrap();
1687 std::fs::create_dir_all(&b_dir).unwrap();
1688 let a_writer = FilesystemMemWriter::new(a_dir.clone());
1689 let b_writer = FilesystemMemWriter::new(b_dir.clone());
1690 let mut engine = Engine::from_mounts(vec![
1691 (
1692 folder_mount("specs", a_dir),
1693 Box::new(a_writer) as Box<dyn MemBackend>,
1694 ),
1695 (
1696 folder_mount("memos", b_dir),
1697 Box::new(b_writer) as Box<dyn MemBackend>,
1698 ),
1699 ])
1700 .unwrap();
1701 let mut settings = crate::workspace::WorkspaceSettings::default();
1702 settings.cross_mem_links.insert(
1703 "specs".to_string(),
1704 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
1705 );
1706 engine.set_settings(settings);
1707
1708 let (actor, client) = cli_actor();
1709 let source = engine
1710 .create_entity(
1711 empty_create_args("specs", "Source"),
1712 actor,
1713 Some(&client),
1714 None,
1715 )
1716 .unwrap();
1717 let target = engine
1718 .create_entity(
1719 empty_create_args("memos", "Target"),
1720 actor,
1721 Some(&client),
1722 None,
1723 )
1724 .unwrap();
1725 engine
1726 .relate_entity(
1727 crate::engine::RelateEntityArgs {
1728 source: source.id.clone(),
1729 expected_hash: Some(source.content_hash.clone()),
1730 rel_type: "USES".to_string(),
1731 target: target.id.clone(),
1732 remove: false,
1733 description: None,
1734 },
1735 actor,
1736 Some(&client),
1737 None,
1738 )
1739 .unwrap();
1740
1741 engine.reload_one_mem("specs").unwrap();
1742
1743 let out = engine
1744 .store()
1745 .outgoing(&source.id)
1746 .iter()
1747 .any(|edge| edge.target == target.id);
1748 let inc = engine
1749 .store()
1750 .incoming(&target.id)
1751 .iter()
1752 .any(|edge| edge.from == source.id);
1753 assert!(
1754 out && inc,
1755 "outgoing cross-mem edge must survive a source-mem reload"
1756 );
1757 }
1758
1759 #[test]
1760 fn workspace_root_setter_round_trips() {
1761 let tmp = TempDir::new().unwrap();
1762 let mem_dir = tmp.path().to_path_buf();
1763 let writer = FilesystemMemWriter::new(mem_dir.clone());
1764 let mut engine = Engine::from_mounts(vec![(
1765 folder_mount("specs", mem_dir),
1766 Box::new(writer) as Box<dyn MemBackend>,
1767 )])
1768 .unwrap();
1769 let root = tmp.path().to_path_buf();
1770 engine.set_workspace_root(root.clone());
1771 assert_eq!(engine.workspace_root(), Some(root.as_path()));
1772 }
1773
1774 #[test]
1775 fn export_mem_folder_backend_produces_archive() {
1776 let tmp = TempDir::new().unwrap();
1780 let mem_dir = tmp.path().join("specs");
1781 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1782 let config_body = r#"{
1783 "format": 1,
1784 "schema": "default@1.0.0",
1785 "version": "1.0.0"
1786 }"#;
1787 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
1788
1789 let writer = FilesystemMemWriter::new(mem_dir.clone());
1790 let engine = Engine::from_mounts(vec![(
1791 folder_mount("specs", mem_dir.clone()),
1792 Box::new(writer) as Box<dyn MemBackend>,
1793 )])
1794 .unwrap();
1795
1796 let archive_path = tmp.path().join("specs.mem");
1797 let result = engine.export_mem("specs", &archive_path).unwrap();
1798 assert!(archive_path.exists(), "archive must exist on disk");
1799 assert!(result.size_bytes > 0);
1800 assert_eq!(result.entity_count, 0);
1803 }
1804
1805 #[test]
1806 fn export_mem_unknown_mem_returns_unknown_mem() {
1807 let tmp = TempDir::new().unwrap();
1808 let mem_dir = tmp.path().to_path_buf();
1809 let writer = FilesystemMemWriter::new(mem_dir.clone());
1810 let engine = Engine::from_mounts(vec![(
1811 folder_mount("specs", mem_dir),
1812 Box::new(writer) as Box<dyn MemBackend>,
1813 )])
1814 .unwrap();
1815 let output = tmp.path().join("out.mem");
1816 let err = engine.export_mem("missing", &output).unwrap_err();
1817 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
1818 }
1819
1820 #[test]
1821 fn export_mem_missing_config_returns_invalid_input() {
1822 let tmp = TempDir::new().unwrap();
1826 let mem_dir = tmp.path().to_path_buf();
1827 let writer = FilesystemMemWriter::new(mem_dir.clone());
1828 let engine = Engine::from_mounts(vec![(
1829 folder_mount("specs", mem_dir),
1830 Box::new(writer) as Box<dyn MemBackend>,
1831 )])
1832 .unwrap();
1833 let output = tmp.path().join("out.mem");
1834 let err = engine.export_mem("specs", &output).unwrap_err();
1835 assert!(matches!(err, EngineError::InvalidInput(_)));
1836 }
1837
1838 #[test]
1839 fn export_mem_archive_backend_returns_sealed() {
1840 let tmp = TempDir::new().unwrap();
1843 let archive_path = build_archive(
1844 tmp.path(),
1845 "ext",
1846 &[(
1847 ".memstead/config.json",
1848 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
1849 )],
1850 );
1851 let engine = Engine::from_mounts(vec![(
1852 archive_mount("ext", archive_path.clone()),
1853 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1854 )])
1855 .unwrap();
1856 let output = tmp.path().join("out.mem");
1857 let err = engine.export_mem("ext", &output).unwrap_err();
1858 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
1859 }
1860
1861 #[test]
1862 fn export_markdown_writes_unchanged_files_zero_writes() {
1863 let tmp = TempDir::new().unwrap();
1868 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
1869 let result = engine.export_markdown(None, None).unwrap();
1870 assert_eq!(
1871 result.written, 0,
1872 "freshly-created entity's file already matches generated markdown"
1873 );
1874 assert_eq!(
1875 result.unchanged, 1,
1876 "the one seeded entity counts as unchanged"
1877 );
1878 assert!(
1879 result.skipped_mounts.is_empty(),
1880 "folder-only workspace has no skipped mounts"
1881 );
1882 }
1883
1884 #[test]
1885 fn export_markdown_skips_non_folder_mounts() {
1886 let tmp = TempDir::new().unwrap();
1890 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
1891 let engine = Engine::from_mounts(vec![(
1892 archive_mount("ext", archive_path.clone()),
1893 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1894 )])
1895 .unwrap();
1896 let result = engine.export_markdown(None, None).unwrap();
1897 assert_eq!(result.written, 0);
1898 assert_eq!(result.unchanged, 0);
1899 assert_eq!(
1900 result.skipped_mounts.len(),
1901 1,
1902 "archive mount is in the skipped list"
1903 );
1904 let entry = &result.skipped_mounts[0];
1905 assert_eq!(entry.mem, "ext");
1906 assert_eq!(entry.active_backend, "archive");
1907 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
1908 }
1909
1910 #[test]
1911 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
1912 let tmp = TempDir::new().unwrap();
1916 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
1917 let engine = Engine::from_mounts(vec![(
1918 archive_mount("ext", archive_path.clone()),
1919 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1920 )])
1921 .unwrap();
1922 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
1923 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
1924 let details = err.details();
1925 assert_eq!(details["mem"], "ext");
1926 assert_eq!(details["active_backend"], "archive");
1927 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
1928 }
1929
1930 #[test]
1931 fn register_writable_mem_adds_mount_and_router_entry() {
1932 let tmp = TempDir::new().unwrap();
1935 let mem_a = tmp.path().join("a");
1936 std::fs::create_dir_all(&mem_a).unwrap();
1937 let writer_a = FilesystemMemWriter::new(mem_a.clone());
1938
1939 let mut engine = Engine::from_mounts(vec![(
1940 folder_mount("alpha", mem_a),
1941 Box::new(writer_a) as Box<dyn MemBackend>,
1942 )])
1943 .unwrap();
1944 assert!(engine.mem_router().is_writable("alpha"));
1945
1946 let mem_b = tmp.path().join("b");
1947 std::fs::create_dir_all(&mem_b).unwrap();
1948 let writer_b = FilesystemMemWriter::new(mem_b.clone());
1949
1950 engine
1951 .register_writable_mem(
1952 folder_mount("beta", mem_b.clone()),
1953 Box::new(writer_b) as Box<dyn MemBackend>,
1954 MemOrigin::ExplicitToml,
1955 )
1956 .unwrap();
1957
1958 assert!(engine.mem_router().is_writable("alpha"));
1960 assert!(engine.mem_router().is_writable("beta"));
1961 assert!(engine.mem_router().is_visible("beta"));
1962
1963 assert!(engine.mount("beta").is_some());
1965 assert!(engine.schemas().contains_key("beta"));
1966
1967 assert_eq!(
1969 engine.mem_router().dir_for_mem("beta"),
1970 Some(mem_b.as_path()),
1971 );
1972 }
1973
1974 #[test]
1980 fn register_writable_mem_resolves_schema_from_mem_config() {
1981 let tmp = TempDir::new().unwrap();
1982 let mem_a = tmp.path().join("a");
1983 std::fs::create_dir_all(&mem_a).unwrap();
1984 let mut engine = Engine::from_mounts(vec![(
1985 folder_mount("alpha", mem_a.clone()),
1986 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
1987 )])
1988 .unwrap();
1989
1990 let mem_b = tmp.path().join("b");
1991 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
1992 std::fs::write(
1993 mem_b.join(".memstead").join("config.json"),
1994 r#"{"schema":"software@0.1.0"}"#,
1995 )
1996 .unwrap();
1997 let mount_b = crate::workspace::Mount {
1998 mem: "beta".to_string(),
1999 schema: Some(memstead_schema::SchemaRef::new(
2000 "totally-not-a-schema",
2001 semver::Version::new(9, 9, 9),
2002 )),
2003 storage: crate::workspace::MountStorage::Folder {
2004 path: mem_b.clone(),
2005 },
2006 capability: crate::workspace::MountCapability::Write,
2007 lifecycle: crate::workspace::MountLifecycle::Eager,
2008 cross_linkable: true,
2009 migration_target: None,
2010 };
2011 engine
2012 .register_writable_mem(
2013 mount_b,
2014 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
2015 MemOrigin::ExplicitToml,
2016 )
2017 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
2018
2019 assert!(engine.schemas().contains_key("beta"));
2020 let surfaced = engine.load_warnings().iter().any(|w| {
2021 matches!(
2022 w,
2023 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
2024 if mem == "beta"
2025 && config_pin == "software@0.1.0"
2026 && mount_pin == "totally-not-a-schema@9.9.9"
2027 )
2028 });
2029 assert!(
2030 surfaced,
2031 "SchemaPinMismatch must surface for beta: {:?}",
2032 engine.load_warnings(),
2033 );
2034 }
2035
2036 #[test]
2037 fn register_writable_mem_rejects_existing_name() {
2038 let tmp = TempDir::new().unwrap();
2041 let mem_a = tmp.path().join("a");
2042 std::fs::create_dir_all(&mem_a).unwrap();
2043 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2044
2045 let mut engine = Engine::from_mounts(vec![(
2046 folder_mount("alpha", mem_a),
2047 Box::new(writer_a) as Box<dyn MemBackend>,
2048 )])
2049 .unwrap();
2050 let mount_count_pre = engine.mounts().len();
2051
2052 let mem_collide = tmp.path().join("alpha-2");
2053 std::fs::create_dir_all(&mem_collide).unwrap();
2054 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
2055
2056 let err = engine
2057 .register_writable_mem(
2058 folder_mount("alpha", mem_collide),
2059 Box::new(writer_collide) as Box<dyn MemBackend>,
2060 MemOrigin::ExplicitToml,
2061 )
2062 .unwrap_err();
2063 match err {
2064 EngineError::MemNameCollision {
2065 name,
2066 source_origin,
2067 } => {
2068 assert_eq!(name, "alpha");
2069 assert!(
2074 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
2075 );
2076 }
2077 other => panic!("expected MemNameCollision, got {other:?}"),
2078 }
2079
2080 assert_eq!(engine.mounts().len(), mount_count_pre);
2082 }
2083
2084 #[test]
2085 fn register_writable_mem_loads_entities_into_store() {
2086 let tmp = TempDir::new().unwrap();
2089 let mem_a = tmp.path().join("a");
2090 std::fs::create_dir_all(&mem_a).unwrap();
2091 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2092
2093 let mut engine = Engine::from_mounts(vec![(
2094 folder_mount("alpha", mem_a),
2095 Box::new(writer_a) as Box<dyn MemBackend>,
2096 )])
2097 .unwrap();
2098 let pre_count = engine.store().all_entities().count();
2099
2100 let mem_b = tmp.path().join("b");
2102 std::fs::create_dir_all(&mem_b).unwrap();
2103 std::fs::write(
2104 mem_b.join("b1.md"),
2105 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
2106 )
2107 .unwrap();
2108 let writer_b = FilesystemMemWriter::new(mem_b.clone());
2109
2110 engine
2111 .register_writable_mem(
2112 folder_mount("beta", mem_b),
2113 Box::new(writer_b) as Box<dyn MemBackend>,
2114 MemOrigin::ExplicitToml,
2115 )
2116 .unwrap();
2117
2118 let post_count = engine.store().all_entities().count();
2119 assert!(post_count > pre_count, "register must load entities");
2120 let beta_count = engine
2121 .store()
2122 .all_entities()
2123 .filter(|e| e.mem == "beta")
2124 .count();
2125 assert_eq!(beta_count, 1);
2126 }
2127
2128 #[test]
2129 fn register_then_unregister_round_trips() {
2130 let tmp = TempDir::new().unwrap();
2134 let mem_a = tmp.path().join("a");
2135 std::fs::create_dir_all(&mem_a).unwrap();
2136 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2137
2138 let mut engine = Engine::from_mounts(vec![(
2139 folder_mount("alpha", mem_a),
2140 Box::new(writer_a) as Box<dyn MemBackend>,
2141 )])
2142 .unwrap();
2143 let pre_mounts = engine.mounts().len();
2144
2145 let mem_b = tmp.path().join("b");
2146 std::fs::create_dir_all(&mem_b).unwrap();
2147 let writer_b = FilesystemMemWriter::new(mem_b);
2148
2149 engine
2150 .register_writable_mem(
2151 folder_mount("beta", tmp.path().join("b")),
2152 Box::new(writer_b) as Box<dyn MemBackend>,
2153 MemOrigin::ExplicitToml,
2154 )
2155 .unwrap();
2156 assert_eq!(engine.mounts().len(), pre_mounts + 1);
2157
2158 let removed = engine.unregister_writable_mem("beta").unwrap();
2159 assert!(removed.is_some());
2160 assert_eq!(engine.mounts().len(), pre_mounts);
2161 assert!(!engine.mem_router().is_writable("beta"));
2162 }
2163
2164 #[test]
2165 fn unregister_writable_mem_returns_false_for_unknown_name() {
2166 let tmp = TempDir::new().unwrap();
2170 let mem_dir = tmp.path().to_path_buf();
2171 let writer = FilesystemMemWriter::new(mem_dir.clone());
2172 let mut engine = Engine::from_mounts(vec![(
2173 folder_mount("specs", mem_dir),
2174 Box::new(writer) as Box<dyn MemBackend>,
2175 )])
2176 .unwrap();
2177 let removed = engine.unregister_writable_mem("missing").unwrap();
2178 assert!(removed.is_none(), "unknown mem returns Ok(None)");
2179 assert!(engine.mem_router().is_writable("specs"));
2181 }
2182
2183 #[test]
2184 fn unregister_writable_mem_drops_mount_and_router_entry() {
2185 let tmp = TempDir::new().unwrap();
2190 let mem_a = tmp.path().join("a");
2191 std::fs::create_dir_all(&mem_a).unwrap();
2192 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2193 let mem_b = tmp.path().join("b");
2194 std::fs::create_dir_all(&mem_b).unwrap();
2195 let writer_b = FilesystemMemWriter::new(mem_b.clone());
2196
2197 let mut engine = Engine::from_mounts(vec![
2198 (
2199 folder_mount("alpha", mem_a),
2200 Box::new(writer_a) as Box<dyn MemBackend>,
2201 ),
2202 (
2203 folder_mount("beta", mem_b),
2204 Box::new(writer_b) as Box<dyn MemBackend>,
2205 ),
2206 ])
2207 .unwrap();
2208
2209 let removed = engine.unregister_writable_mem("alpha").unwrap();
2210 assert!(removed.is_some());
2211
2212 assert!(!engine.mem_router().is_writable("alpha"));
2214 assert!(!engine.mem_router().is_visible("alpha"));
2215 assert!(engine.mount("alpha").is_none());
2216
2217 assert!(engine.mem_router().is_writable("beta"));
2219 assert!(engine.mount("beta").is_some());
2220 }
2221
2222 #[test]
2223 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
2224 let tmp = TempDir::new().unwrap();
2228 let mem_a = tmp.path().join("a");
2229 std::fs::create_dir_all(&mem_a).unwrap();
2230 std::fs::write(
2231 mem_a.join("a1.md"),
2232 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
2233 )
2234 .unwrap();
2235 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2236
2237 let mem_b = tmp.path().join("b");
2238 std::fs::create_dir_all(&mem_b).unwrap();
2239 std::fs::write(
2240 mem_b.join("b1.md"),
2241 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
2242 )
2243 .unwrap();
2244 let writer_b = FilesystemMemWriter::new(mem_b.clone());
2245
2246 let mut engine = Engine::from_mounts(vec![
2247 (
2248 folder_mount("alpha", mem_a),
2249 Box::new(writer_a) as Box<dyn MemBackend>,
2250 ),
2251 (
2252 folder_mount("beta", mem_b),
2253 Box::new(writer_b) as Box<dyn MemBackend>,
2254 ),
2255 ])
2256 .unwrap();
2257
2258 let pre_total = engine.store().all_entities().count();
2259 assert!(pre_total >= 2, "both mems must load entities");
2260
2261 engine.unregister_writable_mem("alpha").unwrap();
2262
2263 let alpha_remaining = engine
2265 .store()
2266 .all_entities()
2267 .filter(|e| e.mem == "alpha")
2268 .count();
2269 assert_eq!(alpha_remaining, 0);
2270
2271 let beta_remaining = engine
2273 .store()
2274 .all_entities()
2275 .filter(|e| e.mem == "beta")
2276 .count();
2277 assert!(beta_remaining > 0, "beta entities must survive");
2278 }
2279 #[test]
2280 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
2281 let tmp = TempDir::new().unwrap();
2282 let mut engine = build_demo_engine(&tmp);
2283 let result = engine
2284 .reload_one_mem("specs")
2285 .expect("reload on stable disk must succeed");
2286 assert!(result.added.is_empty(), "added: {:?}", result.added);
2287 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
2288 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
2289 }
2290
2291 #[test]
2292 fn reload_one_mem_picks_up_external_addition() {
2293 let tmp = TempDir::new().unwrap();
2294 let mut engine = build_demo_engine(&tmp);
2295 std::fs::write(
2298 tmp.path().join("external.md"),
2299 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
2300 )
2301 .unwrap();
2302 let result = engine.reload_one_mem("specs").unwrap();
2303 assert_eq!(
2304 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
2305 vec!["specs--external"]
2306 );
2307 assert!(result.changed.is_empty());
2308 assert!(result.removed.is_empty());
2309 assert!(
2311 engine
2312 .get_entity(&crate::EntityId::new("specs", "external"))
2313 .is_some()
2314 );
2315 }
2316
2317 #[test]
2318 fn reload_one_mem_picks_up_external_removal() {
2319 let tmp = TempDir::new().unwrap();
2320 let mut engine = build_demo_engine(&tmp);
2321 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2324 let result = engine.reload_one_mem("specs").unwrap();
2325 assert!(result.added.is_empty());
2326 assert!(result.changed.is_empty());
2327 assert_eq!(
2328 result
2329 .removed
2330 .iter()
2331 .map(|i| i.as_ref())
2332 .collect::<Vec<_>>(),
2333 vec!["specs--lonely-three"]
2334 );
2335 }
2336
2337 #[test]
2338 fn reload_one_mem_picks_up_external_change() {
2339 let tmp = TempDir::new().unwrap();
2340 let mut engine = build_demo_engine(&tmp);
2341 std::fs::write(
2344 tmp.path().join("source-one.md"),
2345 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
2346 )
2347 .unwrap();
2348 let result = engine.reload_one_mem("specs").unwrap();
2349 assert!(result.added.is_empty());
2350 assert_eq!(
2351 result
2352 .changed
2353 .iter()
2354 .map(|i| i.as_ref())
2355 .collect::<Vec<_>>(),
2356 vec!["specs--source-one"]
2357 );
2358 assert!(result.removed.is_empty());
2359 }
2360
2361 #[test]
2362 fn reload_one_mem_rejects_unknown_mem() {
2363 let tmp = TempDir::new().unwrap();
2364 let mut engine = build_demo_engine(&tmp);
2365 let err = engine.reload_one_mem("nope").unwrap_err();
2366 match err {
2367 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
2368 other => panic!("expected UnknownMem, got {other:?}"),
2369 }
2370 }
2371
2372 #[test]
2373 fn reload_each_writable_mem_returns_one_entry_per_mount() {
2374 let tmp = TempDir::new().unwrap();
2375 let mut engine = build_demo_engine(&tmp);
2376 let reports = engine
2377 .reload_each_writable_mem()
2378 .expect("batch reload on stable disk must succeed");
2379 assert_eq!(reports.len(), 1);
2380 assert_eq!(reports[0].0, "specs");
2381 assert!(reports[0].1.added.is_empty());
2382 assert!(reports[0].1.changed.is_empty());
2383 assert!(reports[0].1.removed.is_empty());
2384 }
2385
2386 #[test]
2389 fn settings_default_to_empty_on_fresh_engine() {
2390 let tmp = TempDir::new().unwrap();
2391 let engine = build_demo_engine(&tmp);
2392 let s = engine.settings();
2393 assert!(s.mem_create_rules.is_empty());
2394 assert!(s.mem_delete_rules.is_empty());
2395 assert!(s.cross_mem_links.is_empty());
2396 }
2397
2398 #[test]
2399 fn set_settings_replaces_workspace_policy() {
2400 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
2401 let tmp = TempDir::new().unwrap();
2402 let mut engine = build_demo_engine(&tmp);
2403 let mut settings = WorkspaceSettings::default();
2404 settings.mem_create_rules.push(CreateRuleSetting {
2405 pattern: "exec-*".to_string(),
2406 schemas: vec!["default@1.0.0".to_string()],
2407 default_cross_links: None,
2408 });
2409 settings.mem_delete_rules.push(DeleteRuleSetting {
2410 pattern: "exec-*".to_string(),
2411 });
2412 engine.set_settings(settings);
2413 assert_eq!(engine.settings().mem_create_rules.len(), 1);
2414 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
2415 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
2416 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
2417 }
2418
2419 #[test]
2422 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
2423 let tmp = TempDir::new().unwrap();
2424 let mut engine = build_demo_engine(&tmp);
2425 std::fs::write(
2427 tmp.path().join("new-via-disk.md"),
2428 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
2429 )
2430 .unwrap();
2431 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2432 std::fs::write(
2433 tmp.path().join("source-one.md"),
2434 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
2435 )
2436 .unwrap();
2437
2438 let reports = engine.reload_each_writable_mem().unwrap();
2439 assert_eq!(reports.len(), 1);
2440 let (mem, result) = &reports[0];
2441 assert_eq!(mem, "specs");
2442 assert_eq!(
2443 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
2444 vec!["specs--new-via-disk"]
2445 );
2446 assert_eq!(
2447 result
2448 .removed
2449 .iter()
2450 .map(|i| i.as_ref())
2451 .collect::<Vec<_>>(),
2452 vec!["specs--lonely-three"]
2453 );
2454 assert_eq!(
2455 result
2456 .changed
2457 .iter()
2458 .map(|i| i.as_ref())
2459 .collect::<Vec<_>>(),
2460 vec!["specs--source-one"]
2461 );
2462 }
2463
2464 #[test]
2467 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
2468 let tmp = TempDir::new().unwrap();
2473 let mut engine = build_demo_engine(&tmp);
2474 let report = engine.reload_one_mem_report("specs").unwrap();
2475 assert_eq!(report.mem, "specs");
2476 assert_eq!(report.head_before, crate::ops::EMPTY_TREE_SHA);
2477 assert_eq!(report.head_after, crate::ops::EMPTY_TREE_SHA);
2478 assert_eq!(report.entities_loaded, 3);
2481 assert!(report.changed_entity_ids.is_empty());
2483 }
2484
2485 #[test]
2486 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
2487 let tmp = TempDir::new().unwrap();
2492 let mut engine = build_demo_engine(&tmp);
2493 std::fs::write(
2494 tmp.path().join("new-via-disk.md"),
2495 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
2496 )
2497 .unwrap();
2498 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2499 std::fs::write(
2500 tmp.path().join("source-one.md"),
2501 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
2502 )
2503 .unwrap();
2504
2505 let report = engine.reload_one_mem_report("specs").unwrap();
2506 assert_eq!(report.mem, "specs");
2507 let ids: Vec<&str> = report
2508 .changed_entity_ids
2509 .iter()
2510 .map(|id| id.as_ref())
2511 .collect();
2512 assert_eq!(
2514 ids,
2515 vec![
2516 "specs--lonely-three",
2517 "specs--new-via-disk",
2518 "specs--source-one",
2519 ]
2520 );
2521 }
2522
2523 #[test]
2524 fn reload_one_mem_report_rejects_unknown_mem() {
2525 let tmp = TempDir::new().unwrap();
2526 let mut engine = build_demo_engine(&tmp);
2527 let err = engine.reload_one_mem_report("missing").unwrap_err();
2528 assert!(matches!(err, EngineError::UnknownMem(_)));
2529 }
2530
2531 #[test]
2532 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
2533 let tmp = TempDir::new().unwrap();
2534 let mut engine = build_demo_engine(&tmp);
2535 let reports = engine.reload_each_writable_mem_reports().unwrap();
2536 assert_eq!(reports.len(), 1);
2537 assert_eq!(reports[0].mem, "specs");
2538 assert_eq!(reports[0].entities_loaded, 3);
2539 }
2540
2541 #[test]
2548 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
2549 let tmp = TempDir::new().unwrap();
2550
2551 let memstead_dir = tmp.path().join(".memstead");
2554 std::fs::create_dir_all(&memstead_dir).unwrap();
2555 let workspace_toml = memstead_dir.join("workspace.toml");
2556 std::fs::write(
2557 &workspace_toml,
2558 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2559 )
2560 .unwrap();
2561 let mounts_json = memstead_dir.join("state").join("mounts.json");
2562 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
2563 let mem_dir = tmp.path().join("specs");
2564 std::fs::create_dir_all(&mem_dir).unwrap();
2565 let mounts_body = format!(
2566 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
2567 mem_dir.display(),
2568 );
2569 std::fs::write(&mounts_json, mounts_body).unwrap();
2570
2571 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2572 assert!(
2573 engine.settings().mem_create_rules.is_empty(),
2574 "boot-time settings carry no create rules"
2575 );
2576
2577 std::fs::write(
2579 &workspace_toml,
2580 "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",
2581 )
2582 .unwrap();
2583
2584 engine.reload_each_writable_mem_reports().unwrap();
2585
2586 let rules = &engine.settings().mem_create_rules;
2587 assert_eq!(
2588 rules.len(),
2589 1,
2590 "workspace-wide reload must refresh the policy"
2591 );
2592 assert_eq!(rules[0].pattern, "exec-*");
2593 }
2594
2595 const MIG_TYPE_TAIL: &str = r#"sections:
2600 - key: body
2601 heading: Body
2602 required: true
2603 search_weight: 10.0
2604 catch_all: true
2605 write_rules: []
2606title_weight: 100.0
2607text_fields:
2608 - body
2609hierarchy_relationship: _default
2610propagating_relationships: []
2611updatable_fields: []
2612health_required_fields: []
2613staleness_threshold_days: 90
2614write_rules: []
2615"#;
2616
2617 fn mig_manifest(name: &str, version: &str) -> String {
2622 format!(
2623 r#"name: {name}
2624version: {version}
2625description: migration test schema
2626when_to_use: tests
2627types:
2628 - doc
2629relationships:
2630 mode: strict
2631 definitions:
2632 - name: USES
2633 description: link
2634 default_weight: 1.0
2635 - name: _default
2636 description: fallback
2637 default_weight: 1.0
2638community:
2639 resolution: 1.0
2640 seed: 42
2641"#
2642 )
2643 }
2644
2645 fn mig_type_yaml(with_status: bool) -> String {
2646 let metadata = if with_status {
2647 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n enum_values:\n - open\n - closed\n"
2648 } else {
2649 "metadata_fields: []\n"
2650 };
2651 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
2652 }
2653
2654 fn write_mig_schema(
2655 root: &std::path::Path,
2656 dir: &str,
2657 name: &str,
2658 version: &str,
2659 with_status: bool,
2660 ) {
2661 let d = root.join(dir);
2662 std::fs::create_dir_all(d.join("types")).unwrap();
2663 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
2664 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
2665 }
2666
2667 fn migration_engine() -> (tempfile::TempDir, Engine) {
2672 let tmp = tempfile::TempDir::new().unwrap();
2673 let schemas_dir = tmp.path().join("schemas");
2674 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
2675 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
2676 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
2677 let mem_dir = tmp.path().join("mem");
2678 std::fs::create_dir_all(&mem_dir).unwrap();
2679 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2680 let mut mount = folder_mount("specs", mem_dir);
2681 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2682 let mut engine = Engine::from_mounts_with_schemas_dir(
2683 vec![(
2684 mount,
2685 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2686 )],
2687 Some(&schemas_dir),
2688 )
2689 .unwrap();
2690 for title in ["One", "Two"] {
2691 let mut args = empty_create_args("specs", title);
2692 args.entity_type = "doc".to_string();
2693 args.sections =
2694 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
2695 engine
2696 .create_entity(args, crate::vcs::Actor::Cli, None, None)
2697 .expect("conformant create under mig-a");
2698 }
2699 (tmp, engine)
2700 }
2701
2702 fn sref(s: &str) -> memstead_schema::SchemaRef {
2703 s.parse().unwrap()
2704 }
2705
2706 #[test]
2707 fn set_schema_noop_on_current_pin() {
2708 let (_tmp, mut engine) = migration_engine();
2709 let out = engine
2710 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
2711 .unwrap();
2712 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
2713 assert_eq!(out.schema_pin, "mig-a@0.1.0");
2714 assert_eq!(out.migration_target, None);
2715 assert!(out.findings.is_empty());
2716 }
2717
2718 #[test]
2719 fn set_schema_switches_immediately_when_integral() {
2720 let (_tmp, mut engine) = migration_engine();
2723 let out = engine
2724 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
2725 .unwrap();
2726 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2727 assert_eq!(out.schema_pin, "mig-a@0.2.0");
2728 assert_eq!(out.migration_target, None);
2729 assert!(out.findings.is_empty());
2730 assert_eq!(
2731 engine.schema_pin("specs").unwrap().as_display(),
2732 "mig-a@0.2.0"
2733 );
2734 assert!(engine.migration_target("specs").is_none());
2735 }
2736
2737 #[test]
2743 fn set_schema_switch_persists_pin_into_backend_config() {
2744 let tmp = tempfile::TempDir::new().unwrap();
2745 let schemas_dir = tmp.path().join("schemas");
2746 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
2747 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
2748 let mem_dir = tmp.path().join("mem");
2749 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2750 std::fs::write(
2752 mem_dir.join(".memstead").join("config.json"),
2753 br#"{"schema":"mig-a@0.1.0"}"#,
2754 )
2755 .unwrap();
2756 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2757 let mut mount = folder_mount("specs", mem_dir.clone());
2758 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2759 let mut engine = Engine::from_mounts_with_schemas_dir(
2760 vec![(
2761 mount,
2762 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2763 )],
2764 Some(&schemas_dir),
2765 )
2766 .unwrap();
2767
2768 let out = engine
2769 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
2770 .unwrap();
2771 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2772
2773 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
2776 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
2777 assert_eq!(
2778 cfg["schema"], "mig-a@0.2.0",
2779 "atomic switch must update the authoritative backend config"
2780 );
2781 }
2782
2783 #[test]
2784 fn set_schema_unknown_target_refuses_schema_not_found() {
2785 let (_tmp, mut engine) = migration_engine();
2786 let err = engine
2787 .set_mem_schema("specs", &sref("nope@9.9.9"))
2788 .unwrap_err();
2789 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
2790 assert!(engine.migration_target("specs").is_none());
2792 }
2793
2794 #[test]
2795 fn set_schema_migration_lifecycle_end_to_end() {
2796 let (_tmp, mut engine) = migration_engine();
2797 let target = sref("mig-b@0.1.0");
2798
2799 let out = engine.set_mem_schema("specs", &target).unwrap();
2801 assert_eq!(
2802 out.outcome,
2803 crate::engine::SetSchemaResult::MigrationStarted
2804 );
2805 assert_eq!(out.schema_pin, "mig-a@0.1.0");
2806 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
2807 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
2808 assert!(
2809 out.findings
2810 .iter()
2811 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
2812 );
2813
2814 let one = crate::entity::EntityId::new("specs", "one");
2816 assert!(engine.store().get(&one).is_some());
2817
2818 let out = engine.set_mem_schema("specs", &target).unwrap();
2820 assert_eq!(
2821 out.outcome,
2822 crate::engine::SetSchemaResult::MigrationPending
2823 );
2824 assert_eq!(out.findings.len(), 2);
2825
2826 let mut bad = crate::engine::UpdateEntityArgs {
2830 id: one.clone(),
2831 expected_hash: None,
2832 sections: indexmap::IndexMap::new(),
2833 append_sections: indexmap::IndexMap::new(),
2834 patch_sections: indexmap::IndexMap::new(),
2835 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
2836 metadata_unset: Vec::new(),
2837 declare_relations: Vec::new(),
2838 dry_run: false,
2839 relations_unset: Vec::new(),
2840 };
2841 let err = engine
2842 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
2843 .unwrap_err();
2844 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
2845 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
2846 engine
2847 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
2848 .expect("repair write validated against the migration target");
2849
2850 let out = engine.set_mem_schema("specs", &target).unwrap();
2852 assert_eq!(
2853 out.outcome,
2854 crate::engine::SetSchemaResult::MigrationPending
2855 );
2856 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
2857
2858 let two = crate::entity::EntityId::new("specs", "two");
2860 let repair = crate::engine::UpdateEntityArgs {
2861 id: two.clone(),
2862 expected_hash: None,
2863 sections: indexmap::IndexMap::new(),
2864 append_sections: indexmap::IndexMap::new(),
2865 patch_sections: indexmap::IndexMap::new(),
2866 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
2867 metadata_unset: Vec::new(),
2868 declare_relations: Vec::new(),
2869 dry_run: false,
2870 relations_unset: Vec::new(),
2871 };
2872 engine
2873 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
2874 .unwrap();
2875 let out = engine.set_mem_schema("specs", &target).unwrap();
2876 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2877 assert_eq!(out.schema_pin, "mig-b@0.1.0");
2878 assert_eq!(out.migration_target, None);
2879 assert!(out.findings.is_empty());
2880 assert_eq!(
2881 engine.schema_pin("specs").unwrap().as_display(),
2882 "mig-b@0.1.0"
2883 );
2884 assert!(engine.migration_target("specs").is_none());
2885 }
2886
2887 #[test]
2892 fn relations_unset_works_during_migration_without_mode_flag() {
2893 let (_tmp, mut engine) = migration_engine();
2894 let one = crate::entity::EntityId::new("specs", "one");
2895 let two = crate::entity::EntityId::new("specs", "two");
2896 engine
2897 .relate_entity(
2898 crate::engine::RelateEntityArgs {
2899 source: one.clone(),
2900 expected_hash: None,
2901 rel_type: "USES".to_string(),
2902 target: two.clone(),
2903 remove: false,
2904 description: None,
2905 },
2906 crate::vcs::Actor::Cli,
2907 None,
2908 None,
2909 )
2910 .unwrap();
2911 let shut = engine
2913 .update_entity(
2914 crate::engine::UpdateEntityArgs {
2915 id: one.clone(),
2916 expected_hash: None,
2917 sections: indexmap::IndexMap::new(),
2918 append_sections: indexmap::IndexMap::new(),
2919 patch_sections: indexmap::IndexMap::new(),
2920 metadata: indexmap::IndexMap::new(),
2921 metadata_unset: Vec::new(),
2922 declare_relations: Vec::new(),
2923 dry_run: false,
2924 relations_unset: vec![crate::ops::RelationUnsetArg {
2925 rel_type: "USES".to_string(),
2926 target: two.clone(),
2927 }],
2928 },
2929 crate::vcs::Actor::Cli,
2930 None,
2931 None,
2932 )
2933 .unwrap_err();
2934 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
2935
2936 engine
2940 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
2941 .unwrap();
2942 engine
2943 .update_entity(
2944 crate::engine::UpdateEntityArgs {
2945 id: one.clone(),
2946 expected_hash: None,
2947 sections: indexmap::IndexMap::new(),
2948 append_sections: indexmap::IndexMap::new(),
2949 patch_sections: indexmap::IndexMap::new(),
2950 metadata: indexmap::IndexMap::from_iter([(
2951 "status".to_string(),
2952 "open".to_string(),
2953 )]),
2954 metadata_unset: Vec::new(),
2955 declare_relations: Vec::new(),
2956 dry_run: false,
2957 relations_unset: vec![crate::ops::RelationUnsetArg {
2958 rel_type: "USES".to_string(),
2959 target: two.clone(),
2960 }],
2961 },
2962 crate::vcs::Actor::Cli,
2963 None,
2964 None,
2965 )
2966 .expect("repair-shaped update lands during migration without a flag");
2967 let entity = engine.store().get(&one).unwrap();
2968 assert!(entity.relationships.is_empty());
2969 }
2970
2971 #[test]
2976 fn boot_resumes_dual_pin_validation_against_target() {
2977 let (tmp, engine) = migration_engine();
2978 drop(engine);
2979 let schemas_dir = tmp.path().join("schemas");
2980 let mem_dir = tmp.path().join("mem");
2981 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2982 let mut mount = folder_mount("specs", mem_dir);
2983 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2984 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
2985 let engine = Engine::from_mounts_with_schemas_dir(
2986 vec![(
2987 mount,
2988 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2989 )],
2990 Some(&schemas_dir),
2991 )
2992 .unwrap();
2993 let (name, version) = {
2995 let s = engine.schema_for("specs").unwrap();
2996 let (n, v) = s.id();
2997 (n.to_string(), v.to_string())
2998 };
2999 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
3000 assert_eq!(
3003 engine.schema_pin("specs").unwrap().as_display(),
3004 "mig-a@0.1.0"
3005 );
3006 assert_eq!(
3007 engine.migration_target("specs").unwrap().as_display(),
3008 "mig-b@0.1.0"
3009 );
3010 }
3011}