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 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
788 (hook.export)(
789 gitdir,
790 branch,
791 mem_name,
792 config,
793 output_path,
794 workspace_root,
795 workspace_schemas_dir,
796 provenance_bytes.as_deref(),
797 anchors_bytes.as_deref(),
798 )
799 .map_err(EngineError::Backend)
800 }
801 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
802 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
807 "export not yet supported for in-memory backend".to_string(),
808 ))),
809 }
810 }
811
812 pub fn record_pipeline_edit_provenance(
836 &self,
837 mem: &str,
838 kind: &str,
839 edits: &[(String, Option<Vec<u8>>)],
840 note: Option<&str>,
841 verb: &str,
842 ) -> Result<(), crate::backend::BackendError> {
843 match self.mounts.iter().find(|m| m.mount.mem == mem) {
844 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
845 None => Ok(()),
846 }
847 }
848
849 pub fn set_mem_version(
850 &mut self,
851 mem_name: &str,
852 new_version: semver::Version,
853 note: Option<&str>,
854 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
855 let mount_idx = self
858 .mounts
859 .iter()
860 .position(|m| m.mount.mem == mem_name)
861 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
862 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
863 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
864 }
865
866 let mut warnings = self.reload_if_stale(Some(mem_name));
874 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
879 warnings.push(w);
880 }
881
882 let mounted = &mut self.mounts[mount_idx];
883 let mut config = mounted.mem_config.clone().ok_or_else(|| {
884 EngineError::InvalidInput(format!(
885 "mem '{mem_name}' has no loaded MemConfig — \
886 cannot set version (initialize the mem via `memstead init` \
887 or `memstead mem create` first)"
888 ))
889 })?;
890 let old_version = config.version.clone();
891 config.version = Some(new_version.clone());
892
893 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
894 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
895 })?;
896 bytes.push(b'\n');
897 mounted.backend.write_mem_config_with_note(&bytes, note)?;
898 mounted.mem_config = Some(config);
899
900 let new_head = mounted.backend.current_head().ok().flatten();
906 if let Some(sha) = new_head {
907 mounted.last_known_head = Some(sha);
908 }
909
910 Ok(crate::ops::SetMemVersionOutcome {
911 mem: mem_name.to_string(),
912 old_version,
913 new_version,
914 warnings,
915 })
916 }
917
918 pub fn set_mem_description(
925 &mut self,
926 mem_name: &str,
927 new_description: Option<String>,
928 note: Option<&str>,
929 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
930 let mount_idx = self
931 .mounts
932 .iter()
933 .position(|m| m.mount.mem == mem_name)
934 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
935 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
936 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
937 }
938
939 let mut warnings = self.reload_if_stale(Some(mem_name));
940 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
941 warnings.push(w);
942 }
943
944 let mounted = &mut self.mounts[mount_idx];
945 let mut config = mounted.mem_config.clone().ok_or_else(|| {
946 EngineError::InvalidInput(format!(
947 "mem '{mem_name}' has no loaded MemConfig — \
948 cannot set description (initialize the mem via `memstead init` \
949 or `memstead mem create` first)"
950 ))
951 })?;
952 let old_description = config.description.clone();
953 config.description = new_description.clone();
954
955 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
956 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
957 })?;
958 bytes.push(b'\n');
959 mounted.backend.write_mem_config_with_note(&bytes, note)?;
960 mounted.mem_config = Some(config);
961
962 let new_head = mounted.backend.current_head().ok().flatten();
963 if let Some(sha) = new_head {
964 mounted.last_known_head = Some(sha);
965 }
966
967 Ok(crate::ops::SetMemDescriptionOutcome {
968 mem: mem_name.to_string(),
969 old_description,
970 new_description,
971 warnings,
972 })
973 }
974
975 pub fn set_mem_internal(
987 &mut self,
988 mem_name: &str,
989 internal: bool,
990 note: Option<&str>,
991 ) -> Result<bool, EngineError> {
992 let mount_idx = self
993 .mounts
994 .iter()
995 .position(|m| m.mount.mem == mem_name)
996 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
997 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
998 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
999 }
1000
1001 let _ = self.reload_if_stale(Some(mem_name));
1002
1003 let mounted = &mut self.mounts[mount_idx];
1004 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1005 EngineError::InvalidInput(format!(
1006 "mem '{mem_name}' has no loaded MemConfig — initialize the mem first"
1007 ))
1008 })?;
1009 if internal {
1010 config
1011 .extra
1012 .insert("internal".to_string(), serde_json::Value::Bool(true));
1013 } else {
1014 config.extra.remove("internal");
1015 }
1016
1017 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1018 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1019 })?;
1020 bytes.push(b'\n');
1021 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1022 mounted.mem_config = Some(config);
1023
1024 let new_head = mounted.backend.current_head().ok().flatten();
1025 if let Some(sha) = new_head {
1026 mounted.last_known_head = Some(sha);
1027 }
1028
1029 Ok(internal)
1030 }
1031
1032 pub fn set_mem_sync_state(
1057 &mut self,
1058 mem_name: &str,
1059 key: &str,
1060 token: &str,
1061 note: Option<&str>,
1062 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
1063 let mount_idx = self
1066 .mounts
1067 .iter()
1068 .position(|m| m.mount.mem == mem_name)
1069 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
1070 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1071 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1072 }
1073
1074 let mut warnings = self.reload_if_stale(Some(mem_name));
1078 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
1079 warnings.push(w);
1080 }
1081
1082 let mounted = &mut self.mounts[mount_idx];
1083 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1084 EngineError::InvalidInput(format!(
1085 "mem '{mem_name}' has no loaded MemConfig — \
1086 cannot set sync state (initialize the mem via `memstead init` \
1087 or `memstead mem create` first)"
1088 ))
1089 })?;
1090
1091 let removed;
1095 let previous;
1096 if token.is_empty() {
1097 previous = config.sync_state.remove(key);
1098 removed = previous.is_some();
1099 } else {
1100 previous = config.sync_state.insert(key.to_string(), token.to_string());
1101 removed = false;
1102 }
1103
1104 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1105 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1106 })?;
1107 bytes.push(b'\n');
1108 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1109 mounted.mem_config = Some(config);
1110
1111 let new_head = mounted.backend.current_head().ok().flatten();
1114 if let Some(sha) = new_head {
1115 mounted.last_known_head = Some(sha);
1116 }
1117
1118 Ok(crate::ops::SetMemSyncStateOutcome {
1119 mem: mem_name.to_string(),
1120 key: key.to_string(),
1121 previous,
1122 removed,
1123 warnings,
1124 })
1125 }
1126
1127 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
1147 let mut sink: Vec<WarningHint> = Vec::new();
1154 self.reload_one_mem_inner(mem, &mut sink)
1155 }
1156
1157 fn reload_one_mem_inner(
1163 &mut self,
1164 mem: &str,
1165 warnings_sink: &mut Vec<WarningHint>,
1166 ) -> Result<crate::ops::ReloadResult, EngineError> {
1167 let mount_idx = self
1170 .mounts
1171 .iter()
1172 .position(|m| m.mount.mem == mem)
1173 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
1174 let schema = self
1175 .schemas
1176 .get(mem)
1177 .cloned()
1178 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
1179
1180 let pre: HashMap<EntityId, String> = self
1182 .store
1183 .all_entities()
1184 .filter(|e| !e.stub && e.mem == mem)
1185 .map(|e| (e.id.clone(), e.content_hash.clone()))
1186 .collect();
1187 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
1188
1189 let backend = self.mounts[mount_idx].backend.as_ref();
1192 let (entries, read_errors) = collect_source_entries(backend)?;
1193 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
1194
1195 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1199 let known_suffixes: Vec<String> = mem_names
1200 .iter()
1201 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
1202 .collect();
1203
1204 self.store.remove_entities_by_mem(mem);
1206 let fallback = engine_fallback_type();
1207 push_entities_into_store(
1208 &mut self.store,
1209 load_result.entities,
1210 fallback.as_ref(),
1211 Some(crate::entity::store_builder::LoadCollector {
1212 warnings: warnings_sink,
1213 known_suffixes: &known_suffixes,
1214 mem_names: &mem_names,
1215 }),
1216 );
1217 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
1224 .mounts
1225 .iter()
1226 .map(|m| (m.mount.mem.clone(), m.mount.capability))
1227 .collect();
1228 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
1236 crate::entity::store_builder::validate_loaded_relations(
1237 &mut self.store,
1238 &self.schemas,
1239 &mount_caps,
1240 warnings_sink,
1241 );
1242 crate::entity::store_builder::remap_alias_target_edge_sources(
1243 &mut self.store,
1244 &self.schemas,
1245 );
1246 self.load_errors.extend(load_result.errors);
1252
1253 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
1261 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
1262 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1263 {
1264 self.mounts[mount_idx].mem_config = Some(cfg);
1265 }
1266
1267 let mut added: Vec<EntityId> = Vec::new();
1269 let mut changed: Vec<EntityId> = Vec::new();
1270 for entity in self.store.all_entities() {
1271 if entity.stub || entity.mem != mem {
1272 continue;
1273 }
1274 match pre.get(&entity.id) {
1275 None => added.push(entity.id.clone()),
1276 Some(prev_hash) if prev_hash != &entity.content_hash => {
1277 changed.push(entity.id.clone());
1278 }
1279 Some(_) => {}
1280 }
1281 }
1282 let post_ids: std::collections::HashSet<EntityId> = self
1283 .store
1284 .all_entities()
1285 .filter(|e| !e.stub && e.mem == mem)
1286 .map(|e| e.id.clone())
1287 .collect();
1288 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
1289 added.sort_by(|a, b| a.0.cmp(&b.0));
1290 changed.sort_by(|a, b| a.0.cmp(&b.0));
1291 removed.sort_by(|a, b| a.0.cmp(&b.0));
1292
1293 self.invalidate_communities();
1294 self.invalidate_search_indexes();
1295
1296 Ok(crate::ops::ReloadResult {
1297 added,
1298 changed,
1299 removed,
1300 })
1301 }
1302
1303 pub fn reload_one_mem_report(
1331 &mut self,
1332 mem: &str,
1333 ) -> Result<crate::ops::ReloadReport, EngineError> {
1334 let tracks_head = self
1344 .mounts
1345 .iter()
1346 .find(|m| m.mount.mem == mem)
1347 .and_then(|m| m.backend.current_head().ok().flatten())
1348 .is_some();
1349 let head_before = if tracks_head {
1350 self.mounts
1351 .iter()
1352 .find(|m| m.mount.mem == mem)
1353 .and_then(|m| m.last_known_head.clone())
1354 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
1355 } else {
1356 crate::ops::EMPTY_TREE_SHA.to_string()
1357 };
1358
1359 let result = self.reload_one_mem(mem)?;
1360
1361 let head_after_raw = self
1368 .mounts
1369 .iter()
1370 .find(|m| m.mount.mem == mem)
1371 .and_then(|m| m.backend.current_head().ok().flatten());
1372 if let Some(new_head) = head_after_raw.clone()
1373 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
1374 {
1375 m.last_known_head = Some(new_head);
1376 }
1377 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
1378
1379 let entities_loaded = self
1380 .store
1381 .all_entities()
1382 .filter(|e| !e.stub && e.mem == mem)
1383 .count();
1384
1385 let mut changed_entity_ids: Vec<EntityId> = result
1390 .added
1391 .into_iter()
1392 .chain(result.changed)
1393 .chain(result.removed)
1394 .collect();
1395 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
1396
1397 Ok(crate::ops::ReloadReport {
1398 mem: mem.to_string(),
1399 head_before,
1400 head_after,
1401 entities_loaded,
1402 changed_entity_ids,
1403 })
1404 }
1405
1406 pub fn reload_each_writable_mem_reports(
1436 &mut self,
1437 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
1438 self.refresh_workspace_settings_if_possible();
1439 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1440 let mut out = Vec::with_capacity(names.len());
1441 for name in names {
1442 let report = self.reload_one_mem_report(&name)?;
1443 out.push(report);
1444 }
1445 Ok(out)
1446 }
1447
1448 fn refresh_workspace_settings_if_possible(&mut self) {
1460 let Some(root) = self.workspace_root.clone() else {
1461 return;
1462 };
1463 let store = crate::workspace_store::FileWorkspaceStore::new();
1464 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
1465 Ok(w) => w,
1466 Err(_) => return,
1467 };
1468 self.set_settings(workspace.settings);
1469 }
1470
1471 pub fn reload_each_writable_mem(
1487 &mut self,
1488 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
1489 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1490 let mut sink = std::mem::take(&mut self.load_warnings);
1496 sink.clear();
1497 let mut out = Vec::with_capacity(names.len());
1498 let mut loop_err = None;
1499 for name in names {
1500 match self.reload_one_mem_inner(&name, &mut sink) {
1501 Ok(result) => out.push((name, result)),
1502 Err(e) => {
1503 loop_err = Some(e);
1504 break;
1505 }
1506 }
1507 }
1508 self.load_warnings = sink;
1509 if let Some(e) = loop_err {
1510 return Err(e);
1511 }
1512 Ok(out)
1513 }
1514}
1515
1516#[cfg(test)]
1517mod tests {
1518
1519 use tempfile::TempDir;
1520
1521 use crate::backend::{BackendError, MemBackend};
1522 use crate::engine::test_helpers::*;
1523 use crate::engine::{Engine, EngineError};
1524 use crate::mem::MemOrigin;
1525 use crate::ops::WarningHint;
1526 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1527
1528 #[test]
1529 fn reload_each_writable_mem_repopulates_load_warnings() {
1530 let tmp = TempDir::new().unwrap();
1534 let mem_dir = tmp.path().to_path_buf();
1535 let writer = FilesystemMemWriter::new(mem_dir.clone());
1536 let mut engine = Engine::from_mounts(vec![(
1537 folder_mount("specs", mem_dir.clone()),
1538 Box::new(writer) as Box<dyn MemBackend>,
1539 )])
1540 .unwrap();
1541 assert!(
1542 engine.load_warnings().is_empty(),
1543 "clean boot has no warnings"
1544 );
1545
1546 let body =
1548 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1549 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1550
1551 engine.reload_each_writable_mem().unwrap();
1552 let warnings = engine.load_warnings();
1553 assert!(
1554 warnings
1555 .iter()
1556 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1557 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
1558 );
1559 }
1560
1561 #[test]
1568 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
1569 let tmp = TempDir::new().unwrap();
1570 let mem_dir = tmp.path().to_path_buf();
1571 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1573 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
1574 let writer = FilesystemMemWriter::new(mem_dir.clone());
1575 let mut engine = Engine::from_mounts(vec![(
1576 folder_mount("specs", mem_dir.clone()),
1577 Box::new(writer) as Box<dyn MemBackend>,
1578 )])
1579 .unwrap();
1580 assert!(
1582 !engine
1583 .load_warnings()
1584 .iter()
1585 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
1586 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
1587 engine.load_warnings()
1588 );
1589
1590 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";
1592 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
1593
1594 engine.reload_each_writable_mem().unwrap();
1595
1596 let invalid: Vec<_> = engine
1597 .load_warnings()
1598 .iter()
1599 .filter_map(|w| match w {
1600 WarningHint::ParsedRelationInvalid {
1601 rel_type,
1602 reason,
1603 origin,
1604 ..
1605 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
1606 _ => None,
1607 })
1608 .collect();
1609 assert_eq!(
1610 invalid.len(),
1611 1,
1612 "reload must surface the parse-time drift, got: {invalid:?}",
1613 );
1614 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1615 assert_eq!(invalid[0].1, "unknown_rel_type");
1616 assert_eq!(invalid[0].2, "writable");
1617 }
1618
1619 #[test]
1620 fn reload_one_mem_keeps_engine_load_warnings_pristine() {
1621 let tmp = TempDir::new().unwrap();
1627 let mem_dir = tmp.path().to_path_buf();
1628 let body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
1629 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1630 let writer = FilesystemMemWriter::new(mem_dir.clone());
1631 let mut engine = Engine::from_mounts(vec![(
1632 folder_mount("specs", mem_dir.clone()),
1633 Box::new(writer) as Box<dyn MemBackend>,
1634 )])
1635 .unwrap();
1636 let pre = engine.load_warnings().to_vec();
1637 assert!(!pre.is_empty(), "boot must populate load_warnings");
1638
1639 engine.reload_one_mem("specs").unwrap();
1640 let post = engine.load_warnings();
1641 assert_eq!(
1643 post.len(),
1644 pre.len(),
1645 "single-mem reload must not touch sink"
1646 );
1647 }
1648
1649 #[test]
1657 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
1658 let tmp = TempDir::new().unwrap();
1659 let a_dir = tmp.path().join("a");
1660 let b_dir = tmp.path().join("b");
1661 std::fs::create_dir_all(&a_dir).unwrap();
1662 std::fs::create_dir_all(&b_dir).unwrap();
1663 let a_writer = FilesystemMemWriter::new(a_dir.clone());
1664 let b_writer = FilesystemMemWriter::new(b_dir.clone());
1665 let mut engine = Engine::from_mounts(vec![
1666 (
1667 folder_mount("specs", a_dir),
1668 Box::new(a_writer) as Box<dyn MemBackend>,
1669 ),
1670 (
1671 folder_mount("memos", b_dir),
1672 Box::new(b_writer) as Box<dyn MemBackend>,
1673 ),
1674 ])
1675 .unwrap();
1676
1677 let mut settings = crate::workspace::WorkspaceSettings::default();
1679 settings.cross_mem_links.insert(
1680 "specs".to_string(),
1681 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
1682 );
1683 engine.set_settings(settings);
1684
1685 let (actor, client) = cli_actor();
1686 let source = engine
1687 .create_entity(
1688 empty_create_args("specs", "Source"),
1689 actor,
1690 Some(&client),
1691 None,
1692 )
1693 .unwrap();
1694 let target = engine
1695 .create_entity(
1696 empty_create_args("memos", "Target"),
1697 actor,
1698 Some(&client),
1699 None,
1700 )
1701 .unwrap();
1702 engine
1703 .relate_entity(
1704 crate::engine::RelateEntityArgs {
1705 source: source.id.clone(),
1706 expected_hash: Some(source.content_hash.clone()),
1707 rel_type: "USES".to_string(),
1708 target: target.id.clone(),
1709 remove: false,
1710 description: None,
1711 },
1712 actor,
1713 Some(&client),
1714 None,
1715 )
1716 .unwrap();
1717
1718 let has_edge = |e: &Engine| {
1720 let out = e
1721 .store()
1722 .outgoing(&source.id)
1723 .iter()
1724 .any(|edge| edge.target == target.id);
1725 let inc = e
1726 .store()
1727 .incoming(&target.id)
1728 .iter()
1729 .any(|edge| edge.from == source.id);
1730 (out, inc)
1731 };
1732
1733 assert_eq!(
1734 has_edge(&engine),
1735 (true, true),
1736 "edge must be indexed in both directions after relate",
1737 );
1738
1739 engine.reload_one_mem("memos").unwrap();
1741 assert_eq!(
1742 has_edge(&engine),
1743 (true, true),
1744 "cross-mem edge into B must survive a per-mem reload of B",
1745 );
1746
1747 engine.reload_each_writable_mem().unwrap();
1750 assert_eq!(
1751 has_edge(&engine),
1752 (true, true),
1753 "per-mem and workspace reload converge on the same edge",
1754 );
1755
1756 assert!(
1759 engine
1760 .store()
1761 .get(&source.id)
1762 .unwrap()
1763 .relationships
1764 .iter()
1765 .any(|r| r.target == target.id),
1766 "source record must retain the relationship throughout",
1767 );
1768 }
1769
1770 #[test]
1776 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
1777 let tmp = TempDir::new().unwrap();
1778 let a_dir = tmp.path().join("a");
1779 let b_dir = tmp.path().join("b");
1780 std::fs::create_dir_all(&a_dir).unwrap();
1781 std::fs::create_dir_all(&b_dir).unwrap();
1782 let a_writer = FilesystemMemWriter::new(a_dir.clone());
1783 let b_writer = FilesystemMemWriter::new(b_dir.clone());
1784 let mut engine = Engine::from_mounts(vec![
1785 (
1786 folder_mount("specs", a_dir),
1787 Box::new(a_writer) as Box<dyn MemBackend>,
1788 ),
1789 (
1790 folder_mount("memos", b_dir),
1791 Box::new(b_writer) as Box<dyn MemBackend>,
1792 ),
1793 ])
1794 .unwrap();
1795 let mut settings = crate::workspace::WorkspaceSettings::default();
1796 settings.cross_mem_links.insert(
1797 "specs".to_string(),
1798 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
1799 );
1800 engine.set_settings(settings);
1801
1802 let (actor, client) = cli_actor();
1803 let source = engine
1804 .create_entity(
1805 empty_create_args("specs", "Source"),
1806 actor,
1807 Some(&client),
1808 None,
1809 )
1810 .unwrap();
1811 let target = engine
1812 .create_entity(
1813 empty_create_args("memos", "Target"),
1814 actor,
1815 Some(&client),
1816 None,
1817 )
1818 .unwrap();
1819 engine
1820 .relate_entity(
1821 crate::engine::RelateEntityArgs {
1822 source: source.id.clone(),
1823 expected_hash: Some(source.content_hash.clone()),
1824 rel_type: "USES".to_string(),
1825 target: target.id.clone(),
1826 remove: false,
1827 description: None,
1828 },
1829 actor,
1830 Some(&client),
1831 None,
1832 )
1833 .unwrap();
1834
1835 engine.reload_one_mem("specs").unwrap();
1836
1837 let out = engine
1838 .store()
1839 .outgoing(&source.id)
1840 .iter()
1841 .any(|edge| edge.target == target.id);
1842 let inc = engine
1843 .store()
1844 .incoming(&target.id)
1845 .iter()
1846 .any(|edge| edge.from == source.id);
1847 assert!(
1848 out && inc,
1849 "outgoing cross-mem edge must survive a source-mem reload"
1850 );
1851 }
1852
1853 #[test]
1854 fn workspace_root_setter_round_trips() {
1855 let tmp = TempDir::new().unwrap();
1856 let mem_dir = tmp.path().to_path_buf();
1857 let writer = FilesystemMemWriter::new(mem_dir.clone());
1858 let mut engine = Engine::from_mounts(vec![(
1859 folder_mount("specs", mem_dir),
1860 Box::new(writer) as Box<dyn MemBackend>,
1861 )])
1862 .unwrap();
1863 let root = tmp.path().to_path_buf();
1864 engine.set_workspace_root(root.clone());
1865 assert_eq!(engine.workspace_root(), Some(root.as_path()));
1866 }
1867
1868 #[test]
1869 fn export_mem_folder_backend_produces_archive() {
1870 let tmp = TempDir::new().unwrap();
1874 let mem_dir = tmp.path().join("specs");
1875 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1876 let config_body = r#"{
1877 "format": 1,
1878 "schema": "default@1.0.0",
1879 "version": "1.0.0"
1880 }"#;
1881 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
1882
1883 let writer = FilesystemMemWriter::new(mem_dir.clone());
1884 let engine = Engine::from_mounts(vec![(
1885 folder_mount("specs", mem_dir.clone()),
1886 Box::new(writer) as Box<dyn MemBackend>,
1887 )])
1888 .unwrap();
1889
1890 let archive_path = tmp.path().join("specs.mem");
1891 let result = engine.export_mem("specs", &archive_path).unwrap();
1892 assert!(archive_path.exists(), "archive must exist on disk");
1893 assert!(result.size_bytes > 0);
1894 assert_eq!(result.entity_count, 0);
1897 }
1898
1899 #[test]
1900 fn export_mem_unknown_mem_returns_unknown_mem() {
1901 let tmp = TempDir::new().unwrap();
1902 let mem_dir = tmp.path().to_path_buf();
1903 let writer = FilesystemMemWriter::new(mem_dir.clone());
1904 let engine = Engine::from_mounts(vec![(
1905 folder_mount("specs", mem_dir),
1906 Box::new(writer) as Box<dyn MemBackend>,
1907 )])
1908 .unwrap();
1909 let output = tmp.path().join("out.mem");
1910 let err = engine.export_mem("missing", &output).unwrap_err();
1911 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
1912 }
1913
1914 #[test]
1915 fn export_mem_missing_config_returns_invalid_input() {
1916 let tmp = TempDir::new().unwrap();
1920 let mem_dir = tmp.path().to_path_buf();
1921 let writer = FilesystemMemWriter::new(mem_dir.clone());
1922 let engine = Engine::from_mounts(vec![(
1923 folder_mount("specs", mem_dir),
1924 Box::new(writer) as Box<dyn MemBackend>,
1925 )])
1926 .unwrap();
1927 let output = tmp.path().join("out.mem");
1928 let err = engine.export_mem("specs", &output).unwrap_err();
1929 assert!(matches!(err, EngineError::InvalidInput(_)));
1930 }
1931
1932 #[test]
1933 fn export_mem_archive_backend_returns_sealed() {
1934 let tmp = TempDir::new().unwrap();
1937 let archive_path = build_archive(
1938 tmp.path(),
1939 "ext",
1940 &[(
1941 ".memstead/config.json",
1942 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
1943 )],
1944 );
1945 let engine = Engine::from_mounts(vec![(
1946 archive_mount("ext", archive_path.clone()),
1947 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1948 )])
1949 .unwrap();
1950 let output = tmp.path().join("out.mem");
1951 let err = engine.export_mem("ext", &output).unwrap_err();
1952 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
1953 }
1954
1955 #[test]
1956 fn export_markdown_writes_unchanged_files_zero_writes() {
1957 let tmp = TempDir::new().unwrap();
1962 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
1963 let result = engine.export_markdown(None, None).unwrap();
1964 assert_eq!(
1965 result.written, 0,
1966 "freshly-created entity's file already matches generated markdown"
1967 );
1968 assert_eq!(
1969 result.unchanged, 1,
1970 "the one seeded entity counts as unchanged"
1971 );
1972 assert!(
1973 result.skipped_mounts.is_empty(),
1974 "folder-only workspace has no skipped mounts"
1975 );
1976 }
1977
1978 #[test]
1979 fn export_markdown_skips_non_folder_mounts() {
1980 let tmp = TempDir::new().unwrap();
1984 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
1985 let engine = Engine::from_mounts(vec![(
1986 archive_mount("ext", archive_path.clone()),
1987 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1988 )])
1989 .unwrap();
1990 let result = engine.export_markdown(None, None).unwrap();
1991 assert_eq!(result.written, 0);
1992 assert_eq!(result.unchanged, 0);
1993 assert_eq!(
1994 result.skipped_mounts.len(),
1995 1,
1996 "archive mount is in the skipped list"
1997 );
1998 let entry = &result.skipped_mounts[0];
1999 assert_eq!(entry.mem, "ext");
2000 assert_eq!(entry.active_backend, "archive");
2001 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
2002 }
2003
2004 #[test]
2005 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
2006 let tmp = TempDir::new().unwrap();
2010 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
2011 let engine = Engine::from_mounts(vec![(
2012 archive_mount("ext", archive_path.clone()),
2013 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2014 )])
2015 .unwrap();
2016 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
2017 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
2018 let details = err.details();
2019 assert_eq!(details["mem"], "ext");
2020 assert_eq!(details["active_backend"], "archive");
2021 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
2022 }
2023
2024 #[test]
2025 fn register_writable_mem_adds_mount_and_router_entry() {
2026 let tmp = TempDir::new().unwrap();
2029 let mem_a = tmp.path().join("a");
2030 std::fs::create_dir_all(&mem_a).unwrap();
2031 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2032
2033 let mut engine = Engine::from_mounts(vec![(
2034 folder_mount("alpha", mem_a),
2035 Box::new(writer_a) as Box<dyn MemBackend>,
2036 )])
2037 .unwrap();
2038 assert!(engine.mem_router().is_writable("alpha"));
2039
2040 let mem_b = tmp.path().join("b");
2041 std::fs::create_dir_all(&mem_b).unwrap();
2042 let writer_b = FilesystemMemWriter::new(mem_b.clone());
2043
2044 engine
2045 .register_writable_mem(
2046 folder_mount("beta", mem_b.clone()),
2047 Box::new(writer_b) as Box<dyn MemBackend>,
2048 MemOrigin::ExplicitToml,
2049 )
2050 .unwrap();
2051
2052 assert!(engine.mem_router().is_writable("alpha"));
2054 assert!(engine.mem_router().is_writable("beta"));
2055 assert!(engine.mem_router().is_visible("beta"));
2056
2057 assert!(engine.mount("beta").is_some());
2059 assert!(engine.schemas().contains_key("beta"));
2060
2061 assert_eq!(
2063 engine.mem_router().dir_for_mem("beta"),
2064 Some(mem_b.as_path()),
2065 );
2066 }
2067
2068 #[test]
2074 fn register_writable_mem_resolves_schema_from_mem_config() {
2075 let tmp = TempDir::new().unwrap();
2076 let mem_a = tmp.path().join("a");
2077 std::fs::create_dir_all(&mem_a).unwrap();
2078 let mut engine = Engine::from_mounts(vec![(
2079 folder_mount("alpha", mem_a.clone()),
2080 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
2081 )])
2082 .unwrap();
2083
2084 let mem_b = tmp.path().join("b");
2085 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
2086 std::fs::write(
2087 mem_b.join(".memstead").join("config.json"),
2088 r#"{"schema":"software@0.1.0"}"#,
2089 )
2090 .unwrap();
2091 let mount_b = crate::workspace::Mount {
2092 mem: "beta".to_string(),
2093 schema: Some(memstead_schema::SchemaRef::new(
2094 "totally-not-a-schema",
2095 semver::Version::new(9, 9, 9),
2096 )),
2097 storage: crate::workspace::MountStorage::Folder {
2098 path: mem_b.clone(),
2099 },
2100 capability: crate::workspace::MountCapability::Write,
2101 lifecycle: crate::workspace::MountLifecycle::Eager,
2102 cross_linkable: true,
2103 migration_target: None,
2104 };
2105 engine
2106 .register_writable_mem(
2107 mount_b,
2108 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
2109 MemOrigin::ExplicitToml,
2110 )
2111 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
2112
2113 assert!(engine.schemas().contains_key("beta"));
2114 let surfaced = engine.load_warnings().iter().any(|w| {
2115 matches!(
2116 w,
2117 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
2118 if mem == "beta"
2119 && config_pin == "software@0.1.0"
2120 && mount_pin == "totally-not-a-schema@9.9.9"
2121 )
2122 });
2123 assert!(
2124 surfaced,
2125 "SchemaPinMismatch must surface for beta: {:?}",
2126 engine.load_warnings(),
2127 );
2128 }
2129
2130 #[test]
2131 fn register_writable_mem_rejects_existing_name() {
2132 let tmp = TempDir::new().unwrap();
2135 let mem_a = tmp.path().join("a");
2136 std::fs::create_dir_all(&mem_a).unwrap();
2137 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2138
2139 let mut engine = Engine::from_mounts(vec![(
2140 folder_mount("alpha", mem_a),
2141 Box::new(writer_a) as Box<dyn MemBackend>,
2142 )])
2143 .unwrap();
2144 let mount_count_pre = engine.mounts().len();
2145
2146 let mem_collide = tmp.path().join("alpha-2");
2147 std::fs::create_dir_all(&mem_collide).unwrap();
2148 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
2149
2150 let err = engine
2151 .register_writable_mem(
2152 folder_mount("alpha", mem_collide),
2153 Box::new(writer_collide) as Box<dyn MemBackend>,
2154 MemOrigin::ExplicitToml,
2155 )
2156 .unwrap_err();
2157 match err {
2158 EngineError::MemNameCollision {
2159 name,
2160 source_origin,
2161 } => {
2162 assert_eq!(name, "alpha");
2163 assert!(
2168 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
2169 );
2170 }
2171 other => panic!("expected MemNameCollision, got {other:?}"),
2172 }
2173
2174 assert_eq!(engine.mounts().len(), mount_count_pre);
2176 }
2177
2178 #[test]
2179 fn register_writable_mem_loads_entities_into_store() {
2180 let tmp = TempDir::new().unwrap();
2183 let mem_a = tmp.path().join("a");
2184 std::fs::create_dir_all(&mem_a).unwrap();
2185 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2186
2187 let mut engine = Engine::from_mounts(vec![(
2188 folder_mount("alpha", mem_a),
2189 Box::new(writer_a) as Box<dyn MemBackend>,
2190 )])
2191 .unwrap();
2192 let pre_count = engine.store().all_entities().count();
2193
2194 let mem_b = tmp.path().join("b");
2196 std::fs::create_dir_all(&mem_b).unwrap();
2197 std::fs::write(
2198 mem_b.join("b1.md"),
2199 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
2200 )
2201 .unwrap();
2202 let writer_b = FilesystemMemWriter::new(mem_b.clone());
2203
2204 engine
2205 .register_writable_mem(
2206 folder_mount("beta", mem_b),
2207 Box::new(writer_b) as Box<dyn MemBackend>,
2208 MemOrigin::ExplicitToml,
2209 )
2210 .unwrap();
2211
2212 let post_count = engine.store().all_entities().count();
2213 assert!(post_count > pre_count, "register must load entities");
2214 let beta_count = engine
2215 .store()
2216 .all_entities()
2217 .filter(|e| e.mem == "beta")
2218 .count();
2219 assert_eq!(beta_count, 1);
2220 }
2221
2222 #[test]
2223 fn register_then_unregister_round_trips() {
2224 let tmp = TempDir::new().unwrap();
2228 let mem_a = tmp.path().join("a");
2229 std::fs::create_dir_all(&mem_a).unwrap();
2230 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2231
2232 let mut engine = Engine::from_mounts(vec![(
2233 folder_mount("alpha", mem_a),
2234 Box::new(writer_a) as Box<dyn MemBackend>,
2235 )])
2236 .unwrap();
2237 let pre_mounts = engine.mounts().len();
2238
2239 let mem_b = tmp.path().join("b");
2240 std::fs::create_dir_all(&mem_b).unwrap();
2241 let writer_b = FilesystemMemWriter::new(mem_b);
2242
2243 engine
2244 .register_writable_mem(
2245 folder_mount("beta", tmp.path().join("b")),
2246 Box::new(writer_b) as Box<dyn MemBackend>,
2247 MemOrigin::ExplicitToml,
2248 )
2249 .unwrap();
2250 assert_eq!(engine.mounts().len(), pre_mounts + 1);
2251
2252 let removed = engine.unregister_writable_mem("beta").unwrap();
2253 assert!(removed.is_some());
2254 assert_eq!(engine.mounts().len(), pre_mounts);
2255 assert!(!engine.mem_router().is_writable("beta"));
2256 }
2257
2258 #[test]
2259 fn unregister_writable_mem_returns_false_for_unknown_name() {
2260 let tmp = TempDir::new().unwrap();
2264 let mem_dir = tmp.path().to_path_buf();
2265 let writer = FilesystemMemWriter::new(mem_dir.clone());
2266 let mut engine = Engine::from_mounts(vec![(
2267 folder_mount("specs", mem_dir),
2268 Box::new(writer) as Box<dyn MemBackend>,
2269 )])
2270 .unwrap();
2271 let removed = engine.unregister_writable_mem("missing").unwrap();
2272 assert!(removed.is_none(), "unknown mem returns Ok(None)");
2273 assert!(engine.mem_router().is_writable("specs"));
2275 }
2276
2277 #[test]
2278 fn unregister_writable_mem_drops_mount_and_router_entry() {
2279 let tmp = TempDir::new().unwrap();
2284 let mem_a = tmp.path().join("a");
2285 std::fs::create_dir_all(&mem_a).unwrap();
2286 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2287 let mem_b = tmp.path().join("b");
2288 std::fs::create_dir_all(&mem_b).unwrap();
2289 let writer_b = FilesystemMemWriter::new(mem_b.clone());
2290
2291 let mut engine = Engine::from_mounts(vec![
2292 (
2293 folder_mount("alpha", mem_a),
2294 Box::new(writer_a) as Box<dyn MemBackend>,
2295 ),
2296 (
2297 folder_mount("beta", mem_b),
2298 Box::new(writer_b) as Box<dyn MemBackend>,
2299 ),
2300 ])
2301 .unwrap();
2302
2303 let removed = engine.unregister_writable_mem("alpha").unwrap();
2304 assert!(removed.is_some());
2305
2306 assert!(!engine.mem_router().is_writable("alpha"));
2308 assert!(!engine.mem_router().is_visible("alpha"));
2309 assert!(engine.mount("alpha").is_none());
2310
2311 assert!(engine.mem_router().is_writable("beta"));
2313 assert!(engine.mount("beta").is_some());
2314 }
2315
2316 #[test]
2317 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
2318 let tmp = TempDir::new().unwrap();
2322 let mem_a = tmp.path().join("a");
2323 std::fs::create_dir_all(&mem_a).unwrap();
2324 std::fs::write(
2325 mem_a.join("a1.md"),
2326 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
2327 )
2328 .unwrap();
2329 let writer_a = FilesystemMemWriter::new(mem_a.clone());
2330
2331 let mem_b = tmp.path().join("b");
2332 std::fs::create_dir_all(&mem_b).unwrap();
2333 std::fs::write(
2334 mem_b.join("b1.md"),
2335 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
2336 )
2337 .unwrap();
2338 let writer_b = FilesystemMemWriter::new(mem_b.clone());
2339
2340 let mut engine = Engine::from_mounts(vec![
2341 (
2342 folder_mount("alpha", mem_a),
2343 Box::new(writer_a) as Box<dyn MemBackend>,
2344 ),
2345 (
2346 folder_mount("beta", mem_b),
2347 Box::new(writer_b) as Box<dyn MemBackend>,
2348 ),
2349 ])
2350 .unwrap();
2351
2352 let pre_total = engine.store().all_entities().count();
2353 assert!(pre_total >= 2, "both mems must load entities");
2354
2355 engine.unregister_writable_mem("alpha").unwrap();
2356
2357 let alpha_remaining = engine
2359 .store()
2360 .all_entities()
2361 .filter(|e| e.mem == "alpha")
2362 .count();
2363 assert_eq!(alpha_remaining, 0);
2364
2365 let beta_remaining = engine
2367 .store()
2368 .all_entities()
2369 .filter(|e| e.mem == "beta")
2370 .count();
2371 assert!(beta_remaining > 0, "beta entities must survive");
2372 }
2373 #[test]
2374 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
2375 let tmp = TempDir::new().unwrap();
2376 let mut engine = build_demo_engine(&tmp);
2377 let result = engine
2378 .reload_one_mem("specs")
2379 .expect("reload on stable disk must succeed");
2380 assert!(result.added.is_empty(), "added: {:?}", result.added);
2381 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
2382 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
2383 }
2384
2385 #[test]
2386 fn reload_one_mem_picks_up_external_addition() {
2387 let tmp = TempDir::new().unwrap();
2388 let mut engine = build_demo_engine(&tmp);
2389 std::fs::write(
2392 tmp.path().join("external.md"),
2393 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
2394 )
2395 .unwrap();
2396 let result = engine.reload_one_mem("specs").unwrap();
2397 assert_eq!(
2398 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
2399 vec!["specs--external"]
2400 );
2401 assert!(result.changed.is_empty());
2402 assert!(result.removed.is_empty());
2403 assert!(
2405 engine
2406 .get_entity(&crate::EntityId::new("specs", "external"))
2407 .is_some()
2408 );
2409 }
2410
2411 #[test]
2412 fn reload_one_mem_picks_up_external_removal() {
2413 let tmp = TempDir::new().unwrap();
2414 let mut engine = build_demo_engine(&tmp);
2415 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2418 let result = engine.reload_one_mem("specs").unwrap();
2419 assert!(result.added.is_empty());
2420 assert!(result.changed.is_empty());
2421 assert_eq!(
2422 result
2423 .removed
2424 .iter()
2425 .map(|i| i.as_ref())
2426 .collect::<Vec<_>>(),
2427 vec!["specs--lonely-three"]
2428 );
2429 }
2430
2431 #[test]
2432 fn reload_one_mem_picks_up_external_change() {
2433 let tmp = TempDir::new().unwrap();
2434 let mut engine = build_demo_engine(&tmp);
2435 std::fs::write(
2438 tmp.path().join("source-one.md"),
2439 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
2440 )
2441 .unwrap();
2442 let result = engine.reload_one_mem("specs").unwrap();
2443 assert!(result.added.is_empty());
2444 assert_eq!(
2445 result
2446 .changed
2447 .iter()
2448 .map(|i| i.as_ref())
2449 .collect::<Vec<_>>(),
2450 vec!["specs--source-one"]
2451 );
2452 assert!(result.removed.is_empty());
2453 }
2454
2455 #[test]
2456 fn reload_one_mem_rejects_unknown_mem() {
2457 let tmp = TempDir::new().unwrap();
2458 let mut engine = build_demo_engine(&tmp);
2459 let err = engine.reload_one_mem("nope").unwrap_err();
2460 match err {
2461 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
2462 other => panic!("expected UnknownMem, got {other:?}"),
2463 }
2464 }
2465
2466 #[test]
2467 fn reload_each_writable_mem_returns_one_entry_per_mount() {
2468 let tmp = TempDir::new().unwrap();
2469 let mut engine = build_demo_engine(&tmp);
2470 let reports = engine
2471 .reload_each_writable_mem()
2472 .expect("batch reload on stable disk must succeed");
2473 assert_eq!(reports.len(), 1);
2474 assert_eq!(reports[0].0, "specs");
2475 assert!(reports[0].1.added.is_empty());
2476 assert!(reports[0].1.changed.is_empty());
2477 assert!(reports[0].1.removed.is_empty());
2478 }
2479
2480 #[test]
2483 fn settings_default_to_empty_on_fresh_engine() {
2484 let tmp = TempDir::new().unwrap();
2485 let engine = build_demo_engine(&tmp);
2486 let s = engine.settings();
2487 assert!(s.mem_create_rules.is_empty());
2488 assert!(s.mem_delete_rules.is_empty());
2489 assert!(s.cross_mem_links.is_empty());
2490 }
2491
2492 #[test]
2493 fn set_settings_replaces_workspace_policy() {
2494 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
2495 let tmp = TempDir::new().unwrap();
2496 let mut engine = build_demo_engine(&tmp);
2497 let mut settings = WorkspaceSettings::default();
2498 settings.mem_create_rules.push(CreateRuleSetting {
2499 pattern: "exec-*".to_string(),
2500 schemas: vec!["default@1.0.0".to_string()],
2501 default_cross_links: None,
2502 });
2503 settings.mem_delete_rules.push(DeleteRuleSetting {
2504 pattern: "exec-*".to_string(),
2505 });
2506 engine.set_settings(settings);
2507 assert_eq!(engine.settings().mem_create_rules.len(), 1);
2508 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
2509 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
2510 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
2511 }
2512
2513 #[test]
2516 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
2517 let tmp = TempDir::new().unwrap();
2518 let mut engine = build_demo_engine(&tmp);
2519 std::fs::write(
2521 tmp.path().join("new-via-disk.md"),
2522 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
2523 )
2524 .unwrap();
2525 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2526 std::fs::write(
2527 tmp.path().join("source-one.md"),
2528 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
2529 )
2530 .unwrap();
2531
2532 let reports = engine.reload_each_writable_mem().unwrap();
2533 assert_eq!(reports.len(), 1);
2534 let (mem, result) = &reports[0];
2535 assert_eq!(mem, "specs");
2536 assert_eq!(
2537 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
2538 vec!["specs--new-via-disk"]
2539 );
2540 assert_eq!(
2541 result
2542 .removed
2543 .iter()
2544 .map(|i| i.as_ref())
2545 .collect::<Vec<_>>(),
2546 vec!["specs--lonely-three"]
2547 );
2548 assert_eq!(
2549 result
2550 .changed
2551 .iter()
2552 .map(|i| i.as_ref())
2553 .collect::<Vec<_>>(),
2554 vec!["specs--source-one"]
2555 );
2556 }
2557
2558 #[test]
2561 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
2562 let tmp = TempDir::new().unwrap();
2567 let mut engine = build_demo_engine(&tmp);
2568 let report = engine.reload_one_mem_report("specs").unwrap();
2569 assert_eq!(report.mem, "specs");
2570 assert_eq!(report.head_before, crate::ops::EMPTY_TREE_SHA);
2571 assert_eq!(report.head_after, crate::ops::EMPTY_TREE_SHA);
2572 assert_eq!(report.entities_loaded, 3);
2575 assert!(report.changed_entity_ids.is_empty());
2577 }
2578
2579 #[test]
2580 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
2581 let tmp = TempDir::new().unwrap();
2586 let mut engine = build_demo_engine(&tmp);
2587 std::fs::write(
2588 tmp.path().join("new-via-disk.md"),
2589 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
2590 )
2591 .unwrap();
2592 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2593 std::fs::write(
2594 tmp.path().join("source-one.md"),
2595 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
2596 )
2597 .unwrap();
2598
2599 let report = engine.reload_one_mem_report("specs").unwrap();
2600 assert_eq!(report.mem, "specs");
2601 let ids: Vec<&str> = report
2602 .changed_entity_ids
2603 .iter()
2604 .map(|id| id.as_ref())
2605 .collect();
2606 assert_eq!(
2608 ids,
2609 vec![
2610 "specs--lonely-three",
2611 "specs--new-via-disk",
2612 "specs--source-one",
2613 ]
2614 );
2615 }
2616
2617 #[test]
2618 fn reload_one_mem_report_rejects_unknown_mem() {
2619 let tmp = TempDir::new().unwrap();
2620 let mut engine = build_demo_engine(&tmp);
2621 let err = engine.reload_one_mem_report("missing").unwrap_err();
2622 assert!(matches!(err, EngineError::UnknownMem(_)));
2623 }
2624
2625 #[test]
2626 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
2627 let tmp = TempDir::new().unwrap();
2628 let mut engine = build_demo_engine(&tmp);
2629 let reports = engine.reload_each_writable_mem_reports().unwrap();
2630 assert_eq!(reports.len(), 1);
2631 assert_eq!(reports[0].mem, "specs");
2632 assert_eq!(reports[0].entities_loaded, 3);
2633 }
2634
2635 #[test]
2642 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
2643 let tmp = TempDir::new().unwrap();
2644
2645 let memstead_dir = tmp.path().join(".memstead");
2648 std::fs::create_dir_all(&memstead_dir).unwrap();
2649 let workspace_toml = memstead_dir.join("workspace.toml");
2650 std::fs::write(
2651 &workspace_toml,
2652 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2653 )
2654 .unwrap();
2655 let mounts_json = memstead_dir.join("state").join("mounts.json");
2656 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
2657 let mem_dir = tmp.path().join("specs");
2658 std::fs::create_dir_all(&mem_dir).unwrap();
2659 let mounts_body = format!(
2660 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
2661 mem_dir.display(),
2662 );
2663 std::fs::write(&mounts_json, mounts_body).unwrap();
2664
2665 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2666 assert!(
2667 engine.settings().mem_create_rules.is_empty(),
2668 "boot-time settings carry no create rules"
2669 );
2670
2671 std::fs::write(
2673 &workspace_toml,
2674 "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",
2675 )
2676 .unwrap();
2677
2678 engine.reload_each_writable_mem_reports().unwrap();
2679
2680 let rules = &engine.settings().mem_create_rules;
2681 assert_eq!(
2682 rules.len(),
2683 1,
2684 "workspace-wide reload must refresh the policy"
2685 );
2686 assert_eq!(rules[0].pattern, "exec-*");
2687 }
2688
2689 const MIG_TYPE_TAIL: &str = r#"sections:
2694 - key: body
2695 heading: Body
2696 required: true
2697 search_weight: 10.0
2698 catch_all: true
2699 write_rules: []
2700title_weight: 100.0
2701text_fields:
2702 - body
2703hierarchy_relationship: _default
2704propagating_relationships: []
2705updatable_fields: []
2706health_required_fields: []
2707staleness_threshold_days: 90
2708write_rules: []
2709"#;
2710
2711 fn mig_manifest(name: &str, version: &str) -> String {
2716 format!(
2717 r#"name: {name}
2718version: {version}
2719description: migration test schema
2720when_to_use: tests
2721types:
2722 - doc
2723relationships:
2724 mode: strict
2725 definitions:
2726 - name: USES
2727 description: link
2728 default_weight: 1.0
2729 - name: _default
2730 description: fallback
2731 default_weight: 1.0
2732community:
2733 resolution: 1.0
2734 seed: 42
2735"#
2736 )
2737 }
2738
2739 fn mig_type_yaml(with_status: bool) -> String {
2740 let metadata = if with_status {
2741 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n enum_values:\n - open\n - closed\n"
2742 } else {
2743 "metadata_fields: []\n"
2744 };
2745 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
2746 }
2747
2748 fn write_mig_schema(
2749 root: &std::path::Path,
2750 dir: &str,
2751 name: &str,
2752 version: &str,
2753 with_status: bool,
2754 ) {
2755 let d = root.join(dir);
2756 std::fs::create_dir_all(d.join("types")).unwrap();
2757 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
2758 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
2759 }
2760
2761 fn migration_engine() -> (tempfile::TempDir, Engine) {
2766 let tmp = tempfile::TempDir::new().unwrap();
2767 let schemas_dir = tmp.path().join("schemas");
2768 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
2769 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
2770 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
2771 let mem_dir = tmp.path().join("mem");
2772 std::fs::create_dir_all(&mem_dir).unwrap();
2773 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2774 let mut mount = folder_mount("specs", mem_dir);
2775 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2776 let mut engine = Engine::from_mounts_with_schemas_dir(
2777 vec![(
2778 mount,
2779 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2780 )],
2781 Some(&schemas_dir),
2782 )
2783 .unwrap();
2784 for title in ["One", "Two"] {
2785 let mut args = empty_create_args("specs", title);
2786 args.entity_type = "doc".to_string();
2787 args.sections =
2788 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
2789 engine
2790 .create_entity(args, crate::vcs::Actor::Cli, None, None)
2791 .expect("conformant create under mig-a");
2792 }
2793 (tmp, engine)
2794 }
2795
2796 fn sref(s: &str) -> memstead_schema::SchemaRef {
2797 s.parse().unwrap()
2798 }
2799
2800 #[test]
2801 fn set_schema_noop_on_current_pin() {
2802 let (_tmp, mut engine) = migration_engine();
2803 let out = engine
2804 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
2805 .unwrap();
2806 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
2807 assert_eq!(out.schema_pin, "mig-a@0.1.0");
2808 assert_eq!(out.migration_target, None);
2809 assert!(out.findings.is_empty());
2810 }
2811
2812 #[test]
2813 fn set_schema_switches_immediately_when_integral() {
2814 let (_tmp, mut engine) = migration_engine();
2817 let out = engine
2818 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
2819 .unwrap();
2820 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2821 assert_eq!(out.schema_pin, "mig-a@0.2.0");
2822 assert_eq!(out.migration_target, None);
2823 assert!(out.findings.is_empty());
2824 assert_eq!(
2825 engine.schema_pin("specs").unwrap().as_display(),
2826 "mig-a@0.2.0"
2827 );
2828 assert!(engine.migration_target("specs").is_none());
2829 }
2830
2831 #[test]
2837 fn set_schema_switch_persists_pin_into_backend_config() {
2838 let tmp = tempfile::TempDir::new().unwrap();
2839 let schemas_dir = tmp.path().join("schemas");
2840 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
2841 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
2842 let mem_dir = tmp.path().join("mem");
2843 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2844 std::fs::write(
2846 mem_dir.join(".memstead").join("config.json"),
2847 br#"{"schema":"mig-a@0.1.0"}"#,
2848 )
2849 .unwrap();
2850 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2851 let mut mount = folder_mount("specs", mem_dir.clone());
2852 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2853 let mut engine = Engine::from_mounts_with_schemas_dir(
2854 vec![(
2855 mount,
2856 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2857 )],
2858 Some(&schemas_dir),
2859 )
2860 .unwrap();
2861
2862 let out = engine
2863 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
2864 .unwrap();
2865 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2866
2867 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
2870 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
2871 assert_eq!(
2872 cfg["schema"], "mig-a@0.2.0",
2873 "atomic switch must update the authoritative backend config"
2874 );
2875 }
2876
2877 #[test]
2878 fn set_schema_unknown_target_refuses_schema_not_found() {
2879 let (_tmp, mut engine) = migration_engine();
2880 let err = engine
2881 .set_mem_schema("specs", &sref("nope@9.9.9"))
2882 .unwrap_err();
2883 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
2884 assert!(engine.migration_target("specs").is_none());
2886 }
2887
2888 #[test]
2889 fn set_schema_migration_lifecycle_end_to_end() {
2890 let (_tmp, mut engine) = migration_engine();
2891 let target = sref("mig-b@0.1.0");
2892
2893 let out = engine.set_mem_schema("specs", &target).unwrap();
2895 assert_eq!(
2896 out.outcome,
2897 crate::engine::SetSchemaResult::MigrationStarted
2898 );
2899 assert_eq!(out.schema_pin, "mig-a@0.1.0");
2900 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
2901 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
2902 assert!(
2903 out.findings
2904 .iter()
2905 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
2906 );
2907
2908 let one = crate::entity::EntityId::new("specs", "one");
2910 assert!(engine.store().get(&one).is_some());
2911
2912 let out = engine.set_mem_schema("specs", &target).unwrap();
2914 assert_eq!(
2915 out.outcome,
2916 crate::engine::SetSchemaResult::MigrationPending
2917 );
2918 assert_eq!(out.findings.len(), 2);
2919
2920 let mut bad = crate::engine::UpdateEntityArgs {
2924 anchors: Vec::new(),
2925 id: one.clone(),
2926 expected_hash: None,
2927 sections: indexmap::IndexMap::new(),
2928 append_sections: indexmap::IndexMap::new(),
2929 patch_sections: indexmap::IndexMap::new(),
2930 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
2931 metadata_unset: Vec::new(),
2932 declare_relations: Vec::new(),
2933 dry_run: false,
2934 relations_unset: Vec::new(),
2935 };
2936 let err = engine
2937 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
2938 .unwrap_err();
2939 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
2940 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
2941 engine
2942 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
2943 .expect("repair write validated against the migration target");
2944
2945 let out = engine.set_mem_schema("specs", &target).unwrap();
2947 assert_eq!(
2948 out.outcome,
2949 crate::engine::SetSchemaResult::MigrationPending
2950 );
2951 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
2952
2953 let two = crate::entity::EntityId::new("specs", "two");
2955 let repair = crate::engine::UpdateEntityArgs {
2956 anchors: Vec::new(),
2957 id: two.clone(),
2958 expected_hash: None,
2959 sections: indexmap::IndexMap::new(),
2960 append_sections: indexmap::IndexMap::new(),
2961 patch_sections: indexmap::IndexMap::new(),
2962 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
2963 metadata_unset: Vec::new(),
2964 declare_relations: Vec::new(),
2965 dry_run: false,
2966 relations_unset: Vec::new(),
2967 };
2968 engine
2969 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
2970 .unwrap();
2971 let out = engine.set_mem_schema("specs", &target).unwrap();
2972 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2973 assert_eq!(out.schema_pin, "mig-b@0.1.0");
2974 assert_eq!(out.migration_target, None);
2975 assert!(out.findings.is_empty());
2976 assert_eq!(
2977 engine.schema_pin("specs").unwrap().as_display(),
2978 "mig-b@0.1.0"
2979 );
2980 assert!(engine.migration_target("specs").is_none());
2981 }
2982
2983 #[test]
2988 fn relations_unset_works_during_migration_without_mode_flag() {
2989 let (_tmp, mut engine) = migration_engine();
2990 let one = crate::entity::EntityId::new("specs", "one");
2991 let two = crate::entity::EntityId::new("specs", "two");
2992 engine
2993 .relate_entity(
2994 crate::engine::RelateEntityArgs {
2995 source: one.clone(),
2996 expected_hash: None,
2997 rel_type: "USES".to_string(),
2998 target: two.clone(),
2999 remove: false,
3000 description: None,
3001 },
3002 crate::vcs::Actor::Cli,
3003 None,
3004 None,
3005 )
3006 .unwrap();
3007 let shut = engine
3009 .update_entity(
3010 crate::engine::UpdateEntityArgs {
3011 anchors: Vec::new(),
3012 id: one.clone(),
3013 expected_hash: None,
3014 sections: indexmap::IndexMap::new(),
3015 append_sections: indexmap::IndexMap::new(),
3016 patch_sections: indexmap::IndexMap::new(),
3017 metadata: indexmap::IndexMap::new(),
3018 metadata_unset: Vec::new(),
3019 declare_relations: Vec::new(),
3020 dry_run: false,
3021 relations_unset: vec![crate::ops::RelationUnsetArg {
3022 rel_type: "USES".to_string(),
3023 target: two.clone(),
3024 }],
3025 },
3026 crate::vcs::Actor::Cli,
3027 None,
3028 None,
3029 )
3030 .unwrap_err();
3031 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
3032
3033 engine
3037 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
3038 .unwrap();
3039 engine
3040 .update_entity(
3041 crate::engine::UpdateEntityArgs {
3042 anchors: Vec::new(),
3043 id: one.clone(),
3044 expected_hash: None,
3045 sections: indexmap::IndexMap::new(),
3046 append_sections: indexmap::IndexMap::new(),
3047 patch_sections: indexmap::IndexMap::new(),
3048 metadata: indexmap::IndexMap::from_iter([(
3049 "status".to_string(),
3050 "open".to_string(),
3051 )]),
3052 metadata_unset: Vec::new(),
3053 declare_relations: Vec::new(),
3054 dry_run: false,
3055 relations_unset: vec![crate::ops::RelationUnsetArg {
3056 rel_type: "USES".to_string(),
3057 target: two.clone(),
3058 }],
3059 },
3060 crate::vcs::Actor::Cli,
3061 None,
3062 None,
3063 )
3064 .expect("repair-shaped update lands during migration without a flag");
3065 let entity = engine.store().get(&one).unwrap();
3066 assert!(entity.relationships.is_empty());
3067 }
3068
3069 #[test]
3074 fn boot_resumes_dual_pin_validation_against_target() {
3075 let (tmp, engine) = migration_engine();
3076 drop(engine);
3077 let schemas_dir = tmp.path().join("schemas");
3078 let mem_dir = tmp.path().join("mem");
3079 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
3080 let mut mount = folder_mount("specs", mem_dir);
3081 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
3082 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
3083 let engine = Engine::from_mounts_with_schemas_dir(
3084 vec![(
3085 mount,
3086 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
3087 )],
3088 Some(&schemas_dir),
3089 )
3090 .unwrap();
3091 let (name, version) = {
3093 let s = engine.schema_for("specs").unwrap();
3094 let (n, v) = s.id();
3095 (n.to_string(), v.to_string())
3096 };
3097 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
3098 assert_eq!(
3101 engine.schema_pin("specs").unwrap().as_display(),
3102 "mig-a@0.1.0"
3103 );
3104 assert_eq!(
3105 engine.migration_target("specs").unwrap().as_display(),
3106 "mig-b@0.1.0"
3107 );
3108 }
3109}