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_mutation_clock(&mut self, clock: crate::engine::MutationClock) {
65 self.mutation_clock = clock;
66 }
67
68 pub fn set_role(&mut self, role: crate::vcs::Role) {
74 self.current_role = role;
75 }
76
77 pub fn current_role(&self) -> crate::vcs::Role {
79 self.current_role
80 }
81
82 pub(crate) fn now_iso(&self) -> String {
86 crate::engine::mutation::iso_from_system_time((self.mutation_clock)())
87 }
88
89 pub fn set_git_branch_ops(&mut self, ops: GitBranchOps) {
95 self.git_branch_ops = Some(ops);
96 }
97
98 pub fn install_schema(
113 &self,
114 name: &str,
115 version: &str,
116 files: &[(String, Vec<u8>)],
117 ) -> Result<String, EngineError> {
118 Self::validate_schema_package(name, version, files)?;
124 let gitdir = self
130 .mounts
131 .iter()
132 .find_map(|m| match &m.mount.storage {
133 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
134 _ => None,
135 })
136 .or_else(|| {
137 self.workspace_root()
138 .map(|r| r.join("mem-repo").join(".git"))
139 })
140 .ok_or_else(|| {
141 EngineError::Mem(
142 "schema install requires a mem-repo workspace (no git-branch mount and \
143 no workspace root to resolve the mem-repo gitdir)"
144 .to_string(),
145 )
146 })?;
147 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
148 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
149 })?;
150 let files = memstead_schema::loader::with_format_marker(files.to_vec());
154 (ops.write_schema)(&gitdir, name, version, &files).map_err(EngineError::Backend)
155 }
156
157 pub fn validate_schema_package(
168 name: &str,
169 version: &str,
170 files: &[(String, Vec<u8>)],
171 ) -> Result<(), EngineError> {
172 let invalid = |message: String| EngineError::SchemaPackageInvalid {
173 name: name.to_string(),
174 version: version.to_string(),
175 message,
176 };
177 let manifest_yaml = files
178 .iter()
179 .find(|(rel, _)| rel == "schema.yaml")
180 .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned())
181 .ok_or_else(|| invalid("package has no schema.yaml".to_string()))?;
182 let types: Vec<(String, String)> = files
183 .iter()
184 .filter_map(|(rel, bytes)| {
185 rel.strip_prefix("types/")
186 .and_then(|f| f.strip_suffix(".yaml"))
187 .map(|stem| {
188 (
189 stem.to_string(),
190 String::from_utf8_lossy(bytes).into_owned(),
191 )
192 })
193 })
194 .collect();
195 let schema = memstead_schema::load_schema_from_memory_with_format(
199 &manifest_yaml,
200 &types,
201 memstead_schema::loader::MetadataPolarityFormat::RequiredOptIn,
202 )
203 .map_err(|e| invalid(e.to_string()))?;
204 memstead_schema::check_section_heading_roundtrip(&schema)
205 .map_err(|e| invalid(e.to_string()))?;
206 memstead_schema::check_reserved_metadata_keys(&schema)
207 .map_err(|e| invalid(e.to_string()))?;
208 memstead_schema::check_section_formats(&schema).map_err(|e| invalid(e.to_string()))?;
209 let (declared_name, declared_version) =
210 (schema.manifest.name.as_str(), schema.version.to_string());
211 if declared_name != name || declared_version != version {
212 return Err(invalid(format!(
213 "manifest declares '{declared_name}@{declared_version}' but the package is \
214 being installed as '{name}@{version}'"
215 )));
216 }
217 Self::validate_schema_exemplars(&std::sync::Arc::new(schema)).map_err(invalid)?;
218 Ok(())
219 }
220
221 pub fn validate_schema_exemplars(
243 schema: &std::sync::Arc<memstead_schema::Schema>,
244 ) -> Result<(), String> {
245 let with_exemplars: Vec<&str> = schema
246 .manifest
247 .types
248 .iter()
249 .filter(|t| {
250 schema
251 .types
252 .get(t.as_str())
253 .is_some_and(|td| td.exemplar.is_some())
254 })
255 .map(String::as_str)
256 .collect();
257 if with_exemplars.is_empty() {
258 return Ok(());
259 }
260
261 let (name, version) = schema.id();
262 let mem = "exemplar";
263 let mount = crate::workspace::Mount {
264 mem: mem.to_string(),
265 schema: Some(memstead_schema::SchemaRef::new(name, version)),
266 storage: crate::workspace::MountStorage::InMemory,
267 capability: crate::workspace::MountCapability::Write,
268 lifecycle: crate::workspace::MountLifecycle::Eager,
269 cross_linkable: true,
270 migration_target: None,
271 };
272 let backend = Box::new(crate::storage::InMemoryBackend::new()) as Box<dyn MemBackend>;
273 let mut engine = Engine::from_mounts_with_schemas_dir_and_extra(
274 vec![(mount, backend)],
275 None,
276 vec![schema.clone()],
277 )
278 .map_err(|e| format!("exemplar validation could not boot: {e}"))?;
279
280 for type_name in with_exemplars {
281 let td = schema
282 .types
283 .get(type_name)
284 .expect("filtered on presence above");
285 let ex = td.exemplar.as_ref().expect("filtered on presence above");
286 let mut relations = Vec::with_capacity(ex.relations.len());
287 for r in &ex.relations {
288 if r.to.contains("--") || r.to.trim().is_empty() {
289 return Err(format!(
290 "type '{type_name}' exemplar relation target '{}' must be a bare \
291 placeholder slug (no `--`, non-empty) — exemplars live outside \
292 any mem",
293 r.to
294 ));
295 }
296 relations.push(crate::ops::RelateArg {
297 to: crate::entity::EntityId::new(mem, &r.to),
298 rel_type: r.rel_type.clone(),
299 description: r.description.clone(),
300 });
301 }
302 let args = crate::engine::CreateEntityArgs {
303 anchors: Vec::new(),
304 mem: mem.to_string(),
305 title: ex.title.clone(),
306 entity_type: type_name.to_string(),
307 sections: ex.sections.clone(),
308 metadata: ex.metadata.clone(),
309 relations,
310 dry_run: true,
311 };
312 if let Err(e) = engine.create_entity(args, crate::vcs::Actor::Cli, None, None) {
313 return Err(format!(
314 "type '{type_name}' exemplar does not conform: [{}] {e}",
315 e.code()
316 ));
317 }
318 }
319 Ok(())
320 }
321 pub fn unregister_writable_mem(
340 &mut self,
341 mem_name: &str,
342 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
343 let pos = self.mounts.iter().position(|m| m.mount.mem == mem_name);
344 let Some(idx) = pos else {
345 return Ok(None);
346 };
347
348 let mount = self.mounts.remove(idx);
354
355 self.schemas.remove(&mount.mount.mem);
358
359 let _removed = self.store.remove_entities_by_mem(mem_name);
364
365 self.load_warnings
376 .retain(|w| w.source_mem() != Some(mem_name));
377
378 Arc::make_mut(&mut self.mem_router).remove_writable(mem_name);
386
387 self.invalidate_communities();
392 self.invalidate_search_indexes();
393
394 Ok(Some(mount.backend))
395 }
396
397 pub fn register_read_mount(
405 &mut self,
406 mount: Mount,
407 backend: Box<dyn MemBackend>,
408 origin: MemOrigin,
409 ) -> Result<(), EngineError> {
410 self.register_writable_mem_inner(mount, backend, origin, true)
411 }
412
413 pub fn unregister_read_mount(
421 &mut self,
422 mem_name: &str,
423 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
424 let pos = self.mounts.iter().position(|m| {
425 m.mount.mem == mem_name
426 && m.mount.capability == crate::workspace::MountCapability::ReadOnly
427 });
428 let Some(idx) = pos else {
429 return Ok(None);
430 };
431 let mount = self.mounts.remove(idx);
432 self.schemas.remove(&mount.mount.mem);
433 let _removed = self.store.remove_entities_by_mem(mem_name);
434 self.load_warnings
435 .retain(|w| w.source_mem() != Some(mem_name));
436 Arc::make_mut(&mut self.mem_router).remove_read_only(mem_name);
437 self.invalidate_communities();
438 self.invalidate_search_indexes();
439 Ok(Some(mount.backend))
440 }
441
442 pub fn push_load_warning(&mut self, warning: crate::ops::WarningHint) {
447 self.load_warnings.push(warning);
448 }
449
450 pub fn register_writable_mem(
489 &mut self,
490 mount: Mount,
491 backend: Box<dyn MemBackend>,
492 origin: MemOrigin,
493 ) -> Result<(), EngineError> {
494 self.register_writable_mem_inner(mount, backend, origin, true)
495 }
496
497 fn register_writable_mem_inner(
507 &mut self,
508 mount: Mount,
509 backend: Box<dyn MemBackend>,
510 origin: MemOrigin,
511 run_global_passes: bool,
512 ) -> Result<(), EngineError> {
513 if let Some(existing) = self.mem_router.origin_for_mem(&mount.mem) {
515 return Err(EngineError::MemNameCollision {
516 name: mount.mem.clone(),
517 source_origin: existing.render_source(),
518 });
519 }
520 if self.mem_router.archive_path_for_mem(&mount.mem).is_some() {
521 return Err(EngineError::MemNameCollision {
522 name: mount.mem.clone(),
523 source_origin: "attached read mem".to_string(),
524 });
525 }
526
527 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
533 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
534 memstead_schema::config::parse_mem_config(&value).ok()
535 });
536
537 let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
547 self.workspace_schemas().to_vec();
548 builtin_schemas.extend(
549 memstead_schema::builtins::load_builtin_schemas()
550 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?,
551 );
552 let config_pin = mem_config.as_ref().and_then(|c| c.schema.as_ref());
553 let mount_pin = mount.schema.as_ref();
554 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
555 && cfg != mp
556 {
557 self.load_warnings
558 .push(crate::ops::WarningHint::SchemaPinMismatch {
559 mem: mount.mem.clone(),
560 config_pin: cfg.as_display(),
561 mount_pin: mp.as_display(),
562 });
563 }
564 let settled_pin = config_pin.or(mount_pin);
565 let effective_pin = mount
566 .migration_target
567 .as_ref()
568 .or(settled_pin)
569 .ok_or_else(|| EngineError::MemConfigIncomplete {
570 mem: mount.mem.clone(),
571 missing_fields: vec!["schema".to_string()],
572 })?
573 .clone();
574 let schema = crate::engine::SchemaResolver::new(&builtin_schemas)
575 .resolve(&effective_pin)
576 .map_err(|sources| {
577 EngineError::SchemaNotFound {
578 mem: mount.mem.clone(),
579 pin: effective_pin.as_display(),
580 sources,
581 install_hint: None,
582 }
583 .with_schema_install_probe(self.workspace_root())
584 })?;
585
586 let (entries, read_errors) = collect_source_entries(backend.as_ref())?;
594 let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
595
596 let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
597 mem_names.push(mount.mem.clone());
598 let known_suffixes: Vec<String> = mem_names
599 .iter()
600 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
601 .collect();
602 let fallback = engine_fallback_type();
603 push_entities_into_store(
604 &mut self.store,
605 load_result.entities,
606 fallback.as_ref(),
607 Some(crate::entity::store_builder::LoadCollector {
608 warnings: &mut self.load_warnings,
609 known_suffixes: &known_suffixes,
610 mem_names: &mem_names,
611 }),
612 );
613 self.load_errors.extend(load_result.errors);
614
615 self.schemas.insert(mount.mem.clone(), schema);
617
618 if run_global_passes {
629 let mut mount_caps: std::collections::HashMap<
630 String,
631 crate::workspace::MountCapability,
632 > = self
633 .mounts
634 .iter()
635 .map(|m| (m.mount.mem.clone(), m.mount.capability))
636 .collect();
637 mount_caps.insert(mount.mem.clone(), mount.capability);
638 crate::entity::store_builder::validate_loaded_relations(
639 &mut self.store,
640 &self.schemas,
641 &mount_caps,
642 &mut self.load_warnings,
643 );
644 crate::entity::store_builder::remap_alias_target_edge_sources(
645 &mut self.store,
646 &self.schemas,
647 );
648 }
649
650 let last_known_head = backend.current_head().ok().flatten();
652 let mem_name_for_router = mount.mem.clone();
653 let storage_for_router = mount.storage.clone();
654 let mount_capability_for_router = mount.capability;
655 self.mounts.push(MountedBackend {
656 mount,
657 backend,
658 last_known_head,
659 mem_config,
660 archive_provenance: None,
663 });
664
665 match (&mount_capability_for_router, &storage_for_router) {
673 (crate::workspace::MountCapability::ReadOnly, MountStorage::Archive { path }) => {
674 Arc::make_mut(&mut self.mem_router)
675 .add_read_only(mem_name_for_router, path.clone());
676 }
677 _ => {
678 let dir: Option<PathBuf> = match &storage_for_router {
679 MountStorage::Folder { path } => Some(path.clone()),
680 MountStorage::GitBranch { .. }
681 | MountStorage::Archive { .. }
682 | MountStorage::InMemory => None,
683 };
684 Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
685 }
686 }
687
688 if run_global_passes {
690 self.invalidate_communities();
691 self.invalidate_search_indexes();
692 }
693
694 Ok(())
695 }
696
697 fn finish_batched_registrations(&mut self) {
702 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
703 .mounts
704 .iter()
705 .map(|m| (m.mount.mem.clone(), m.mount.capability))
706 .collect();
707 crate::entity::store_builder::validate_loaded_relations(
708 &mut self.store,
709 &self.schemas,
710 &mount_caps,
711 &mut self.load_warnings,
712 );
713 crate::entity::store_builder::remap_alias_target_edge_sources(
714 &mut self.store,
715 &self.schemas,
716 );
717 self.invalidate_communities();
718 self.invalidate_search_indexes();
719 }
720
721 pub fn full_refresh(&mut self) -> crate::ops::FullRefreshReport {
746 let started = std::time::Instant::now();
747 let mut report = crate::ops::FullRefreshReport::default();
748
749 let Some(root) = self.workspace_root.clone() else {
750 report.failures.push(crate::ops::RefreshFailure {
751 item: "workspace".to_string(),
752 error: "engine has no workspace root (ad-hoc mount-list construction) — \
753 nothing to re-scan"
754 .to_string(),
755 });
756 report.elapsed_ms = started.elapsed().as_millis() as u64;
757 return report;
758 };
759
760 self.refresh_workspace_settings_if_possible();
763
764 use crate::schema_source::SchemaSource as _;
766 let mut fresh: Vec<std::sync::Arc<memstead_schema::Schema>> = Vec::new();
767 let mut sources_complete = true;
768 match crate::schema_source::FolderSchemaSource::for_workspace(&root).read_schemas() {
769 Ok(mut s) => fresh.append(&mut s),
770 Err(e) => {
771 sources_complete = false;
772 report.failures.push(crate::ops::RefreshFailure {
773 item: "schema-source:folder".to_string(),
774 error: e.to_string(),
775 });
776 }
777 }
778 if let Some(ops) = self.git_branch_ops() {
779 match (ops.read_ref_schemas)(&root) {
780 Ok(mut s) => fresh.append(&mut s),
781 Err(e) => {
782 sources_complete = false;
783 report.failures.push(crate::ops::RefreshFailure {
784 item: "schema-source:memstead-ref".to_string(),
785 error: e.to_string(),
786 });
787 }
788 }
789 }
790 let key = |s: &memstead_schema::Schema| {
791 let (name, version) = s.id();
792 format!("{name}@{version}")
793 };
794 let existing: std::collections::HashSet<String> =
795 self.workspace_schemas.iter().map(|s| key(s)).collect();
796 let fresh_keys: std::collections::HashSet<String> = fresh.iter().map(|s| key(s)).collect();
797 for schema in fresh {
798 let k = key(&schema);
799 if !existing.contains(&k) && !report.schemas_added.contains(&k) {
800 report.schemas_added.push(k);
801 self.workspace_schemas.push(schema);
802 }
803 }
804 report.schemas_added.sort();
805 if sources_complete {
809 report.schema_removals_skipped = existing
810 .difference(&fresh_keys)
811 .cloned()
812 .collect::<Vec<_>>();
813 report.schema_removals_skipped.sort();
814 }
815
816 let store = crate::workspace_store::FileWorkspaceStore::new();
818 match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
819 Err(e) => {
820 report.failures.push(crate::ops::RefreshFailure {
821 item: "mount-manifest".to_string(),
822 error: e.to_string(),
823 });
824 }
825 Ok(workspace) => {
826 let manifest_names: std::collections::HashSet<String> =
827 workspace.mounts.iter().map(|m| m.mem.clone()).collect();
828 let mut any_mounted = false;
829 for mount in workspace.mounts {
830 if self.mounts.iter().any(|m| m.mount.mem == mount.mem) {
831 continue;
832 }
833 let name = mount.mem.clone();
834 if mount.capability != crate::workspace::MountCapability::Write {
835 report.failures.push(crate::ops::RefreshFailure {
836 item: format!("mount:{name}"),
837 error: "only writable mounts attach on a warm refresh — \
838 restart the process to attach this mount"
839 .to_string(),
840 });
841 continue;
842 }
843 let backend = match (self.backend_factory)(&mount) {
844 Ok(b) => b,
845 Err(e) => {
846 report.failures.push(crate::ops::RefreshFailure {
847 item: format!("mount:{name}"),
848 error: e.to_string(),
849 });
850 continue;
851 }
852 };
853 match self.register_writable_mem_inner(
854 mount,
855 backend,
856 crate::mem::MemOrigin::ExplicitToml,
857 false,
858 ) {
859 Ok(()) => {
860 any_mounted = true;
861 report.mems_mounted.push(name);
862 }
863 Err(e) => report.failures.push(crate::ops::RefreshFailure {
864 item: format!("mount:{name}"),
865 error: e.to_string(),
866 }),
867 }
868 }
869 report.mem_removals_skipped = self
874 .mounts
875 .iter()
876 .filter(|m| {
877 m.mount.capability == crate::workspace::MountCapability::Write
878 && !manifest_names.contains(&m.mount.mem)
879 })
880 .map(|m| m.mount.mem.clone())
881 .collect();
882 report.mem_removals_skipped.sort();
883 if any_mounted {
884 self.finish_batched_registrations();
886 }
887 }
888 }
889
890 report.elapsed_ms = started.elapsed().as_millis() as u64;
891 report
892 }
893
894 pub fn set_workspace_root(&mut self, root: PathBuf) {
900 self.workspace_root = Some(root);
901 }
902
903 pub fn persist_state(&self) -> Result<(), EngineError> {
926 let Some(root) = self.workspace_root.as_ref() else {
927 return Ok(());
928 };
929 let workspace = crate::workspace::Workspace {
930 mounts: self.mounts.iter().map(|m| m.mount.clone()).collect(),
931 settings: self.settings.clone(),
932 };
933 let store = crate::FileWorkspaceStore::new();
934 crate::workspace_store::WorkspaceStoreAdapter::save_state(&store, root, &workspace)
935 .map_err(|e| EngineError::Mem(format!("persist workspace state: {e}")))
936 }
937 pub fn set_mem_schema(
957 &mut self,
958 mem: &str,
959 target: &memstead_schema::SchemaRef,
960 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
961 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
962 if self.quarantine_reason(mem).is_some() {
968 return self.set_schema_on_quarantined(mem, target);
969 }
970 let mount_idx = self
971 .mounts
972 .iter()
973 .position(|m| m.mount.mem == mem)
974 .ok_or_else(|| self.unknown_mem_error(mem))?;
975 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
981 return Err(EngineError::ReadOnlyMount(mem.to_string()));
982 }
983
984 let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
987 let consulted: Vec<_> = self
990 .workspace_schemas
991 .iter()
992 .chain(self.builtin_schemas.iter())
993 .cloned()
994 .collect();
995 EngineError::SchemaNotFound {
996 mem: mem.to_string(),
997 pin: target.as_display(),
998 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
999 &target.name,
1000 &target.version,
1001 &consulted,
1002 ),
1003 install_hint: None,
1004 }
1005 .with_schema_install_probe(self.workspace_root())
1006 })?;
1007
1008 let current_pin = self.mounts[mount_idx].mount.schema.clone();
1013 let current_pin_display = current_pin
1014 .as_ref()
1015 .map(|p| p.as_display())
1016 .unwrap_or_else(|| "<unset>".to_string());
1017 let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1018
1019 if current_pin.as_ref() == Some(target) {
1020 return Ok(SetSchemaOutcome {
1021 mem: mem.to_string(),
1022 schema_pin: current_pin_display,
1023 migration_target: in_flight.map(|t| t.as_display()),
1024 outcome: SetSchemaResult::Noop,
1025 findings: Vec::new(),
1026 });
1027 }
1028
1029 let findings = crate::ops::integrity::conformance_findings(
1035 &self.store,
1036 mem,
1037 target_schema.as_ref(),
1038 &self.schemas,
1039 );
1040
1041 if findings.is_empty() {
1042 self.persist_mem_schema_pin(mount_idx, target)?;
1050 self.mounts[mount_idx].mount.schema = Some(target.clone());
1051 self.mounts[mount_idx].mount.migration_target = None;
1052 self.schemas.insert(mem.to_string(), target_schema);
1053 self.invalidate_communities();
1054 self.persist_state()?;
1055 return Ok(SetSchemaOutcome {
1056 mem: mem.to_string(),
1057 schema_pin: target.as_display(),
1058 migration_target: None,
1059 outcome: SetSchemaResult::Switched,
1060 findings: Vec::new(),
1061 });
1062 }
1063
1064 let outcome = if in_flight.as_ref() == Some(target) {
1065 SetSchemaResult::MigrationPending
1066 } else {
1067 SetSchemaResult::MigrationStarted
1068 };
1069 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1070 self.schemas.insert(mem.to_string(), target_schema);
1073 self.invalidate_communities();
1074 self.persist_state()?;
1075 Ok(SetSchemaOutcome {
1076 mem: mem.to_string(),
1077 schema_pin: current_pin_display,
1078 migration_target: Some(target.as_display()),
1079 outcome,
1080 findings,
1081 })
1082 }
1083
1084 fn persist_mem_schema_pin(
1101 &mut self,
1102 mount_idx: usize,
1103 target: &memstead_schema::SchemaRef,
1104 ) -> Result<(), EngineError> {
1105 let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1106 if let Some(value) = value
1109 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1110 {
1111 self.mounts[mount_idx].mem_config = Some(cfg);
1112 }
1113 Ok(())
1114 }
1115
1116 pub fn export_markdown(
1145 &self,
1146 mem_filter: Option<&str>,
1147 schema_filter: Option<&str>,
1148 ) -> Result<crate::ops::ExportResult, EngineError> {
1149 use crate::workspace::MountStorage;
1150 let fallback = engine_fallback_type();
1151 let supported_backends = vec!["folder".to_string()];
1152
1153 if let Some(name) = mem_filter {
1154 let mount = self
1155 .mounts
1156 .iter()
1157 .find(|m| m.mount.mem == name)
1158 .ok_or_else(|| self.unknown_mem_error(name))?;
1159 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1160 return Err(EngineError::MarkdownExportUnsupportedBackend {
1161 mem: name.to_string(),
1162 active_backend: mount.mount.storage.backend_id().to_string(),
1163 supported_backends,
1164 });
1165 }
1166 }
1167
1168 let mut total_written = 0;
1169 let mut total_unchanged = 0;
1170 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1171
1172 for mount in &self.mounts {
1173 let mem_name = mount.mount.mem.as_str();
1174 if let Some(filter) = mem_filter
1175 && mem_name != filter
1176 {
1177 continue;
1178 }
1179 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1180 skipped_mounts.push(crate::ops::SkippedMount {
1181 mem: mem_name.to_string(),
1182 active_backend: mount.mount.storage.backend_id().to_string(),
1183 reason: "backend_does_not_support_markdown_export".to_string(),
1184 });
1185 continue;
1186 };
1187 let schema = match self.schemas.get(mem_name) {
1188 Some(s) => s,
1189 None => continue,
1190 };
1191
1192 for entity in self.store.all_entities() {
1193 if entity.stub || entity.file_path.is_empty() {
1194 continue;
1195 }
1196 if entity.id.mem() != mem_name {
1197 continue;
1198 }
1199 if let Some(filter) = schema_filter
1200 && entity.entity_type != filter
1201 {
1202 continue;
1203 }
1204 let type_def = schema
1205 .get_type(&entity.entity_type)
1206 .unwrap_or_else(|| fallback.clone());
1207 let generated = generate_markdown(entity, type_def.as_ref());
1208
1209 let full_path = mem_dir.join(&entity.file_path);
1210 let needs_write = match std::fs::read_to_string(&full_path) {
1211 Ok(existing) => existing != generated,
1212 Err(_) => true,
1213 };
1214 if needs_write {
1215 let _ = crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref());
1216 total_written += 1;
1217 } else {
1218 total_unchanged += 1;
1219 }
1220 }
1221 }
1222
1223 Ok(crate::ops::ExportResult {
1224 written: total_written,
1225 unchanged: total_unchanged,
1226 skipped_mounts,
1227 })
1228 }
1229
1230 pub fn export_mem(
1248 &self,
1249 mem_name: &str,
1250 output_path: &std::path::Path,
1251 ) -> Result<crate::ops::MemExportResult, EngineError> {
1252 let mount = self
1253 .mounts
1254 .iter()
1255 .find(|m| m.mount.mem == mem_name)
1256 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1257 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1258 EngineError::InvalidInput(format!(
1259 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1260 ))
1261 })?;
1262 if config.version.is_none() {
1270 return Err(EngineError::MemConfigIncomplete {
1271 mem: mem_name.to_string(),
1272 missing_fields: vec!["version".to_string()],
1273 });
1274 }
1275 let workspace_root = self.workspace_root.as_deref();
1276 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1280 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1281 match &mount.mount.storage {
1282 MountStorage::Folder { path } => crate::ops::export::export_mem(
1283 path,
1284 config,
1285 output_path,
1286 workspace_root,
1287 workspace_schemas_dir,
1288 )
1289 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1290 MountStorage::GitBranch { gitdir, branch } => {
1291 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1292 EngineError::Backend(BackendError::Other(
1293 "git-branch export hook not installed (full flavour not loaded)"
1294 .to_string(),
1295 ))
1296 })?;
1297 let provenance_bytes = mount
1301 .backend
1302 .read_provenance(None)
1303 .ok()
1304 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
1305 .and_then(|prov| prov.to_archive_bytes().ok());
1306 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1309 (hook.export)(
1310 gitdir,
1311 branch,
1312 mem_name,
1313 config,
1314 output_path,
1315 workspace_root,
1316 workspace_schemas_dir,
1317 provenance_bytes.as_deref(),
1318 anchors_bytes.as_deref(),
1319 )
1320 .map_err(EngineError::Backend)
1321 }
1322 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1323 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1328 "export not yet supported for in-memory backend".to_string(),
1329 ))),
1330 }
1331 }
1332
1333 pub fn record_pipeline_edit_provenance(
1357 &self,
1358 mem: &str,
1359 kind: &str,
1360 edits: &[(String, Option<Vec<u8>>)],
1361 note: Option<&str>,
1362 verb: &str,
1363 ) -> Result<(), crate::backend::BackendError> {
1364 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1365 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1366 None => Ok(()),
1367 }
1368 }
1369
1370 pub fn set_mem_version(
1371 &mut self,
1372 mem_name: &str,
1373 new_version: semver::Version,
1374 note: Option<&str>,
1375 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1376 let mount_idx = self
1379 .mounts
1380 .iter()
1381 .position(|m| m.mount.mem == mem_name)
1382 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1383 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1384 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1385 }
1386
1387 let mut warnings = self.reload_if_stale(Some(mem_name));
1395 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1400 warnings.push(w);
1401 }
1402
1403 let mounted = &mut self.mounts[mount_idx];
1404 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1405 EngineError::InvalidInput(format!(
1406 "mem '{mem_name}' has no loaded MemConfig — \
1407 cannot set version (initialize the mem via `memstead init` \
1408 or `memstead mem create` first)"
1409 ))
1410 })?;
1411 let old_version = config.version.clone();
1412 config.version = Some(new_version.clone());
1413
1414 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1415 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1416 })?;
1417 bytes.push(b'\n');
1418 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1419 mounted.mem_config = Some(config);
1420
1421 let new_head = mounted.backend.current_head().ok().flatten();
1427 if let Some(sha) = new_head {
1428 mounted.last_known_head = Some(sha);
1429 }
1430
1431 Ok(crate::ops::SetMemVersionOutcome {
1432 mem: mem_name.to_string(),
1433 old_version,
1434 new_version,
1435 warnings,
1436 })
1437 }
1438
1439 pub fn set_mem_description(
1446 &mut self,
1447 mem_name: &str,
1448 new_description: Option<String>,
1449 note: Option<&str>,
1450 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1451 let mount_idx = self
1452 .mounts
1453 .iter()
1454 .position(|m| m.mount.mem == mem_name)
1455 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1456 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1457 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1458 }
1459
1460 let mut warnings = self.reload_if_stale(Some(mem_name));
1461 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1462 warnings.push(w);
1463 }
1464
1465 let mounted = &mut self.mounts[mount_idx];
1466 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1467 EngineError::InvalidInput(format!(
1468 "mem '{mem_name}' has no loaded MemConfig — \
1469 cannot set description (initialize the mem via `memstead init` \
1470 or `memstead mem create` first)"
1471 ))
1472 })?;
1473 let old_description = config.description.clone();
1474 config.description = new_description.clone();
1475
1476 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1477 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1478 })?;
1479 bytes.push(b'\n');
1480 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1481 mounted.mem_config = Some(config);
1482
1483 let new_head = mounted.backend.current_head().ok().flatten();
1484 if let Some(sha) = new_head {
1485 mounted.last_known_head = Some(sha);
1486 }
1487
1488 Ok(crate::ops::SetMemDescriptionOutcome {
1489 mem: mem_name.to_string(),
1490 old_description,
1491 new_description,
1492 warnings,
1493 })
1494 }
1495
1496 pub fn set_mem_title(
1501 &mut self,
1502 mem_name: &str,
1503 new_title: Option<String>,
1504 note: Option<&str>,
1505 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1506 let mount_idx = self
1507 .mounts
1508 .iter()
1509 .position(|m| m.mount.mem == mem_name)
1510 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1511 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1512 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1513 }
1514
1515 let mut warnings = self.reload_if_stale(Some(mem_name));
1516 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1517 warnings.push(w);
1518 }
1519
1520 let mounted = &mut self.mounts[mount_idx];
1521 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1522 EngineError::InvalidInput(format!(
1523 "mem '{mem_name}' has no loaded MemConfig — cannot set title"
1524 ))
1525 })?;
1526 let old_title = config.title.clone();
1527 config.title = new_title.clone();
1528
1529 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1530 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1531 })?;
1532 bytes.push(b'\n');
1533 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1534 mounted.mem_config = Some(config);
1535
1536 let new_head = mounted.backend.current_head().ok().flatten();
1537 if let Some(sha) = new_head {
1538 mounted.last_known_head = Some(sha);
1539 }
1540
1541 Ok(crate::ops::SetMemTitleOutcome {
1542 mem: mem_name.to_string(),
1543 old_title,
1544 new_title,
1545 warnings,
1546 })
1547 }
1548
1549 pub fn set_mem_subject(
1553 &mut self,
1554 mem_name: &str,
1555 new_subject: Option<memstead_schema::MemSubject>,
1556 note: Option<&str>,
1557 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1558 let mount_idx = self
1559 .mounts
1560 .iter()
1561 .position(|m| m.mount.mem == mem_name)
1562 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1563 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1564 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1565 }
1566
1567 let mut warnings = self.reload_if_stale(Some(mem_name));
1568 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
1569 warnings.push(w);
1570 }
1571
1572 let mounted = &mut self.mounts[mount_idx];
1573 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1574 EngineError::InvalidInput(format!(
1575 "mem '{mem_name}' has no loaded MemConfig — cannot set subject"
1576 ))
1577 })?;
1578 let old_subject = config.subject.clone();
1579 config.subject = new_subject.clone();
1580
1581 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1582 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1583 })?;
1584 bytes.push(b'\n');
1585 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1586 mounted.mem_config = Some(config);
1587
1588 let new_head = mounted.backend.current_head().ok().flatten();
1589 if let Some(sha) = new_head {
1590 mounted.last_known_head = Some(sha);
1591 }
1592
1593 Ok(crate::ops::SetMemSubjectOutcome {
1594 mem: mem_name.to_string(),
1595 old_subject,
1596 new_subject,
1597 warnings,
1598 })
1599 }
1600
1601 pub fn set_mem_internal(
1613 &mut self,
1614 mem_name: &str,
1615 internal: bool,
1616 note: Option<&str>,
1617 ) -> Result<bool, EngineError> {
1618 let mount_idx = self
1619 .mounts
1620 .iter()
1621 .position(|m| m.mount.mem == mem_name)
1622 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1623 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1624 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1625 }
1626
1627 let _ = self.reload_if_stale(Some(mem_name));
1628
1629 let mounted = &mut self.mounts[mount_idx];
1630 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1631 EngineError::InvalidInput(format!(
1632 "mem '{mem_name}' has no loaded MemConfig — initialize the mem first"
1633 ))
1634 })?;
1635 if internal {
1636 config
1637 .extra
1638 .insert("internal".to_string(), serde_json::Value::Bool(true));
1639 } else {
1640 config.extra.remove("internal");
1641 }
1642
1643 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1644 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1645 })?;
1646 bytes.push(b'\n');
1647 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1648 mounted.mem_config = Some(config);
1649
1650 let new_head = mounted.backend.current_head().ok().flatten();
1651 if let Some(sha) = new_head {
1652 mounted.last_known_head = Some(sha);
1653 }
1654
1655 Ok(internal)
1656 }
1657
1658 pub fn set_mem_sync_state(
1683 &mut self,
1684 mem_name: &str,
1685 key: &str,
1686 token: &str,
1687 note: Option<&str>,
1688 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
1689 let mount_idx = self
1692 .mounts
1693 .iter()
1694 .position(|m| m.mount.mem == mem_name)
1695 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1696 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1697 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1698 }
1699
1700 let mut warnings = self.reload_if_stale(Some(mem_name));
1704 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
1705 warnings.push(w);
1706 }
1707
1708 let mounted = &mut self.mounts[mount_idx];
1709 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1710 EngineError::InvalidInput(format!(
1711 "mem '{mem_name}' has no loaded MemConfig — \
1712 cannot set sync state (initialize the mem via `memstead init` \
1713 or `memstead mem create` first)"
1714 ))
1715 })?;
1716
1717 let removed;
1721 let previous;
1722 if token.is_empty() {
1723 previous = config.sync_state.remove(key);
1724 removed = previous.is_some();
1725 } else {
1726 previous = config.sync_state.insert(key.to_string(), token.to_string());
1727 removed = false;
1728 }
1729
1730 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1731 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1732 })?;
1733 bytes.push(b'\n');
1734 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1735 mounted.mem_config = Some(config);
1736
1737 let new_head = mounted.backend.current_head().ok().flatten();
1740 if let Some(sha) = new_head {
1741 mounted.last_known_head = Some(sha);
1742 }
1743
1744 Ok(crate::ops::SetMemSyncStateOutcome {
1745 mem: mem_name.to_string(),
1746 key: key.to_string(),
1747 previous,
1748 removed,
1749 warnings,
1750 })
1751 }
1752
1753 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
1773 if self.quarantine_reason(mem).is_some() {
1796 return self.reattach_quarantined_mem(mem);
1797 }
1798 let mut sink: Vec<WarningHint> = Vec::new();
1799 let result = self.reload_one_mem_inner(mem, &mut sink)?;
1800 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
1801 self.load_warnings
1802 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
1803 Ok(result)
1804 }
1805
1806 fn set_schema_on_quarantined(
1816 &mut self,
1817 mem: &str,
1818 target: &memstead_schema::SchemaRef,
1819 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1820 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1821 if self.resolve_schema_by_ref(target).is_none() {
1823 let consulted: Vec<_> = self
1824 .workspace_schemas
1825 .iter()
1826 .chain(self.builtin_schemas.iter())
1827 .cloned()
1828 .collect();
1829 return Err(EngineError::SchemaNotFound {
1830 mem: mem.to_string(),
1831 pin: target.as_display(),
1832 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1833 &target.name,
1834 &target.version,
1835 &consulted,
1836 ),
1837 install_hint: None,
1838 }
1839 .with_schema_install_probe(self.workspace_root()));
1840 }
1841 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
1842 return Err(self.unknown_mem_error(mem));
1843 };
1844 self.quarantined[q_idx].mount.schema = Some(target.clone());
1848 self.quarantined[q_idx].mount.migration_target = None;
1849 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
1850 let _ = bump_backend_schema_pin(backend.as_ref(), target);
1851 }
1852 self.persist_state()?;
1853 let _ = self.reattach_quarantined_mem(mem);
1858 Ok(SetSchemaOutcome {
1859 mem: mem.to_string(),
1860 schema_pin: target.as_display(),
1861 migration_target: None,
1862 outcome: SetSchemaResult::Switched,
1863 findings: Vec::new(),
1864 })
1865 }
1866
1867 fn reattach_quarantined_mem(
1875 &mut self,
1876 mem: &str,
1877 ) -> Result<crate::ops::ReloadResult, EngineError> {
1878 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
1879 return Err(self.unknown_mem_error(mem));
1880 };
1881 let mount = self.quarantined[q_idx].mount.clone();
1882
1883 let requarantine = |this: &mut Self, e: &EngineError| {
1884 this.quarantined[q_idx].reason_code = e.code().to_string();
1885 this.quarantined[q_idx].reason_message = e.to_string();
1886 };
1887
1888 let backend = match (self.backend_factory)(&mount) {
1889 Ok(b) => b,
1890 Err(e) => {
1891 let err = EngineError::Mem(e.to_string());
1892 requarantine(self, &err);
1893 return Err(self.unknown_mem_error(mem));
1894 }
1895 };
1896
1897 let last_known_head = backend.current_head().ok().flatten();
1899 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
1900 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
1901 memstead_schema::config::parse_mem_config(&value).ok()
1902 });
1903 let archive_provenance = backend
1904 .read_archive_provenance()
1905 .ok()
1906 .flatten()
1907 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
1908 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
1909 let effective_pin = mount
1910 .migration_target
1911 .clone()
1912 .or(config_pin)
1913 .or(mount.schema.clone());
1914 let Some(effective_pin) = effective_pin else {
1915 let err = EngineError::MemConfigIncomplete {
1916 mem: mem.to_string(),
1917 missing_fields: vec!["schema".to_string()],
1918 };
1919 requarantine(self, &err);
1920 return Err(self.unknown_mem_error(mem));
1921 };
1922 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
1923 .workspace_schemas
1924 .iter()
1925 .chain(self.builtin_schemas.iter())
1926 .cloned()
1927 .collect();
1928 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
1929 Ok(s) => s,
1930 Err(sources) => {
1931 let err = EngineError::SchemaNotFound {
1932 mem: mem.to_string(),
1933 pin: effective_pin.as_display(),
1934 sources,
1935 install_hint: None,
1936 }
1937 .with_schema_install_probe(self.workspace_root());
1938 requarantine(self, &err);
1939 return Err(self.unknown_mem_error(mem));
1940 }
1941 };
1942
1943 self.quarantined.remove(q_idx);
1947 self.schemas.insert(mem.to_string(), schema);
1948 self.mounts.push(crate::engine::MountedBackend {
1949 mount,
1950 backend,
1951 last_known_head,
1952 mem_config,
1953 archive_provenance,
1954 });
1955 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
1956 &self.mounts,
1957 ));
1958 let mut sink: Vec<WarningHint> = Vec::new();
1959 match self.reload_one_mem_inner(mem, &mut sink) {
1960 Ok(result) => {
1961 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
1962 self.load_warnings
1963 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
1964 self.invalidate_communities();
1965 Ok(result)
1966 }
1967 Err(e) => {
1968 let mount_idx = self.mounts.len() - 1;
1969 let mounted = self.mounts.remove(mount_idx);
1970 self.schemas.remove(mem);
1971 self.mem_router = std::sync::Arc::new(
1972 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
1973 );
1974 self.quarantined.push(crate::engine::QuarantinedMem {
1975 mount: mounted.mount,
1976 reason_code: e.code().to_string(),
1977 reason_message: e.to_string(),
1978 });
1979 Err(e)
1980 }
1981 }
1982 }
1983
1984 fn reload_one_mem_inner(
1990 &mut self,
1991 mem: &str,
1992 warnings_sink: &mut Vec<WarningHint>,
1993 ) -> Result<crate::ops::ReloadResult, EngineError> {
1994 let mount_idx = self
1997 .mounts
1998 .iter()
1999 .position(|m| m.mount.mem == mem)
2000 .ok_or_else(|| self.unknown_mem_error(mem))?;
2001 let schema = self
2002 .schemas
2003 .get(mem)
2004 .cloned()
2005 .ok_or_else(|| self.unknown_mem_error(mem))?;
2006
2007 let pre: HashMap<EntityId, String> = self
2009 .store
2010 .all_entities()
2011 .filter(|e| !e.stub && e.mem == mem)
2012 .map(|e| (e.id.clone(), e.content_hash.clone()))
2013 .collect();
2014 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2015
2016 let backend = self.mounts[mount_idx].backend.as_ref();
2019 let (entries, read_errors) = collect_source_entries(backend)?;
2020 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2021
2022 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2026 let known_suffixes: Vec<String> = mem_names
2027 .iter()
2028 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2029 .collect();
2030
2031 self.store.remove_entities_by_mem(mem);
2033 let fallback = engine_fallback_type();
2034 push_entities_into_store(
2035 &mut self.store,
2036 load_result.entities,
2037 fallback.as_ref(),
2038 Some(crate::entity::store_builder::LoadCollector {
2039 warnings: warnings_sink,
2040 known_suffixes: &known_suffixes,
2041 mem_names: &mem_names,
2042 }),
2043 );
2044 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2051 .mounts
2052 .iter()
2053 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2054 .collect();
2055 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2063 crate::entity::store_builder::validate_loaded_relations(
2064 &mut self.store,
2065 &self.schemas,
2066 &mount_caps,
2067 warnings_sink,
2068 );
2069 crate::entity::store_builder::remap_alias_target_edge_sources(
2070 &mut self.store,
2071 &self.schemas,
2072 );
2073 self.load_errors.extend(load_result.errors);
2079
2080 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2088 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2089 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2090 {
2091 self.mounts[mount_idx].mem_config = Some(cfg);
2092 }
2093
2094 let mut added: Vec<EntityId> = Vec::new();
2096 let mut changed: Vec<EntityId> = Vec::new();
2097 for entity in self.store.all_entities() {
2098 if entity.stub || entity.mem != mem {
2099 continue;
2100 }
2101 match pre.get(&entity.id) {
2102 None => added.push(entity.id.clone()),
2103 Some(prev_hash) if prev_hash != &entity.content_hash => {
2104 changed.push(entity.id.clone());
2105 }
2106 Some(_) => {}
2107 }
2108 }
2109 let post_ids: std::collections::HashSet<EntityId> = self
2110 .store
2111 .all_entities()
2112 .filter(|e| !e.stub && e.mem == mem)
2113 .map(|e| e.id.clone())
2114 .collect();
2115 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2116 added.sort_by(|a, b| a.0.cmp(&b.0));
2117 changed.sort_by(|a, b| a.0.cmp(&b.0));
2118 removed.sort_by(|a, b| a.0.cmp(&b.0));
2119
2120 self.invalidate_communities();
2121 self.invalidate_search_indexes();
2122
2123 Ok(crate::ops::ReloadResult {
2124 added,
2125 changed,
2126 removed,
2127 })
2128 }
2129
2130 pub fn reload_one_mem_report(
2158 &mut self,
2159 mem: &str,
2160 ) -> Result<crate::ops::ReloadReport, EngineError> {
2161 let tracks_head = self
2171 .mounts
2172 .iter()
2173 .find(|m| m.mount.mem == mem)
2174 .and_then(|m| m.backend.current_head().ok().flatten())
2175 .is_some();
2176 let head_before = if tracks_head {
2177 self.mounts
2178 .iter()
2179 .find(|m| m.mount.mem == mem)
2180 .and_then(|m| m.last_known_head.clone())
2181 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2182 } else {
2183 crate::ops::EMPTY_TREE_SHA.to_string()
2184 };
2185
2186 let result = self.reload_one_mem(mem)?;
2187
2188 let head_after_raw = self
2195 .mounts
2196 .iter()
2197 .find(|m| m.mount.mem == mem)
2198 .and_then(|m| m.backend.current_head().ok().flatten());
2199 if let Some(new_head) = head_after_raw.clone()
2200 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2201 {
2202 m.last_known_head = Some(new_head);
2203 }
2204 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2205
2206 let entities_loaded = self
2207 .store
2208 .all_entities()
2209 .filter(|e| !e.stub && e.mem == mem)
2210 .count();
2211
2212 let mut changed_entity_ids: Vec<EntityId> = result
2217 .added
2218 .into_iter()
2219 .chain(result.changed)
2220 .chain(result.removed)
2221 .collect();
2222 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2223
2224 Ok(crate::ops::ReloadReport {
2225 mem: mem.to_string(),
2226 head_before,
2227 head_after,
2228 entities_loaded,
2229 changed_entity_ids,
2230 })
2231 }
2232
2233 pub fn reload_each_writable_mem_reports(
2273 &mut self,
2274 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2275 self.refresh_workspace_settings_if_possible();
2276 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2277 let mut out = Vec::with_capacity(names.len());
2278 for name in names {
2279 let report = self.reload_one_mem_report(&name)?;
2280 out.push(report);
2281 }
2282 Ok(out)
2283 }
2284
2285 fn refresh_workspace_settings_if_possible(&mut self) {
2297 let Some(root) = self.workspace_root.clone() else {
2298 return;
2299 };
2300 let store = crate::workspace_store::FileWorkspaceStore::new();
2301 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2302 Ok(w) => w,
2303 Err(_) => return,
2304 };
2305 self.set_settings(workspace.settings);
2306 }
2307
2308 pub fn reload_each_writable_mem(
2324 &mut self,
2325 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2326 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2327 let mut sink = std::mem::take(&mut self.load_warnings);
2333 sink.clear();
2334 let mut out = Vec::with_capacity(names.len());
2335 let mut loop_err = None;
2336 for name in names {
2337 match self.reload_one_mem_inner(&name, &mut sink) {
2338 Ok(result) => out.push((name, result)),
2339 Err(e) => {
2340 loop_err = Some(e);
2341 break;
2342 }
2343 }
2344 }
2345 self.load_warnings = sink;
2346 if let Some(e) = loop_err {
2347 return Err(e);
2348 }
2349 Ok(out)
2350 }
2351}
2352
2353pub fn bump_backend_schema_pin(
2366 backend: &dyn crate::backend::MemBackend,
2367 target: &memstead_schema::SchemaRef,
2368) -> Result<Option<serde_json::Value>, EngineError> {
2369 let Some(bytes) = backend
2370 .read_mem_config()
2371 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2372 else {
2373 return Ok(None);
2374 };
2375 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2376 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2377 value["schema"] = serde_json::Value::String(target.as_display());
2378 let new_bytes = serde_json::to_vec_pretty(&value)
2379 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2380 backend
2381 .write_mem_config(&new_bytes)
2382 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2383 Ok(Some(value))
2384}
2385
2386#[cfg(test)]
2387mod tests {
2388
2389 use tempfile::TempDir;
2390
2391 use crate::backend::{BackendError, MemBackend};
2392 use crate::engine::test_helpers::*;
2393 use crate::engine::{Engine, EngineError};
2394 use crate::mem::MemOrigin;
2395 use crate::ops::WarningHint;
2396 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
2397
2398 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
2399 let manifest = format!(
2400 r#"name: {manifest_name}
2401version: 1.0.0
2402description: Install-gate test schema
2403when_to_use: Tests
2404types:
2405 - sample
2406relationships:
2407 mode: strict
2408 definitions:
2409 - name: PART_OF
2410 description: hier
2411 default_weight: 3.0
2412 - name: _default
2413 description: fallback
2414 default_weight: 1.0
2415community:
2416 resolution: 1.0
2417 seed: 42
2418"#
2419 );
2420 let type_yaml = format!(
2421 r#"name: sample
2422description: t
2423when_to_use: tests
2424sections:
2425 - key: body
2426 heading: {heading}
2427 required: true
2428 search_weight: 10.0
2429 catch_all: true
2430 write_rules: []
2431metadata_fields: []
2432title_weight: 100.0
2433text_fields:
2434 - body
2435hierarchy_relationship: PART_OF
2436no_self_loop_relationships: []
2437updatable_fields:
2438 - title
2439 - body
2440health_required_fields:
2441 - body
2442staleness_threshold_days: 90
2443write_rules: []
2444"#
2445 );
2446 vec![
2447 ("schema.yaml".to_string(), manifest.into_bytes()),
2448 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2449 ]
2450 }
2451
2452 #[test]
2456 fn install_gate_refuses_non_roundtrip_heading() {
2457 let ok =
2458 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
2459 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
2460
2461 let err = Engine::validate_schema_package(
2462 "gate",
2463 "1.0.0",
2464 &schema_package_files("Body Text", "gate"),
2465 )
2466 .expect_err("non-deriving heading must refuse install");
2467 match &err {
2468 EngineError::SchemaPackageInvalid { name, message, .. } => {
2469 assert_eq!(name, "gate");
2470 assert!(
2471 message.contains("'body'") && message.contains("'Body Text'"),
2472 "message names the offending tuple: {message}"
2473 );
2474 }
2475 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2476 }
2477 }
2478
2479 fn exemplar_package_files(
2486 section_key: &str,
2487 status_value: &str,
2488 rel_type: &str,
2489 ) -> Vec<(String, Vec<u8>)> {
2490 let manifest = r#"name: gate
2491version: 1.0.0
2492description: exemplar gate fixture
2493when_to_use: tests
2494types:
2495 - sample
2496 - other
2497relationships:
2498 mode: strict
2499 definitions:
2500 - name: PART_OF
2501 description: hier
2502 default_weight: 3.0
2503 - name: REFINES
2504 description: pinned
2505 default_weight: 1.0
2506 source_types: [other]
2507 - name: _default
2508 description: fallback
2509 default_weight: 1.0
2510community:
2511 resolution: 1.0
2512 seed: 42
2513"#
2514 .to_string();
2515 let type_yaml = format!(
2516 r#"name: sample
2517description: t
2518when_to_use: tests
2519sections:
2520 - key: body
2521 heading: Body
2522 required: true
2523 search_weight: 10.0
2524 catch_all: true
2525 write_rules: []
2526metadata_fields:
2527 - key: status
2528 description: workflow state
2529 field_type: string
2530 enum_values: [draft, final]
2531title_weight: 100.0
2532text_fields:
2533 - body
2534hierarchy_relationship: PART_OF
2535no_self_loop_relationships: []
2536updatable_fields:
2537 - title
2538 - body
2539health_required_fields:
2540 - body
2541staleness_threshold_days: 90
2542write_rules: []
2543exemplar:
2544 title: A Conforming Sample
2545 metadata:
2546 status: "{status_value}"
2547 sections:
2548 {section_key}: "One canonical body paragraph."
2549 relations:
2550 - to: parent-placeholder
2551 type: {rel_type}
2552"#
2553 );
2554 let other_yaml = r#"name: other
2555description: shape-pin partner
2556when_to_use: tests
2557sections:
2558 - key: body
2559 heading: Body
2560 required: true
2561 search_weight: 10.0
2562 catch_all: true
2563 write_rules: []
2564metadata_fields: []
2565title_weight: 100.0
2566text_fields:
2567 - body
2568hierarchy_relationship: PART_OF
2569no_self_loop_relationships: []
2570updatable_fields:
2571 - title
2572 - body
2573health_required_fields:
2574 - body
2575staleness_threshold_days: 90
2576write_rules: []
2577"#
2578 .to_string();
2579 vec![
2580 ("schema.yaml".to_string(), manifest.into_bytes()),
2581 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2582 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
2583 ]
2584 }
2585
2586 #[test]
2593 fn install_gate_validates_exemplars_through_the_real_create_path() {
2594 let ok = Engine::validate_schema_package(
2596 "gate",
2597 "1.0.0",
2598 &exemplar_package_files("body", "draft", "PART_OF"),
2599 );
2600 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
2601
2602 let err = Engine::validate_schema_package(
2604 "gate",
2605 "1.0.0",
2606 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
2607 )
2608 .expect_err("wrong section key must refuse");
2609 match &err {
2610 EngineError::SchemaPackageInvalid { message, .. } => {
2611 assert!(
2612 message.contains("'sample'") && message.contains("exemplar"),
2613 "names type and calls out the exemplar: {message}"
2614 );
2615 assert!(
2616 message.contains("UNKNOWN_SECTION")
2617 || message.contains("MISSING_REQUIRED_SECTION"),
2618 "carries the typed defect code: {message}"
2619 );
2620 }
2621 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2622 }
2623
2624 let err = Engine::validate_schema_package(
2626 "gate",
2627 "1.0.0",
2628 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
2629 )
2630 .expect_err("illegal enum value must refuse");
2631 assert!(
2632 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2633 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
2634 "got {err:?}"
2635 );
2636
2637 let err = Engine::validate_schema_package(
2640 "gate",
2641 "1.0.0",
2642 &exemplar_package_files("body", "draft", "REFINES"),
2643 )
2644 .expect_err("relationship shape violation must refuse");
2645 assert!(
2646 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2647 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
2648 "got {err:?}"
2649 );
2650 }
2651
2652 #[test]
2657 fn worked_example_package_exemplars_validate() {
2658 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2659 .join("../memstead-schema/examples/minimal");
2660 let schema = std::sync::Arc::new(
2661 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
2662 );
2663 assert!(
2664 schema.types.values().all(|td| td.exemplar.is_some()),
2665 "every worked-example type models the exemplar practice"
2666 );
2667 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
2668 }
2669
2670 #[test]
2677 fn builtin_exemplars_validate_through_the_install_gate() {
2678 let schemas = memstead_schema::builtins::load_builtin_schemas()
2679 .expect("built-in schemas always load");
2680 for schema in &schemas {
2682 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
2683 let (name, version) = schema.id();
2684 panic!("built-in {name}@{version}: {defect}");
2685 }
2686 }
2687 let mut newest: std::collections::HashMap<
2689 String,
2690 &std::sync::Arc<memstead_schema::Schema>,
2691 > = std::collections::HashMap::new();
2692 for schema in &schemas {
2693 let name = schema.manifest.name.clone();
2694 match newest.get(&name) {
2695 Some(cur) if cur.version >= schema.version => {}
2696 _ => {
2697 newest.insert(name, schema);
2698 }
2699 }
2700 }
2701 for (name, schema) in &newest {
2702 for (type_name, td) in &schema.types {
2703 assert!(
2704 td.exemplar.is_some(),
2705 "built-in {name}@{} type '{type_name}' has no exemplar — the \
2706 reference schemas model the practice completely",
2707 schema.version
2708 );
2709 }
2710 }
2711 }
2712
2713 #[test]
2718 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
2719 let mut files = exemplar_package_files("body", "draft", "PART_OF");
2720 let patched = String::from_utf8(files[1].1.clone())
2721 .unwrap()
2722 .replace("to: parent-placeholder", "to: other--real-entity");
2723 files[1].1 = patched.into_bytes();
2724 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
2725 .expect_err("mem-prefixed exemplar target must refuse");
2726 assert!(
2727 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2728 if message.contains("bare") && message.contains("'sample'")),
2729 "got {err:?}"
2730 );
2731 }
2732
2733 #[test]
2737 fn install_gate_refuses_manifest_identity_mismatch() {
2738 let err = Engine::validate_schema_package(
2739 "gate",
2740 "1.0.0",
2741 &schema_package_files("Body", "other"),
2742 )
2743 .expect_err("identity mismatch must refuse install");
2744 assert!(
2745 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2746 if message.contains("other@1.0.0")),
2747 "got {err:?}"
2748 );
2749 }
2750
2751 #[test]
2752 fn reload_each_writable_mem_repopulates_load_warnings() {
2753 let tmp = TempDir::new().unwrap();
2757 let mem_dir = tmp.path().to_path_buf();
2758 let writer = FilesystemMemWriter::new(mem_dir.clone());
2759 let mut mount = folder_mount("specs", mem_dir.clone());
2762 mount.schema = Some("default@1.3.0".parse().unwrap());
2763 let mut engine =
2764 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2765 assert!(
2766 engine.load_warnings().is_empty(),
2767 "clean boot has no warnings"
2768 );
2769
2770 let body =
2772 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
2773 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
2774
2775 engine.reload_each_writable_mem().unwrap();
2776 let warnings = engine.load_warnings();
2777 assert!(
2778 warnings
2779 .iter()
2780 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
2781 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
2782 );
2783 }
2784
2785 #[test]
2792 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
2793 let tmp = TempDir::new().unwrap();
2794 let mem_dir = tmp.path().to_path_buf();
2795 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
2797 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
2798 let writer = FilesystemMemWriter::new(mem_dir.clone());
2799 let mut engine = Engine::from_mounts(vec![(
2800 folder_mount("specs", mem_dir.clone()),
2801 Box::new(writer) as Box<dyn MemBackend>,
2802 )])
2803 .unwrap();
2804 assert!(
2806 !engine
2807 .load_warnings()
2808 .iter()
2809 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
2810 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
2811 engine.load_warnings()
2812 );
2813
2814 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";
2816 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
2817
2818 engine.reload_each_writable_mem().unwrap();
2819
2820 let invalid: Vec<_> = engine
2821 .load_warnings()
2822 .iter()
2823 .filter_map(|w| match w {
2824 WarningHint::ParsedRelationInvalid {
2825 rel_type,
2826 reason,
2827 origin,
2828 ..
2829 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
2830 _ => None,
2831 })
2832 .collect();
2833 assert_eq!(
2834 invalid.len(),
2835 1,
2836 "reload must surface the parse-time drift, got: {invalid:?}",
2837 );
2838 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
2839 assert_eq!(invalid[0].1, "unknown_rel_type");
2840 assert_eq!(invalid[0].2, "writable");
2841 }
2842
2843 #[test]
2844 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
2845 let tmp = TempDir::new().unwrap();
2852 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
2853 let a_dir = tmp.path().join("a");
2854 std::fs::create_dir_all(&a_dir).unwrap();
2855 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
2856 let b_dir = tmp.path().join("b");
2857 std::fs::create_dir_all(&b_dir).unwrap();
2858 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
2859 let mut engine = Engine::from_mounts(vec![
2860 (
2861 folder_mount("alpha", a_dir.clone()),
2862 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
2863 ),
2864 (
2865 folder_mount("beta", b_dir.clone()),
2866 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
2867 ),
2868 ])
2869 .unwrap();
2870 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
2871 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
2872 assert!(
2873 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
2874 "boot must populate one warning per mem: {pre:?}"
2875 );
2876
2877 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
2879 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
2880 engine.reload_one_mem("alpha").unwrap();
2881
2882 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
2883 assert!(
2884 !post.contains(&"alpha".to_string()),
2885 "reload must drop the healed mem's stale warning: {post:?}"
2886 );
2887 assert!(
2888 post.contains(&"beta".to_string()),
2889 "reload of alpha must not clear beta's slice: {post:?}"
2890 );
2891 }
2892
2893 #[test]
2894 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
2895 let tmp = TempDir::new().unwrap();
2900 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
2901 let a_dir = tmp.path().join("a");
2902 std::fs::create_dir_all(&a_dir).unwrap();
2903 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
2904 let b_dir = tmp.path().join("b");
2905 std::fs::create_dir_all(&b_dir).unwrap();
2906 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
2907 let mut engine = Engine::from_mounts(vec![
2908 (
2909 folder_mount("alpha", a_dir.clone()),
2910 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
2911 ),
2912 (
2913 folder_mount("beta", b_dir.clone()),
2914 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
2915 ),
2916 ])
2917 .unwrap();
2918 assert!(
2919 engine
2920 .load_warnings()
2921 .iter()
2922 .any(|w| w.source_mem() == Some("alpha")),
2923 "boot must carry alpha-sourced warnings"
2924 );
2925
2926 engine.unregister_writable_mem("alpha").unwrap();
2927
2928 let post = engine.load_warnings();
2929 assert!(
2930 !post.iter().any(|w| w.source_mem() == Some("alpha")),
2931 "delete must purge the removed mem's warnings: {post:?}"
2932 );
2933 assert!(
2934 post.iter().any(|w| w.source_mem() == Some("beta")),
2935 "delete of alpha must keep beta's warnings: {post:?}"
2936 );
2937 }
2938
2939 #[test]
2940 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
2941 let tmp = TempDir::new().unwrap();
2947 let a_dir = tmp.path().join("a");
2948 std::fs::create_dir_all(&a_dir).unwrap();
2949 let source_body = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[beta--b1]]\n";
2950 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
2951 let b_dir = tmp.path().join("b");
2952 std::fs::create_dir_all(&b_dir).unwrap();
2953 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
2954 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
2955 let mut engine = Engine::from_mounts(vec![
2956 (
2957 folder_mount("alpha", a_dir.clone()),
2958 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
2959 ),
2960 (
2961 folder_mount("beta", b_dir.clone()),
2962 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
2963 ),
2964 ])
2965 .unwrap();
2966 let alpha_sourced = |engine: &Engine| {
2967 engine
2968 .load_warnings()
2969 .iter()
2970 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
2971 };
2972 assert!(
2973 alpha_sourced(&engine),
2974 "boot must flag alpha's invalid row: {:?}",
2975 engine.load_warnings()
2976 );
2977
2978 engine.unregister_writable_mem("beta").unwrap();
2979
2980 assert!(
2981 alpha_sourced(&engine),
2982 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
2983 engine.load_warnings()
2984 );
2985 }
2986
2987 #[test]
2995 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
2996 let tmp = TempDir::new().unwrap();
2997 let mem_dir = tmp.path().to_path_buf();
2998 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
2999 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3000 let writer = FilesystemMemWriter::new(mem_dir.clone());
3001 let mut engine = Engine::from_mounts(vec![(
3002 folder_mount("specs", mem_dir.clone()),
3003 Box::new(writer) as Box<dyn MemBackend>,
3004 )])
3005 .unwrap();
3006 assert!(
3007 !engine.load_warnings().is_empty(),
3008 "boot must populate load_warnings"
3009 );
3010
3011 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3014 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3015 engine.reload_each_writable_mem_reports().unwrap();
3016 assert!(
3017 engine.load_warnings().is_empty(),
3018 "reports sweep must drop healed warnings: {:?}",
3019 engine.load_warnings()
3020 );
3021
3022 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3024 engine.reload_each_writable_mem_reports().unwrap();
3025 assert!(
3026 engine
3027 .load_warnings()
3028 .iter()
3029 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3030 "reports sweep must surface fresh drift: {:?}",
3031 engine.load_warnings()
3032 );
3033 }
3034
3035 #[test]
3043 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3044 let tmp = TempDir::new().unwrap();
3045 let a_dir = tmp.path().join("a");
3046 let b_dir = tmp.path().join("b");
3047 std::fs::create_dir_all(&a_dir).unwrap();
3048 std::fs::create_dir_all(&b_dir).unwrap();
3049 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3050 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3051 let mut engine = Engine::from_mounts(vec![
3052 (
3053 folder_mount("specs", a_dir),
3054 Box::new(a_writer) as Box<dyn MemBackend>,
3055 ),
3056 (
3057 folder_mount("memos", b_dir),
3058 Box::new(b_writer) as Box<dyn MemBackend>,
3059 ),
3060 ])
3061 .unwrap();
3062
3063 let mut settings = crate::workspace::WorkspaceSettings::default();
3065 settings.cross_mem_links.insert(
3066 "specs".to_string(),
3067 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3068 );
3069 engine.set_settings(settings);
3070
3071 let (actor, client) = cli_actor();
3072 let source = engine
3073 .create_entity(
3074 empty_create_args("specs", "Source"),
3075 actor,
3076 Some(&client),
3077 None,
3078 )
3079 .unwrap();
3080 let target = engine
3081 .create_entity(
3082 empty_create_args("memos", "Target"),
3083 actor,
3084 Some(&client),
3085 None,
3086 )
3087 .unwrap();
3088 engine
3089 .relate_entity(
3090 crate::engine::RelateEntityArgs {
3091 source: source.id.clone(),
3092 expected_hash: Some(source.content_hash.clone()),
3093 rel_type: "USES".to_string(),
3094 target: target.id.clone(),
3095 remove: false,
3096 description: None,
3097 dry_run: false,
3098 },
3099 actor,
3100 Some(&client),
3101 None,
3102 )
3103 .unwrap();
3104
3105 let has_edge = |e: &Engine| {
3107 let out = e
3108 .store()
3109 .outgoing(&source.id)
3110 .iter()
3111 .any(|edge| edge.target == target.id);
3112 let inc = e
3113 .store()
3114 .incoming(&target.id)
3115 .iter()
3116 .any(|edge| edge.from == source.id);
3117 (out, inc)
3118 };
3119
3120 assert_eq!(
3121 has_edge(&engine),
3122 (true, true),
3123 "edge must be indexed in both directions after relate",
3124 );
3125
3126 engine.reload_one_mem("memos").unwrap();
3128 assert_eq!(
3129 has_edge(&engine),
3130 (true, true),
3131 "cross-mem edge into B must survive a per-mem reload of B",
3132 );
3133
3134 engine.reload_each_writable_mem().unwrap();
3137 assert_eq!(
3138 has_edge(&engine),
3139 (true, true),
3140 "per-mem and workspace reload converge on the same edge",
3141 );
3142
3143 assert!(
3146 engine
3147 .store()
3148 .get(&source.id)
3149 .unwrap()
3150 .relationships
3151 .iter()
3152 .any(|r| r.target == target.id),
3153 "source record must retain the relationship throughout",
3154 );
3155 }
3156
3157 #[test]
3163 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3164 let tmp = TempDir::new().unwrap();
3165 let a_dir = tmp.path().join("a");
3166 let b_dir = tmp.path().join("b");
3167 std::fs::create_dir_all(&a_dir).unwrap();
3168 std::fs::create_dir_all(&b_dir).unwrap();
3169 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3170 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3171 let mut engine = Engine::from_mounts(vec![
3172 (
3173 folder_mount("specs", a_dir),
3174 Box::new(a_writer) as Box<dyn MemBackend>,
3175 ),
3176 (
3177 folder_mount("memos", b_dir),
3178 Box::new(b_writer) as Box<dyn MemBackend>,
3179 ),
3180 ])
3181 .unwrap();
3182 let mut settings = crate::workspace::WorkspaceSettings::default();
3183 settings.cross_mem_links.insert(
3184 "specs".to_string(),
3185 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3186 );
3187 engine.set_settings(settings);
3188
3189 let (actor, client) = cli_actor();
3190 let source = engine
3191 .create_entity(
3192 empty_create_args("specs", "Source"),
3193 actor,
3194 Some(&client),
3195 None,
3196 )
3197 .unwrap();
3198 let target = engine
3199 .create_entity(
3200 empty_create_args("memos", "Target"),
3201 actor,
3202 Some(&client),
3203 None,
3204 )
3205 .unwrap();
3206 engine
3207 .relate_entity(
3208 crate::engine::RelateEntityArgs {
3209 source: source.id.clone(),
3210 expected_hash: Some(source.content_hash.clone()),
3211 rel_type: "USES".to_string(),
3212 target: target.id.clone(),
3213 remove: false,
3214 description: None,
3215 dry_run: false,
3216 },
3217 actor,
3218 Some(&client),
3219 None,
3220 )
3221 .unwrap();
3222
3223 engine.reload_one_mem("specs").unwrap();
3224
3225 let out = engine
3226 .store()
3227 .outgoing(&source.id)
3228 .iter()
3229 .any(|edge| edge.target == target.id);
3230 let inc = engine
3231 .store()
3232 .incoming(&target.id)
3233 .iter()
3234 .any(|edge| edge.from == source.id);
3235 assert!(
3236 out && inc,
3237 "outgoing cross-mem edge must survive a source-mem reload"
3238 );
3239 }
3240
3241 #[test]
3242 fn workspace_root_setter_round_trips() {
3243 let tmp = TempDir::new().unwrap();
3244 let mem_dir = tmp.path().to_path_buf();
3245 let writer = FilesystemMemWriter::new(mem_dir.clone());
3246 let mut engine = Engine::from_mounts(vec![(
3247 folder_mount("specs", mem_dir),
3248 Box::new(writer) as Box<dyn MemBackend>,
3249 )])
3250 .unwrap();
3251 let root = tmp.path().to_path_buf();
3252 engine.set_workspace_root(root.clone());
3253 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3254 }
3255
3256 #[test]
3257 fn export_mem_folder_backend_produces_archive() {
3258 let tmp = TempDir::new().unwrap();
3262 let mem_dir = tmp.path().join("specs");
3263 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3264 let config_body = r#"{
3265 "format": 1,
3266 "schema": "default@1.0.0",
3267 "version": "1.0.0"
3268 }"#;
3269 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3270
3271 let writer = FilesystemMemWriter::new(mem_dir.clone());
3272 let engine = Engine::from_mounts(vec![(
3273 folder_mount("specs", mem_dir.clone()),
3274 Box::new(writer) as Box<dyn MemBackend>,
3275 )])
3276 .unwrap();
3277
3278 let archive_path = tmp.path().join("specs.mem");
3279 let result = engine.export_mem("specs", &archive_path).unwrap();
3280 assert!(archive_path.exists(), "archive must exist on disk");
3281 assert!(result.size_bytes > 0);
3282 assert_eq!(result.entity_count, 0);
3285 }
3286
3287 #[test]
3288 fn export_mem_unknown_mem_returns_unknown_mem() {
3289 let tmp = TempDir::new().unwrap();
3290 let mem_dir = tmp.path().to_path_buf();
3291 let writer = FilesystemMemWriter::new(mem_dir.clone());
3292 let engine = Engine::from_mounts(vec![(
3293 folder_mount("specs", mem_dir),
3294 Box::new(writer) as Box<dyn MemBackend>,
3295 )])
3296 .unwrap();
3297 let output = tmp.path().join("out.mem");
3298 let err = engine.export_mem("missing", &output).unwrap_err();
3299 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3300 }
3301
3302 #[test]
3303 fn export_mem_missing_config_returns_invalid_input() {
3304 let tmp = TempDir::new().unwrap();
3308 let mem_dir = tmp.path().to_path_buf();
3309 let writer = FilesystemMemWriter::new(mem_dir.clone());
3310 let engine = Engine::from_mounts(vec![(
3311 folder_mount("specs", mem_dir),
3312 Box::new(writer) as Box<dyn MemBackend>,
3313 )])
3314 .unwrap();
3315 let output = tmp.path().join("out.mem");
3316 let err = engine.export_mem("specs", &output).unwrap_err();
3317 assert!(matches!(err, EngineError::InvalidInput(_)));
3318 }
3319
3320 #[test]
3321 fn export_mem_archive_backend_returns_sealed() {
3322 let tmp = TempDir::new().unwrap();
3325 let archive_path = build_archive(
3326 tmp.path(),
3327 "ext",
3328 &[(
3329 ".memstead/config.json",
3330 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3331 )],
3332 );
3333 let engine = Engine::from_mounts(vec![(
3334 archive_mount("ext", archive_path.clone()),
3335 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3336 )])
3337 .unwrap();
3338 let output = tmp.path().join("out.mem");
3339 let err = engine.export_mem("ext", &output).unwrap_err();
3340 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3341 }
3342
3343 #[test]
3344 fn export_markdown_writes_unchanged_files_zero_writes() {
3345 let tmp = TempDir::new().unwrap();
3350 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3351 let result = engine.export_markdown(None, None).unwrap();
3352 assert_eq!(
3353 result.written, 0,
3354 "freshly-created entity's file already matches generated markdown"
3355 );
3356 assert_eq!(
3357 result.unchanged, 1,
3358 "the one seeded entity counts as unchanged"
3359 );
3360 assert!(
3361 result.skipped_mounts.is_empty(),
3362 "folder-only workspace has no skipped mounts"
3363 );
3364 }
3365
3366 #[test]
3367 fn export_markdown_skips_non_folder_mounts() {
3368 let tmp = TempDir::new().unwrap();
3372 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3373 let engine = Engine::from_mounts(vec![(
3374 archive_mount("ext", archive_path.clone()),
3375 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3376 )])
3377 .unwrap();
3378 let result = engine.export_markdown(None, None).unwrap();
3379 assert_eq!(result.written, 0);
3380 assert_eq!(result.unchanged, 0);
3381 assert_eq!(
3382 result.skipped_mounts.len(),
3383 1,
3384 "archive mount is in the skipped list"
3385 );
3386 let entry = &result.skipped_mounts[0];
3387 assert_eq!(entry.mem, "ext");
3388 assert_eq!(entry.active_backend, "archive");
3389 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
3390 }
3391
3392 #[test]
3393 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
3394 let tmp = TempDir::new().unwrap();
3398 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3399 let engine = Engine::from_mounts(vec![(
3400 archive_mount("ext", archive_path.clone()),
3401 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3402 )])
3403 .unwrap();
3404 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
3405 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
3406 let details = err.details();
3407 assert_eq!(details["mem"], "ext");
3408 assert_eq!(details["active_backend"], "archive");
3409 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
3410 }
3411
3412 #[test]
3413 fn register_writable_mem_adds_mount_and_router_entry() {
3414 let tmp = TempDir::new().unwrap();
3417 let mem_a = tmp.path().join("a");
3418 std::fs::create_dir_all(&mem_a).unwrap();
3419 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3420
3421 let mut engine = Engine::from_mounts(vec![(
3422 folder_mount("alpha", mem_a),
3423 Box::new(writer_a) as Box<dyn MemBackend>,
3424 )])
3425 .unwrap();
3426 assert!(engine.mem_router().is_writable("alpha"));
3427
3428 let mem_b = tmp.path().join("b");
3429 std::fs::create_dir_all(&mem_b).unwrap();
3430 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3431
3432 engine
3433 .register_writable_mem(
3434 folder_mount("beta", mem_b.clone()),
3435 Box::new(writer_b) as Box<dyn MemBackend>,
3436 MemOrigin::ExplicitToml,
3437 )
3438 .unwrap();
3439
3440 assert!(engine.mem_router().is_writable("alpha"));
3442 assert!(engine.mem_router().is_writable("beta"));
3443 assert!(engine.mem_router().is_visible("beta"));
3444
3445 assert!(engine.mount("beta").is_some());
3447 assert!(engine.schemas().contains_key("beta"));
3448
3449 assert_eq!(
3451 engine.mem_router().dir_for_mem("beta"),
3452 Some(mem_b.as_path()),
3453 );
3454 }
3455
3456 #[test]
3462 fn register_writable_mem_resolves_schema_from_mem_config() {
3463 let tmp = TempDir::new().unwrap();
3464 let mem_a = tmp.path().join("a");
3465 std::fs::create_dir_all(&mem_a).unwrap();
3466 let mut engine = Engine::from_mounts(vec![(
3467 folder_mount("alpha", mem_a.clone()),
3468 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
3469 )])
3470 .unwrap();
3471
3472 let mem_b = tmp.path().join("b");
3473 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
3474 std::fs::write(
3475 mem_b.join(".memstead").join("config.json"),
3476 r#"{"schema":"software@0.1.0"}"#,
3477 )
3478 .unwrap();
3479 let mount_b = crate::workspace::Mount {
3480 mem: "beta".to_string(),
3481 schema: Some(memstead_schema::SchemaRef::new(
3482 "totally-not-a-schema",
3483 semver::Version::new(9, 9, 9),
3484 )),
3485 storage: crate::workspace::MountStorage::Folder {
3486 path: mem_b.clone(),
3487 },
3488 capability: crate::workspace::MountCapability::Write,
3489 lifecycle: crate::workspace::MountLifecycle::Eager,
3490 cross_linkable: true,
3491 migration_target: None,
3492 };
3493 engine
3494 .register_writable_mem(
3495 mount_b,
3496 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
3497 MemOrigin::ExplicitToml,
3498 )
3499 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
3500
3501 assert!(engine.schemas().contains_key("beta"));
3502 let surfaced = engine.load_warnings().iter().any(|w| {
3503 matches!(
3504 w,
3505 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
3506 if mem == "beta"
3507 && config_pin == "software@0.1.0"
3508 && mount_pin == "totally-not-a-schema@9.9.9"
3509 )
3510 });
3511 assert!(
3512 surfaced,
3513 "SchemaPinMismatch must surface for beta: {:?}",
3514 engine.load_warnings(),
3515 );
3516 }
3517
3518 #[test]
3519 fn register_writable_mem_rejects_existing_name() {
3520 let tmp = TempDir::new().unwrap();
3523 let mem_a = tmp.path().join("a");
3524 std::fs::create_dir_all(&mem_a).unwrap();
3525 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3526
3527 let mut engine = Engine::from_mounts(vec![(
3528 folder_mount("alpha", mem_a),
3529 Box::new(writer_a) as Box<dyn MemBackend>,
3530 )])
3531 .unwrap();
3532 let mount_count_pre = engine.mounts().len();
3533
3534 let mem_collide = tmp.path().join("alpha-2");
3535 std::fs::create_dir_all(&mem_collide).unwrap();
3536 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
3537
3538 let err = engine
3539 .register_writable_mem(
3540 folder_mount("alpha", mem_collide),
3541 Box::new(writer_collide) as Box<dyn MemBackend>,
3542 MemOrigin::ExplicitToml,
3543 )
3544 .unwrap_err();
3545 match err {
3546 EngineError::MemNameCollision {
3547 name,
3548 source_origin,
3549 } => {
3550 assert_eq!(name, "alpha");
3551 assert!(
3556 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
3557 );
3558 }
3559 other => panic!("expected MemNameCollision, got {other:?}"),
3560 }
3561
3562 assert_eq!(engine.mounts().len(), mount_count_pre);
3564 }
3565
3566 #[test]
3567 fn register_writable_mem_loads_entities_into_store() {
3568 let tmp = TempDir::new().unwrap();
3571 let mem_a = tmp.path().join("a");
3572 std::fs::create_dir_all(&mem_a).unwrap();
3573 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3574
3575 let mut engine = Engine::from_mounts(vec![(
3576 folder_mount("alpha", mem_a),
3577 Box::new(writer_a) as Box<dyn MemBackend>,
3578 )])
3579 .unwrap();
3580 let pre_count = engine.store().all_entities().count();
3581
3582 let mem_b = tmp.path().join("b");
3584 std::fs::create_dir_all(&mem_b).unwrap();
3585 std::fs::write(
3586 mem_b.join("b1.md"),
3587 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
3588 )
3589 .unwrap();
3590 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3591
3592 engine
3593 .register_writable_mem(
3594 folder_mount("beta", mem_b),
3595 Box::new(writer_b) as Box<dyn MemBackend>,
3596 MemOrigin::ExplicitToml,
3597 )
3598 .unwrap();
3599
3600 let post_count = engine.store().all_entities().count();
3601 assert!(post_count > pre_count, "register must load entities");
3602 let beta_count = engine
3603 .store()
3604 .all_entities()
3605 .filter(|e| e.mem == "beta")
3606 .count();
3607 assert_eq!(beta_count, 1);
3608 }
3609
3610 #[test]
3611 fn register_then_unregister_round_trips() {
3612 let tmp = TempDir::new().unwrap();
3616 let mem_a = tmp.path().join("a");
3617 std::fs::create_dir_all(&mem_a).unwrap();
3618 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3619
3620 let mut engine = Engine::from_mounts(vec![(
3621 folder_mount("alpha", mem_a),
3622 Box::new(writer_a) as Box<dyn MemBackend>,
3623 )])
3624 .unwrap();
3625 let pre_mounts = engine.mounts().len();
3626
3627 let mem_b = tmp.path().join("b");
3628 std::fs::create_dir_all(&mem_b).unwrap();
3629 let writer_b = FilesystemMemWriter::new(mem_b);
3630
3631 engine
3632 .register_writable_mem(
3633 folder_mount("beta", tmp.path().join("b")),
3634 Box::new(writer_b) as Box<dyn MemBackend>,
3635 MemOrigin::ExplicitToml,
3636 )
3637 .unwrap();
3638 assert_eq!(engine.mounts().len(), pre_mounts + 1);
3639
3640 let removed = engine.unregister_writable_mem("beta").unwrap();
3641 assert!(removed.is_some());
3642 assert_eq!(engine.mounts().len(), pre_mounts);
3643 assert!(!engine.mem_router().is_writable("beta"));
3644 }
3645
3646 #[test]
3647 fn unregister_writable_mem_returns_false_for_unknown_name() {
3648 let tmp = TempDir::new().unwrap();
3652 let mem_dir = tmp.path().to_path_buf();
3653 let writer = FilesystemMemWriter::new(mem_dir.clone());
3654 let mut engine = Engine::from_mounts(vec![(
3655 folder_mount("specs", mem_dir),
3656 Box::new(writer) as Box<dyn MemBackend>,
3657 )])
3658 .unwrap();
3659 let removed = engine.unregister_writable_mem("missing").unwrap();
3660 assert!(removed.is_none(), "unknown mem returns Ok(None)");
3661 assert!(engine.mem_router().is_writable("specs"));
3663 }
3664
3665 #[test]
3666 fn unregister_writable_mem_drops_mount_and_router_entry() {
3667 let tmp = TempDir::new().unwrap();
3672 let mem_a = tmp.path().join("a");
3673 std::fs::create_dir_all(&mem_a).unwrap();
3674 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3675 let mem_b = tmp.path().join("b");
3676 std::fs::create_dir_all(&mem_b).unwrap();
3677 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3678
3679 let mut engine = Engine::from_mounts(vec![
3680 (
3681 folder_mount("alpha", mem_a),
3682 Box::new(writer_a) as Box<dyn MemBackend>,
3683 ),
3684 (
3685 folder_mount("beta", mem_b),
3686 Box::new(writer_b) as Box<dyn MemBackend>,
3687 ),
3688 ])
3689 .unwrap();
3690
3691 let removed = engine.unregister_writable_mem("alpha").unwrap();
3692 assert!(removed.is_some());
3693
3694 assert!(!engine.mem_router().is_writable("alpha"));
3696 assert!(!engine.mem_router().is_visible("alpha"));
3697 assert!(engine.mount("alpha").is_none());
3698
3699 assert!(engine.mem_router().is_writable("beta"));
3701 assert!(engine.mount("beta").is_some());
3702 }
3703
3704 #[test]
3705 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
3706 let tmp = TempDir::new().unwrap();
3710 let mem_a = tmp.path().join("a");
3711 std::fs::create_dir_all(&mem_a).unwrap();
3712 std::fs::write(
3713 mem_a.join("a1.md"),
3714 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
3715 )
3716 .unwrap();
3717 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3718
3719 let mem_b = tmp.path().join("b");
3720 std::fs::create_dir_all(&mem_b).unwrap();
3721 std::fs::write(
3722 mem_b.join("b1.md"),
3723 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
3724 )
3725 .unwrap();
3726 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3727
3728 let mut engine = Engine::from_mounts(vec![
3729 (
3730 folder_mount("alpha", mem_a),
3731 Box::new(writer_a) as Box<dyn MemBackend>,
3732 ),
3733 (
3734 folder_mount("beta", mem_b),
3735 Box::new(writer_b) as Box<dyn MemBackend>,
3736 ),
3737 ])
3738 .unwrap();
3739
3740 let pre_total = engine.store().all_entities().count();
3741 assert!(pre_total >= 2, "both mems must load entities");
3742
3743 engine.unregister_writable_mem("alpha").unwrap();
3744
3745 let alpha_remaining = engine
3747 .store()
3748 .all_entities()
3749 .filter(|e| e.mem == "alpha")
3750 .count();
3751 assert_eq!(alpha_remaining, 0);
3752
3753 let beta_remaining = engine
3755 .store()
3756 .all_entities()
3757 .filter(|e| e.mem == "beta")
3758 .count();
3759 assert!(beta_remaining > 0, "beta entities must survive");
3760 }
3761 #[test]
3762 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
3763 let tmp = TempDir::new().unwrap();
3764 let mut engine = build_demo_engine(&tmp);
3765 let result = engine
3766 .reload_one_mem("specs")
3767 .expect("reload on stable disk must succeed");
3768 assert!(result.added.is_empty(), "added: {:?}", result.added);
3769 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
3770 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
3771 }
3772
3773 #[test]
3774 fn reload_one_mem_picks_up_external_addition() {
3775 let tmp = TempDir::new().unwrap();
3776 let mut engine = build_demo_engine(&tmp);
3777 std::fs::write(
3780 tmp.path().join("external.md"),
3781 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
3782 )
3783 .unwrap();
3784 let result = engine.reload_one_mem("specs").unwrap();
3785 assert_eq!(
3786 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
3787 vec!["specs--external"]
3788 );
3789 assert!(result.changed.is_empty());
3790 assert!(result.removed.is_empty());
3791 assert!(
3793 engine
3794 .get_entity(&crate::EntityId::new("specs", "external"))
3795 .is_some()
3796 );
3797 }
3798
3799 #[test]
3800 fn reload_one_mem_picks_up_external_removal() {
3801 let tmp = TempDir::new().unwrap();
3802 let mut engine = build_demo_engine(&tmp);
3803 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
3806 let result = engine.reload_one_mem("specs").unwrap();
3807 assert!(result.added.is_empty());
3808 assert!(result.changed.is_empty());
3809 assert_eq!(
3810 result
3811 .removed
3812 .iter()
3813 .map(|i| i.as_ref())
3814 .collect::<Vec<_>>(),
3815 vec!["specs--lonely-three"]
3816 );
3817 }
3818
3819 #[test]
3820 fn reload_one_mem_picks_up_external_change() {
3821 let tmp = TempDir::new().unwrap();
3822 let mut engine = build_demo_engine(&tmp);
3823 std::fs::write(
3826 tmp.path().join("source-one.md"),
3827 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
3828 )
3829 .unwrap();
3830 let result = engine.reload_one_mem("specs").unwrap();
3831 assert!(result.added.is_empty());
3832 assert_eq!(
3833 result
3834 .changed
3835 .iter()
3836 .map(|i| i.as_ref())
3837 .collect::<Vec<_>>(),
3838 vec!["specs--source-one"]
3839 );
3840 assert!(result.removed.is_empty());
3841 }
3842
3843 #[test]
3844 fn reload_one_mem_rejects_unknown_mem() {
3845 let tmp = TempDir::new().unwrap();
3846 let mut engine = build_demo_engine(&tmp);
3847 let err = engine.reload_one_mem("nope").unwrap_err();
3848 match err {
3849 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
3850 other => panic!("expected UnknownMem, got {other:?}"),
3851 }
3852 }
3853
3854 #[test]
3855 fn reload_each_writable_mem_returns_one_entry_per_mount() {
3856 let tmp = TempDir::new().unwrap();
3857 let mut engine = build_demo_engine(&tmp);
3858 let reports = engine
3859 .reload_each_writable_mem()
3860 .expect("batch reload on stable disk must succeed");
3861 assert_eq!(reports.len(), 1);
3862 assert_eq!(reports[0].0, "specs");
3863 assert!(reports[0].1.added.is_empty());
3864 assert!(reports[0].1.changed.is_empty());
3865 assert!(reports[0].1.removed.is_empty());
3866 }
3867
3868 #[test]
3871 fn settings_default_to_empty_on_fresh_engine() {
3872 let tmp = TempDir::new().unwrap();
3873 let engine = build_demo_engine(&tmp);
3874 let s = engine.settings();
3875 assert!(s.mem_create_rules.is_empty());
3876 assert!(s.mem_delete_rules.is_empty());
3877 assert!(s.cross_mem_links.is_empty());
3878 }
3879
3880 #[test]
3881 fn set_settings_replaces_workspace_policy() {
3882 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
3883 let tmp = TempDir::new().unwrap();
3884 let mut engine = build_demo_engine(&tmp);
3885 let mut settings = WorkspaceSettings::default();
3886 settings.mem_create_rules.push(CreateRuleSetting {
3887 pattern: "exec-*".to_string(),
3888 schemas: vec!["default@1.0.0".to_string()],
3889 default_cross_links: None,
3890 });
3891 settings.mem_delete_rules.push(DeleteRuleSetting {
3892 pattern: "exec-*".to_string(),
3893 });
3894 engine.set_settings(settings);
3895 assert_eq!(engine.settings().mem_create_rules.len(), 1);
3896 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
3897 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
3898 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
3899 }
3900
3901 #[test]
3904 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
3905 let tmp = TempDir::new().unwrap();
3906 let mut engine = build_demo_engine(&tmp);
3907 std::fs::write(
3909 tmp.path().join("new-via-disk.md"),
3910 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
3911 )
3912 .unwrap();
3913 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
3914 std::fs::write(
3915 tmp.path().join("source-one.md"),
3916 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
3917 )
3918 .unwrap();
3919
3920 let reports = engine.reload_each_writable_mem().unwrap();
3921 assert_eq!(reports.len(), 1);
3922 let (mem, result) = &reports[0];
3923 assert_eq!(mem, "specs");
3924 assert_eq!(
3925 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
3926 vec!["specs--new-via-disk"]
3927 );
3928 assert_eq!(
3929 result
3930 .removed
3931 .iter()
3932 .map(|i| i.as_ref())
3933 .collect::<Vec<_>>(),
3934 vec!["specs--lonely-three"]
3935 );
3936 assert_eq!(
3937 result
3938 .changed
3939 .iter()
3940 .map(|i| i.as_ref())
3941 .collect::<Vec<_>>(),
3942 vec!["specs--source-one"]
3943 );
3944 }
3945
3946 #[test]
3949 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
3950 let tmp = TempDir::new().unwrap();
3958 let mut engine = build_demo_engine(&tmp);
3959 let report = engine.reload_one_mem_report("specs").unwrap();
3960 assert_eq!(report.mem, "specs");
3961 assert_eq!(
3962 report.head_before, report.head_after,
3963 "unchanged disk → stable cursor"
3964 );
3965 assert!(
3966 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
3967 "folder heads carry the changelog-ts cursor, got {}",
3968 report.head_after
3969 );
3970 assert_eq!(report.entities_loaded, 3);
3973 assert!(report.changed_entity_ids.is_empty());
3975 }
3976
3977 #[test]
3978 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
3979 let tmp = TempDir::new().unwrap();
3984 let mut engine = build_demo_engine(&tmp);
3985 std::fs::write(
3986 tmp.path().join("new-via-disk.md"),
3987 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
3988 )
3989 .unwrap();
3990 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
3991 std::fs::write(
3992 tmp.path().join("source-one.md"),
3993 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
3994 )
3995 .unwrap();
3996
3997 let report = engine.reload_one_mem_report("specs").unwrap();
3998 assert_eq!(report.mem, "specs");
3999 let ids: Vec<&str> = report
4000 .changed_entity_ids
4001 .iter()
4002 .map(|id| id.as_ref())
4003 .collect();
4004 assert_eq!(
4006 ids,
4007 vec![
4008 "specs--lonely-three",
4009 "specs--new-via-disk",
4010 "specs--source-one",
4011 ]
4012 );
4013 }
4014
4015 #[test]
4016 fn reload_one_mem_report_rejects_unknown_mem() {
4017 let tmp = TempDir::new().unwrap();
4018 let mut engine = build_demo_engine(&tmp);
4019 let err = engine.reload_one_mem_report("missing").unwrap_err();
4020 assert!(matches!(err, EngineError::UnknownMem(_)));
4021 }
4022
4023 #[test]
4024 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4025 let tmp = TempDir::new().unwrap();
4026 let mut engine = build_demo_engine(&tmp);
4027 let reports = engine.reload_each_writable_mem_reports().unwrap();
4028 assert_eq!(reports.len(), 1);
4029 assert_eq!(reports[0].mem, "specs");
4030 assert_eq!(reports[0].entities_loaded, 3);
4031 }
4032
4033 #[test]
4040 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4041 let tmp = TempDir::new().unwrap();
4042
4043 let memstead_dir = tmp.path().join(".memstead");
4046 std::fs::create_dir_all(&memstead_dir).unwrap();
4047 let workspace_toml = memstead_dir.join("workspace.toml");
4048 std::fs::write(
4049 &workspace_toml,
4050 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4051 )
4052 .unwrap();
4053 let mounts_json = memstead_dir.join("state").join("mounts.json");
4054 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4055 let mem_dir = tmp.path().join("specs");
4056 std::fs::create_dir_all(&mem_dir).unwrap();
4057 let mounts_body = format!(
4058 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4059 mem_dir.display(),
4060 );
4061 std::fs::write(&mounts_json, mounts_body).unwrap();
4062
4063 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4064 assert!(
4065 engine.settings().mem_create_rules.is_empty(),
4066 "boot-time settings carry no create rules"
4067 );
4068
4069 std::fs::write(
4071 &workspace_toml,
4072 "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",
4073 )
4074 .unwrap();
4075
4076 engine.reload_each_writable_mem_reports().unwrap();
4077
4078 let rules = &engine.settings().mem_create_rules;
4079 assert_eq!(
4080 rules.len(),
4081 1,
4082 "workspace-wide reload must refresh the policy"
4083 );
4084 assert_eq!(rules[0].pattern, "exec-*");
4085 }
4086
4087 const MIG_TYPE_TAIL: &str = r#"sections:
4092 - key: body
4093 heading: Body
4094 required: true
4095 search_weight: 10.0
4096 catch_all: true
4097 write_rules: []
4098title_weight: 100.0
4099text_fields:
4100 - body
4101hierarchy_relationship: _default
4102no_self_loop_relationships: []
4103updatable_fields: []
4104health_required_fields: []
4105staleness_threshold_days: 90
4106write_rules: []
4107"#;
4108
4109 fn mig_manifest(name: &str, version: &str) -> String {
4114 format!(
4115 r#"name: {name}
4116version: {version}
4117description: migration test schema
4118when_to_use: tests
4119types:
4120 - doc
4121relationships:
4122 mode: strict
4123 definitions:
4124 - name: USES
4125 description: link
4126 default_weight: 1.0
4127 - name: _default
4128 description: fallback
4129 default_weight: 1.0
4130community:
4131 resolution: 1.0
4132 seed: 42
4133"#
4134 )
4135 }
4136
4137 fn mig_type_yaml(with_status: bool) -> String {
4138 let metadata = if with_status {
4139 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4140 } else {
4141 "metadata_fields: []\n"
4142 };
4143 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4144 }
4145
4146 fn write_mig_schema(
4147 root: &std::path::Path,
4148 dir: &str,
4149 name: &str,
4150 version: &str,
4151 with_status: bool,
4152 ) {
4153 let d = root.join(dir);
4154 std::fs::create_dir_all(d.join("types")).unwrap();
4155 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4156 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4157 }
4158
4159 fn migration_engine() -> (tempfile::TempDir, Engine) {
4164 let tmp = tempfile::TempDir::new().unwrap();
4165 let schemas_dir = tmp.path().join("schemas");
4166 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4167 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4168 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
4169 let mem_dir = tmp.path().join("mem");
4170 std::fs::create_dir_all(&mem_dir).unwrap();
4171 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4172 let mut mount = folder_mount("specs", mem_dir);
4173 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4174 let mut engine = Engine::from_mounts_with_schemas_dir(
4175 vec![(
4176 mount,
4177 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4178 )],
4179 Some(&schemas_dir),
4180 )
4181 .unwrap();
4182 for title in ["One", "Two"] {
4183 let mut args = empty_create_args("specs", title);
4184 args.entity_type = "doc".to_string();
4185 args.sections =
4186 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
4187 engine
4188 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4189 .expect("conformant create under mig-a");
4190 }
4191 (tmp, engine)
4192 }
4193
4194 fn sref(s: &str) -> memstead_schema::SchemaRef {
4195 s.parse().unwrap()
4196 }
4197
4198 #[test]
4199 fn set_schema_noop_on_current_pin() {
4200 let (_tmp, mut engine) = migration_engine();
4201 let out = engine
4202 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
4203 .unwrap();
4204 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
4205 assert_eq!(out.schema_pin, "mig-a@0.1.0");
4206 assert_eq!(out.migration_target, None);
4207 assert!(out.findings.is_empty());
4208 }
4209
4210 #[test]
4211 fn set_schema_switches_immediately_when_integral() {
4212 let (_tmp, mut engine) = migration_engine();
4215 let out = engine
4216 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4217 .unwrap();
4218 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4219 assert_eq!(out.schema_pin, "mig-a@0.2.0");
4220 assert_eq!(out.migration_target, None);
4221 assert!(out.findings.is_empty());
4222 assert_eq!(
4223 engine.schema_pin("specs").unwrap().as_display(),
4224 "mig-a@0.2.0"
4225 );
4226 assert!(engine.migration_target("specs").is_none());
4227 }
4228
4229 #[test]
4235 fn set_schema_switch_persists_pin_into_backend_config() {
4236 let tmp = tempfile::TempDir::new().unwrap();
4237 let schemas_dir = tmp.path().join("schemas");
4238 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4239 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4240 let mem_dir = tmp.path().join("mem");
4241 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4242 std::fs::write(
4244 mem_dir.join(".memstead").join("config.json"),
4245 br#"{"schema":"mig-a@0.1.0"}"#,
4246 )
4247 .unwrap();
4248 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4249 let mut mount = folder_mount("specs", mem_dir.clone());
4250 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4251 let mut engine = Engine::from_mounts_with_schemas_dir(
4252 vec![(
4253 mount,
4254 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4255 )],
4256 Some(&schemas_dir),
4257 )
4258 .unwrap();
4259
4260 let out = engine
4261 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4262 .unwrap();
4263 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4264
4265 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
4268 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
4269 assert_eq!(
4270 cfg["schema"], "mig-a@0.2.0",
4271 "atomic switch must update the authoritative backend config"
4272 );
4273 }
4274
4275 #[test]
4276 fn set_schema_unknown_target_refuses_schema_not_found() {
4277 let (_tmp, mut engine) = migration_engine();
4278 let err = engine
4279 .set_mem_schema("specs", &sref("nope@9.9.9"))
4280 .unwrap_err();
4281 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
4282 assert!(engine.migration_target("specs").is_none());
4284 }
4285
4286 #[test]
4287 fn set_schema_migration_lifecycle_end_to_end() {
4288 let (_tmp, mut engine) = migration_engine();
4289 let target = sref("mig-b@0.1.0");
4290
4291 let out = engine.set_mem_schema("specs", &target).unwrap();
4293 assert_eq!(
4294 out.outcome,
4295 crate::engine::SetSchemaResult::MigrationStarted
4296 );
4297 assert_eq!(out.schema_pin, "mig-a@0.1.0");
4298 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
4299 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
4300 assert!(
4301 out.findings
4302 .iter()
4303 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
4304 );
4305
4306 let one = crate::entity::EntityId::new("specs", "one");
4308 assert!(engine.store().get(&one).is_some());
4309
4310 let out = engine.set_mem_schema("specs", &target).unwrap();
4312 assert_eq!(
4313 out.outcome,
4314 crate::engine::SetSchemaResult::MigrationPending
4315 );
4316 assert_eq!(out.findings.len(), 2);
4317
4318 let mut bad = crate::engine::UpdateEntityArgs {
4322 anchors: Vec::new(),
4323 id: one.clone(),
4324 expected_hash: None,
4325 sections: indexmap::IndexMap::new(),
4326 append_sections: indexmap::IndexMap::new(),
4327 patch_sections: indexmap::IndexMap::new(),
4328 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
4329 metadata_unset: Vec::new(),
4330 declare_relations: Vec::new(),
4331 dry_run: false,
4332 relations_unset: Vec::new(),
4333 anchors_unset: Vec::new(),
4334 };
4335 let err = engine
4336 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
4337 .unwrap_err();
4338 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
4339 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
4340 engine
4341 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
4342 .expect("repair write validated against the migration target");
4343
4344 let out = engine.set_mem_schema("specs", &target).unwrap();
4346 assert_eq!(
4347 out.outcome,
4348 crate::engine::SetSchemaResult::MigrationPending
4349 );
4350 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
4351
4352 let two = crate::entity::EntityId::new("specs", "two");
4354 let repair = crate::engine::UpdateEntityArgs {
4355 anchors: Vec::new(),
4356 id: two.clone(),
4357 expected_hash: None,
4358 sections: indexmap::IndexMap::new(),
4359 append_sections: indexmap::IndexMap::new(),
4360 patch_sections: indexmap::IndexMap::new(),
4361 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
4362 metadata_unset: Vec::new(),
4363 declare_relations: Vec::new(),
4364 dry_run: false,
4365 relations_unset: Vec::new(),
4366 anchors_unset: Vec::new(),
4367 };
4368 engine
4369 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
4370 .unwrap();
4371 let out = engine.set_mem_schema("specs", &target).unwrap();
4372 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4373 assert_eq!(out.schema_pin, "mig-b@0.1.0");
4374 assert_eq!(out.migration_target, None);
4375 assert!(out.findings.is_empty());
4376 assert_eq!(
4377 engine.schema_pin("specs").unwrap().as_display(),
4378 "mig-b@0.1.0"
4379 );
4380 assert!(engine.migration_target("specs").is_none());
4381 }
4382
4383 #[test]
4388 fn relations_unset_works_during_migration_without_mode_flag() {
4389 let (_tmp, mut engine) = migration_engine();
4390 let one = crate::entity::EntityId::new("specs", "one");
4391 let two = crate::entity::EntityId::new("specs", "two");
4392 engine
4393 .relate_entity(
4394 crate::engine::RelateEntityArgs {
4395 source: one.clone(),
4396 expected_hash: None,
4397 rel_type: "USES".to_string(),
4398 target: two.clone(),
4399 remove: false,
4400 description: None,
4401 dry_run: false,
4402 },
4403 crate::vcs::Actor::Cli,
4404 None,
4405 None,
4406 )
4407 .unwrap();
4408 let shut = engine
4410 .update_entity(
4411 crate::engine::UpdateEntityArgs {
4412 anchors: Vec::new(),
4413 id: one.clone(),
4414 expected_hash: None,
4415 sections: indexmap::IndexMap::new(),
4416 append_sections: indexmap::IndexMap::new(),
4417 patch_sections: indexmap::IndexMap::new(),
4418 metadata: indexmap::IndexMap::new(),
4419 metadata_unset: Vec::new(),
4420 declare_relations: Vec::new(),
4421 dry_run: false,
4422 relations_unset: vec![crate::ops::RelationUnsetArg {
4423 rel_type: "USES".to_string(),
4424 target: two.clone(),
4425 }],
4426 anchors_unset: Vec::new(),
4427 },
4428 crate::vcs::Actor::Cli,
4429 None,
4430 None,
4431 )
4432 .unwrap_err();
4433 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
4434
4435 engine
4439 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
4440 .unwrap();
4441 engine
4442 .update_entity(
4443 crate::engine::UpdateEntityArgs {
4444 anchors: Vec::new(),
4445 id: one.clone(),
4446 expected_hash: None,
4447 sections: indexmap::IndexMap::new(),
4448 append_sections: indexmap::IndexMap::new(),
4449 patch_sections: indexmap::IndexMap::new(),
4450 metadata: indexmap::IndexMap::from_iter([(
4451 "status".to_string(),
4452 "open".to_string(),
4453 )]),
4454 metadata_unset: Vec::new(),
4455 declare_relations: Vec::new(),
4456 dry_run: false,
4457 relations_unset: vec![crate::ops::RelationUnsetArg {
4458 rel_type: "USES".to_string(),
4459 target: two.clone(),
4460 }],
4461 anchors_unset: Vec::new(),
4462 },
4463 crate::vcs::Actor::Cli,
4464 None,
4465 None,
4466 )
4467 .expect("repair-shaped update lands during migration without a flag");
4468 let entity = engine.store().get(&one).unwrap();
4469 assert!(entity.relationships.is_empty());
4470 }
4471
4472 #[test]
4477 fn boot_resumes_dual_pin_validation_against_target() {
4478 let (tmp, engine) = migration_engine();
4479 drop(engine);
4480 let schemas_dir = tmp.path().join("schemas");
4481 let mem_dir = tmp.path().join("mem");
4482 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4483 let mut mount = folder_mount("specs", mem_dir);
4484 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4485 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
4486 let engine = Engine::from_mounts_with_schemas_dir(
4487 vec![(
4488 mount,
4489 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4490 )],
4491 Some(&schemas_dir),
4492 )
4493 .unwrap();
4494 let (name, version) = {
4496 let s = engine.schema_for("specs").unwrap();
4497 let (n, v) = s.id();
4498 (n.to_string(), v.to_string())
4499 };
4500 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
4501 assert_eq!(
4504 engine.schema_pin("specs").unwrap().as_display(),
4505 "mig-a@0.1.0"
4506 );
4507 assert_eq!(
4508 engine.migration_target("specs").unwrap().as_display(),
4509 "mig-b@0.1.0"
4510 );
4511 }
4512
4513 #[test]
4523 fn every_lifecycle_setter_refuses_on_read_only_mount() {
4524 let tmp = TempDir::new().unwrap();
4525 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4526 let mut engine = Engine::from_mounts(vec![(
4527 archive_mount("ext", archive_path.clone()),
4528 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4529 )])
4530 .unwrap();
4531
4532 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
4533 let attempts: Vec<(&str, EngineError)> = vec![
4534 (
4535 "set_mem_schema",
4536 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
4537 ),
4538 (
4539 "set_mem_version",
4540 engine
4541 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
4542 .unwrap_err(),
4543 ),
4544 (
4545 "set_mem_description",
4546 engine
4547 .set_mem_description("ext", Some("x".into()), None)
4548 .unwrap_err(),
4549 ),
4550 (
4551 "set_mem_title",
4552 engine
4553 .set_mem_title("ext", Some("x".into()), None)
4554 .unwrap_err(),
4555 ),
4556 (
4557 "set_mem_subject",
4558 engine.set_mem_subject("ext", None, None).unwrap_err(),
4559 ),
4560 (
4561 "set_mem_internal",
4562 engine.set_mem_internal("ext", true, None).unwrap_err(),
4563 ),
4564 (
4565 "set_mem_sync_state",
4566 engine
4567 .set_mem_sync_state("ext", "k", "t", None)
4568 .unwrap_err(),
4569 ),
4570 ];
4571 for (setter, err) in attempts {
4572 match err {
4573 EngineError::ReadOnlyMount(v) => {
4574 assert_eq!(v, "ext", "{setter} must name the refused mem")
4575 }
4576 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
4577 }
4578 }
4579 }
4580}