1use std::cell::OnceCell;
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use crate::backend::{BackendError, MemBackend};
19use crate::engine_fallback_type;
20use crate::entity::EntityId;
21use crate::entity::generator::generate_markdown;
22use crate::entity::loader::parse_entries;
23use crate::entity::store_builder::push_entities_into_store;
24use crate::mem::MemOrigin;
25use crate::ops::WarningHint;
26use crate::workspace::{Mount, MountStorage, WorkspaceSettings};
27
28use super::boot::collect_source_entries;
29use super::{BackendFactory, Engine, EngineError, GitBranchOps, MountedBackend};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum SchemaStaging {
34 AlreadyResolvable,
38 Staged,
41 NoEmbeddedSchema,
44}
45
46impl Engine {
47 pub fn set_settings(&mut self, settings: WorkspaceSettings) {
56 self.settings = settings;
57 self.create_rule_set_memo = OnceCell::new();
58 }
59
60 pub fn set_backend_factory(&mut self, factory: BackendFactory) {
66 self.backend_factory = factory;
67 }
68
69 pub fn set_mutation_clock(&mut self, clock: crate::engine::MutationClock) {
80 self.mutation_clock = clock;
81 }
82
83 pub fn set_role(&mut self, role: crate::vcs::Role) {
89 self.current_role = role;
90 }
91
92 pub fn current_role(&self) -> crate::vcs::Role {
94 self.current_role
95 }
96
97 pub(crate) fn now_iso(&self) -> String {
101 crate::engine::mutation::iso_from_system_time((self.mutation_clock)())
102 }
103
104 pub fn set_git_branch_ops(&mut self, ops: GitBranchOps) {
110 self.git_branch_ops = Some(ops);
111 }
112
113 pub fn install_schema(
128 &self,
129 name: &str,
130 version: &str,
131 files: &[(String, Vec<u8>)],
132 ) -> Result<String, EngineError> {
133 Self::validate_schema_package(name, version, files)?;
139 let gitdir = self
145 .mounts
146 .iter()
147 .find_map(|m| match &m.mount.storage {
148 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
149 _ => None,
150 })
151 .or_else(|| {
152 self.workspace_root()
153 .map(|r| r.join("mem-repo").join(".git"))
154 })
155 .ok_or_else(|| {
156 EngineError::Mem(
157 "schema install requires a mem-repo workspace (no git-branch mount and \
158 no workspace root to resolve the mem-repo gitdir)"
159 .to_string(),
160 )
161 })?;
162 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
163 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
164 })?;
165 let files = memstead_schema::loader::with_format_marker(files.to_vec());
169 (ops.write_schema)(&gitdir, name, version, &files).map_err(EngineError::Backend)
170 }
171
172 pub fn stage_sealed_schema(
200 &mut self,
201 mem: &str,
202 pin: &memstead_schema::SchemaRef,
203 files: &[(String, Vec<u8>)],
204 ) -> Result<SchemaStaging, EngineError> {
205 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
206 .workspace_schemas
207 .iter()
208 .chain(self.builtin_schemas.iter())
209 .cloned()
210 .collect();
211 if crate::engine::SchemaResolver::new(&catalogue)
212 .resolve(pin)
213 .is_ok()
214 {
215 return Ok(SchemaStaging::AlreadyResolvable);
216 }
217 if files.is_empty() {
218 return Ok(SchemaStaging::NoEmbeddedSchema);
222 }
223
224 let unloadable = |reason: String| EngineError::EmbeddedSchemaInvalid {
225 mem: mem.to_string(),
226 pin: pin.as_display(),
227 reason,
228 };
229 let schema =
232 memstead_schema::load_sealed_package(files).map_err(|e| unloadable(e.to_string()))?;
233 let (name, version) = schema.id();
234 if name != pin.name || version != pin.version {
235 return Err(unloadable(format!(
236 "the package declares '{name}@{version}' but the mem pins {}",
237 pin.as_display()
238 )));
239 }
240
241 self.write_to_local_schema_source(&name, &version.to_string(), files)?;
242 self.workspace_schemas.push(std::sync::Arc::new(schema));
243 Ok(SchemaStaging::Staged)
244 }
245
246 fn write_to_local_schema_source(
252 &self,
253 name: &str,
254 version: &str,
255 files: &[(String, Vec<u8>)],
256 ) -> Result<(), EngineError> {
257 let gitdir = self
258 .mounts
259 .iter()
260 .find_map(|m| match &m.mount.storage {
261 crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
262 _ => None,
263 })
264 .or_else(|| {
265 self.workspace_root()
266 .map(|r| r.join("mem-repo").join(".git"))
267 })
268 .filter(|g| g.is_dir())
269 .ok_or_else(|| {
270 EngineError::Mem(
271 "staging a schema requires a mem-repo workspace — the folder workspace's \
272 `.memstead/schemas/` is the authoring tier and never holds sealed \
273 third-party packages"
274 .to_string(),
275 )
276 })?;
277 let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
278 EngineError::Mem("git-branch ops are not wired on this engine".to_string())
279 })?;
280 (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)?;
281 Ok(())
282 }
283
284 pub fn validate_schema_package(
295 name: &str,
296 version: &str,
297 files: &[(String, Vec<u8>)],
298 ) -> Result<(), EngineError> {
299 let invalid = |message: String| EngineError::SchemaPackageInvalid {
300 name: name.to_string(),
301 version: version.to_string(),
302 message,
303 };
304 let manifest_yaml = files
305 .iter()
306 .find(|(rel, _)| rel == "schema.yaml")
307 .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned())
308 .ok_or_else(|| invalid("package has no schema.yaml".to_string()))?;
309 let types: Vec<(String, String)> = files
310 .iter()
311 .filter_map(|(rel, bytes)| {
312 rel.strip_prefix("types/")
313 .and_then(|f| f.strip_suffix(".yaml"))
314 .map(|stem| {
315 (
316 stem.to_string(),
317 String::from_utf8_lossy(bytes).into_owned(),
318 )
319 })
320 })
321 .collect();
322 let schema = memstead_schema::load_schema_from_memory_with_format(
326 &manifest_yaml,
327 &types,
328 memstead_schema::loader::MetadataPolarityFormat::RequiredOptIn,
329 )
330 .map_err(|e| invalid(e.to_string()))?;
331 memstead_schema::check_section_heading_roundtrip(&schema)
332 .map_err(|e| invalid(e.to_string()))?;
333 memstead_schema::check_reserved_metadata_keys(&schema)
334 .map_err(|e| invalid(e.to_string()))?;
335 memstead_schema::check_section_formats(&schema).map_err(|e| invalid(e.to_string()))?;
336 let (declared_name, declared_version) =
337 (schema.manifest.name.as_str(), schema.version.to_string());
338 if declared_name != name || declared_version != version {
339 return Err(invalid(format!(
340 "manifest declares '{declared_name}@{declared_version}' but the package is \
341 being installed as '{name}@{version}'"
342 )));
343 }
344 Self::validate_schema_exemplars(&std::sync::Arc::new(schema)).map_err(invalid)?;
345 Ok(())
346 }
347
348 pub fn validate_schema_exemplars(
370 schema: &std::sync::Arc<memstead_schema::Schema>,
371 ) -> Result<(), String> {
372 let with_exemplars: Vec<&str> = schema
373 .manifest
374 .types
375 .iter()
376 .filter(|t| {
377 schema
378 .types
379 .get(t.as_str())
380 .is_some_and(|td| td.exemplar.is_some())
381 })
382 .map(String::as_str)
383 .collect();
384 if with_exemplars.is_empty() {
385 return Ok(());
386 }
387
388 let (name, version) = schema.id();
389 let mem = "exemplar";
390 let mount = crate::workspace::Mount {
391 mem: mem.to_string(),
392 schema: Some(memstead_schema::SchemaRef::new(name, version)),
393 storage: crate::workspace::MountStorage::InMemory,
394 capability: crate::workspace::MountCapability::Write,
395 lifecycle: crate::workspace::MountLifecycle::Eager,
396 cross_linkable: true,
397 migration_target: None,
398 };
399 let backend = Box::new(crate::storage::InMemoryBackend::new()) as Box<dyn MemBackend>;
400 let mut engine = Engine::from_mounts_with_schemas_dir_and_extra(
401 vec![(mount, backend)],
402 None,
403 vec![schema.clone()],
404 )
405 .map_err(|e| format!("exemplar validation could not boot: {e}"))?;
406
407 for type_name in with_exemplars {
408 let td = schema
409 .types
410 .get(type_name)
411 .expect("filtered on presence above");
412 let ex = td.exemplar.as_ref().expect("filtered on presence above");
413 let mut relations = Vec::with_capacity(ex.relations.len());
414 for r in &ex.relations {
415 if r.to.contains("--") || r.to.trim().is_empty() {
416 return Err(format!(
417 "type '{type_name}' exemplar relation target '{}' must be a bare \
418 placeholder slug (no `--`, non-empty) — exemplars live outside \
419 any mem",
420 r.to
421 ));
422 }
423 relations.push(crate::ops::RelateArg {
424 to: crate::entity::EntityId::new(mem, &r.to),
425 rel_type: r.rel_type.clone(),
426 description: r.description.clone(),
427 });
428 }
429 let args = crate::engine::CreateEntityArgs {
430 anchors: Vec::new(),
431 mem: mem.to_string(),
432 title: ex.title.clone(),
433 entity_type: type_name.to_string(),
434 sections: ex.sections.clone(),
435 metadata: ex.metadata.clone(),
436 relations,
437 dry_run: true,
438 };
439 if let Err(e) = engine.create_entity(args, crate::vcs::Actor::Cli, None, None) {
440 return Err(format!(
441 "type '{type_name}' exemplar does not conform: [{}] {e}",
442 e.code()
443 ));
444 }
445 }
446 Ok(())
447 }
448 pub fn unregister_writable_mem(
467 &mut self,
468 mem_name: &str,
469 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
470 let pos = self.mounts.iter().position(|m| m.mount.mem == mem_name);
471 let Some(idx) = pos else {
472 return Ok(None);
473 };
474
475 let mount = self.mounts.remove(idx);
481
482 self.schemas.remove(&mount.mount.mem);
485
486 let _removed = self.store.remove_entities_by_mem(mem_name);
491
492 self.load_warnings
503 .retain(|w| w.source_mem() != Some(mem_name));
504
505 Arc::make_mut(&mut self.mem_router).remove_writable(mem_name);
513
514 self.invalidate_communities();
519 self.invalidate_search_indexes();
520
521 Ok(Some(mount.backend))
522 }
523
524 pub fn register_read_mount(
532 &mut self,
533 mount: Mount,
534 backend: Box<dyn MemBackend>,
535 origin: MemOrigin,
536 ) -> Result<(), EngineError> {
537 self.register_writable_mem_inner(mount, backend, origin, true)
538 }
539
540 pub fn unregister_read_mount(
548 &mut self,
549 mem_name: &str,
550 ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
551 let pos = self.mounts.iter().position(|m| {
552 m.mount.mem == mem_name
553 && m.mount.capability == crate::workspace::MountCapability::ReadOnly
554 });
555 let Some(idx) = pos else {
556 return Ok(None);
557 };
558 let mount = self.mounts.remove(idx);
559 self.schemas.remove(&mount.mount.mem);
560 let _removed = self.store.remove_entities_by_mem(mem_name);
561 self.load_warnings
562 .retain(|w| w.source_mem() != Some(mem_name));
563 Arc::make_mut(&mut self.mem_router).remove_read_only(mem_name);
564 self.invalidate_communities();
565 self.invalidate_search_indexes();
566 Ok(Some(mount.backend))
567 }
568
569 pub fn push_load_warning(&mut self, warning: crate::ops::WarningHint) {
574 self.load_warnings.push(warning);
575 }
576
577 pub fn register_writable_mem(
616 &mut self,
617 mount: Mount,
618 backend: Box<dyn MemBackend>,
619 origin: MemOrigin,
620 ) -> Result<(), EngineError> {
621 self.register_writable_mem_inner(mount, backend, origin, true)
622 }
623
624 fn register_writable_mem_inner(
634 &mut self,
635 mount: Mount,
636 backend: Box<dyn MemBackend>,
637 origin: MemOrigin,
638 run_global_passes: bool,
639 ) -> Result<(), EngineError> {
640 if let Some(existing) = self.mem_router.origin_for_mem(&mount.mem) {
642 return Err(EngineError::MemNameCollision {
643 name: mount.mem.clone(),
644 source_origin: existing.render_source(),
645 });
646 }
647 if self.mem_router.archive_path_for_mem(&mount.mem).is_some() {
648 return Err(EngineError::MemNameCollision {
649 name: mount.mem.clone(),
650 source_origin: "attached read mem".to_string(),
651 });
652 }
653
654 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
660 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
661 memstead_schema::config::parse_mem_config(&value).ok()
662 });
663
664 let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
674 self.workspace_schemas().to_vec();
675 builtin_schemas.extend(
676 memstead_schema::builtins::load_builtin_schemas()
677 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?,
678 );
679 let config_pin = mem_config.as_ref().and_then(|c| c.schema.as_ref());
680 let mount_pin = mount.schema.as_ref();
681 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
682 && cfg != mp
683 {
684 self.load_warnings
685 .push(crate::ops::WarningHint::SchemaPinMismatch {
686 mem: mount.mem.clone(),
687 config_pin: cfg.as_display(),
688 mount_pin: mp.as_display(),
689 });
690 }
691 let settled_pin = config_pin.or(mount_pin);
692 let effective_pin = mount
693 .migration_target
694 .as_ref()
695 .or(settled_pin)
696 .ok_or_else(|| EngineError::MemConfigIncomplete {
697 mem: mount.mem.clone(),
698 missing_fields: vec!["schema".to_string()],
699 })?
700 .clone();
701 let schema = crate::engine::SchemaResolver::new(&builtin_schemas)
702 .resolve(&effective_pin)
703 .map_err(|sources| {
704 EngineError::SchemaNotFound {
705 mem: mount.mem.clone(),
706 pin: effective_pin.as_display(),
707 sources,
708 install_hint: None,
709 }
710 .with_schema_install_probe(self.workspace_root())
711 })?;
712
713 let (entries, read_errors) = collect_source_entries(backend.as_ref())?;
721 let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
722
723 let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
724 mem_names.push(mount.mem.clone());
725 let known_suffixes: Vec<String> = mem_names
726 .iter()
727 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
728 .collect();
729 let fallback = engine_fallback_type();
730 push_entities_into_store(
731 &mut self.store,
732 load_result.entities,
733 fallback.as_ref(),
734 Some(crate::entity::store_builder::LoadCollector {
735 warnings: &mut self.load_warnings,
736 known_suffixes: &known_suffixes,
737 mem_names: &mem_names,
738 }),
739 );
740 self.load_errors.extend(load_result.errors);
741
742 self.schemas.insert(mount.mem.clone(), schema);
744
745 if run_global_passes {
756 let mut mount_caps: std::collections::HashMap<
757 String,
758 crate::workspace::MountCapability,
759 > = self
760 .mounts
761 .iter()
762 .map(|m| (m.mount.mem.clone(), m.mount.capability))
763 .collect();
764 mount_caps.insert(mount.mem.clone(), mount.capability);
765 crate::entity::store_builder::validate_loaded_relations(
766 &mut self.store,
767 &self.schemas,
768 &mount_caps,
769 &mut self.load_warnings,
770 );
771 crate::entity::store_builder::remap_alias_target_edge_sources(
772 &mut self.store,
773 &self.schemas,
774 );
775 }
776
777 let last_known_head = backend.current_head().ok().flatten();
779 let mem_name_for_router = mount.mem.clone();
780 let storage_for_router = mount.storage.clone();
781 let mount_capability_for_router = mount.capability;
782 self.mounts.push(MountedBackend {
783 mount,
784 backend,
785 last_known_head,
786 mem_config,
787 archive_provenance: None,
790 });
791
792 match (&mount_capability_for_router, &storage_for_router) {
800 (crate::workspace::MountCapability::ReadOnly, MountStorage::Archive { path }) => {
801 Arc::make_mut(&mut self.mem_router)
802 .add_read_only(mem_name_for_router, path.clone());
803 }
804 _ => {
805 let dir: Option<PathBuf> = match &storage_for_router {
806 MountStorage::Folder { path } => Some(path.clone()),
807 MountStorage::GitBranch { .. }
808 | MountStorage::Archive { .. }
809 | MountStorage::InMemory => None,
810 };
811 Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
812 }
813 }
814
815 if run_global_passes {
817 self.invalidate_communities();
818 self.invalidate_search_indexes();
819 }
820
821 Ok(())
822 }
823
824 fn finish_batched_registrations(&mut self) {
829 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
830 .mounts
831 .iter()
832 .map(|m| (m.mount.mem.clone(), m.mount.capability))
833 .collect();
834 crate::entity::store_builder::validate_loaded_relations(
835 &mut self.store,
836 &self.schemas,
837 &mount_caps,
838 &mut self.load_warnings,
839 );
840 crate::entity::store_builder::remap_alias_target_edge_sources(
841 &mut self.store,
842 &self.schemas,
843 );
844 self.invalidate_communities();
845 self.invalidate_search_indexes();
846 }
847
848 pub fn full_refresh(&mut self) -> crate::ops::FullRefreshReport {
873 let started = std::time::Instant::now();
874 let mut report = crate::ops::FullRefreshReport::default();
875
876 let Some(root) = self.workspace_root.clone() else {
877 report.failures.push(crate::ops::RefreshFailure {
878 item: "workspace".to_string(),
879 error: "engine has no workspace root (ad-hoc mount-list construction) — \
880 nothing to re-scan"
881 .to_string(),
882 });
883 report.elapsed_ms = started.elapsed().as_millis() as u64;
884 return report;
885 };
886
887 self.refresh_workspace_settings_if_possible();
890
891 use crate::schema_source::SchemaSource as _;
893 let mut fresh: Vec<std::sync::Arc<memstead_schema::Schema>> = Vec::new();
894 let mut sources_complete = true;
895 match crate::schema_source::FolderSchemaSource::for_workspace(&root).read_schemas() {
896 Ok(mut s) => fresh.append(&mut s),
897 Err(e) => {
898 sources_complete = false;
899 report.failures.push(crate::ops::RefreshFailure {
900 item: "schema-source:folder".to_string(),
901 error: e.to_string(),
902 });
903 }
904 }
905 if let Some(ops) = self.git_branch_ops() {
906 match (ops.read_ref_schemas)(&root) {
907 Ok(mut s) => fresh.append(&mut s),
908 Err(e) => {
909 sources_complete = false;
910 report.failures.push(crate::ops::RefreshFailure {
911 item: "schema-source:memstead-ref".to_string(),
912 error: e.to_string(),
913 });
914 }
915 }
916 }
917 let key = |s: &memstead_schema::Schema| {
918 let (name, version) = s.id();
919 format!("{name}@{version}")
920 };
921 let existing: std::collections::HashSet<String> =
922 self.workspace_schemas.iter().map(|s| key(s)).collect();
923 let fresh_keys: std::collections::HashSet<String> = fresh.iter().map(|s| key(s)).collect();
924 for schema in fresh {
925 let k = key(&schema);
926 if !existing.contains(&k) && !report.schemas_added.contains(&k) {
927 report.schemas_added.push(k);
928 self.workspace_schemas.push(schema);
929 }
930 }
931 report.schemas_added.sort();
932 if sources_complete {
936 report.schema_removals_skipped = existing
937 .difference(&fresh_keys)
938 .cloned()
939 .collect::<Vec<_>>();
940 report.schema_removals_skipped.sort();
941 }
942
943 let store = crate::workspace_store::FileWorkspaceStore::new();
945 match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
946 Err(e) => {
947 report.failures.push(crate::ops::RefreshFailure {
948 item: "mount-manifest".to_string(),
949 error: e.to_string(),
950 });
951 }
952 Ok(workspace) => {
953 let manifest_names: std::collections::HashSet<String> =
954 workspace.mounts.iter().map(|m| m.mem.clone()).collect();
955 let mut any_mounted = false;
956 for mount in workspace.mounts {
957 if self.mounts.iter().any(|m| m.mount.mem == mount.mem) {
958 continue;
959 }
960 let name = mount.mem.clone();
961 if mount.capability != crate::workspace::MountCapability::Write {
962 report.failures.push(crate::ops::RefreshFailure {
963 item: format!("mount:{name}"),
964 error: "only writable mounts attach on a warm refresh — \
965 restart the process to attach this mount"
966 .to_string(),
967 });
968 continue;
969 }
970 let backend = match (self.backend_factory)(&mount) {
971 Ok(b) => b,
972 Err(e) => {
973 report.failures.push(crate::ops::RefreshFailure {
974 item: format!("mount:{name}"),
975 error: e.to_string(),
976 });
977 continue;
978 }
979 };
980 match self.register_writable_mem_inner(
981 mount,
982 backend,
983 crate::mem::MemOrigin::ExplicitToml,
984 false,
985 ) {
986 Ok(()) => {
987 any_mounted = true;
988 report.mems_mounted.push(name);
989 }
990 Err(e) => report.failures.push(crate::ops::RefreshFailure {
991 item: format!("mount:{name}"),
992 error: e.to_string(),
993 }),
994 }
995 }
996 report.mem_removals_skipped = self
1001 .mounts
1002 .iter()
1003 .filter(|m| {
1004 m.mount.capability == crate::workspace::MountCapability::Write
1005 && !manifest_names.contains(&m.mount.mem)
1006 })
1007 .map(|m| m.mount.mem.clone())
1008 .collect();
1009 report.mem_removals_skipped.sort();
1010 if any_mounted {
1011 self.finish_batched_registrations();
1013 }
1014 }
1015 }
1016
1017 report.elapsed_ms = started.elapsed().as_millis() as u64;
1018 report
1019 }
1020
1021 pub fn set_workspace_root(&mut self, root: PathBuf) {
1027 self.workspace_root = Some(root);
1028 }
1029
1030 pub fn persist_state(&self) -> Result<(), EngineError> {
1053 let Some(root) = self.workspace_root.as_ref() else {
1054 return Ok(());
1055 };
1056 let workspace = crate::workspace::Workspace {
1057 mounts: self.mounts.iter().map(|m| m.mount.clone()).collect(),
1058 settings: self.settings.clone(),
1059 };
1060 let store = crate::FileWorkspaceStore::new();
1061 crate::workspace_store::WorkspaceStoreAdapter::save_state(&store, root, &workspace)
1062 .map_err(|e| EngineError::Mem(format!("persist workspace state: {e}")))
1063 }
1064 pub fn set_mem_schema(
1084 &mut self,
1085 mem: &str,
1086 target: &memstead_schema::SchemaRef,
1087 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1088 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1089 if self.quarantine_reason(mem).is_some() {
1095 return self.set_schema_on_quarantined(mem, target);
1096 }
1097 let mount_idx = self
1098 .mounts
1099 .iter()
1100 .position(|m| m.mount.mem == mem)
1101 .ok_or_else(|| self.unknown_mem_error(mem))?;
1102 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1108 return Err(EngineError::ReadOnlyMount(mem.to_string()));
1109 }
1110
1111 let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
1114 let consulted: Vec<_> = self
1117 .workspace_schemas
1118 .iter()
1119 .chain(self.builtin_schemas.iter())
1120 .cloned()
1121 .collect();
1122 EngineError::SchemaNotFound {
1123 mem: mem.to_string(),
1124 pin: target.as_display(),
1125 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1126 &target.name,
1127 &target.version,
1128 &consulted,
1129 ),
1130 install_hint: None,
1131 }
1132 .with_schema_install_probe(self.workspace_root())
1133 })?;
1134
1135 let current_pin = self.mounts[mount_idx].mount.schema.clone();
1140 let current_pin_display = current_pin
1141 .as_ref()
1142 .map(|p| p.as_display())
1143 .unwrap_or_else(|| "<unset>".to_string());
1144 let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1145
1146 if current_pin.as_ref() == Some(target) {
1147 return Ok(SetSchemaOutcome {
1148 mem: mem.to_string(),
1149 schema_pin: current_pin_display,
1150 migration_target: in_flight.map(|t| t.as_display()),
1151 outcome: SetSchemaResult::Noop,
1152 findings: Vec::new(),
1153 });
1154 }
1155
1156 let findings = crate::ops::integrity::conformance_findings(
1162 &self.store,
1163 mem,
1164 target_schema.as_ref(),
1165 &self.schemas,
1166 );
1167
1168 if findings.is_empty() {
1169 self.persist_mem_schema_pin(mount_idx, target)?;
1177 self.mounts[mount_idx].mount.schema = Some(target.clone());
1178 self.mounts[mount_idx].mount.migration_target = None;
1179 self.schemas.insert(mem.to_string(), target_schema);
1180 self.invalidate_communities();
1181 self.persist_state()?;
1182 return Ok(SetSchemaOutcome {
1183 mem: mem.to_string(),
1184 schema_pin: target.as_display(),
1185 migration_target: None,
1186 outcome: SetSchemaResult::Switched,
1187 findings: Vec::new(),
1188 });
1189 }
1190
1191 let outcome = if in_flight.as_ref() == Some(target) {
1192 SetSchemaResult::MigrationPending
1193 } else {
1194 SetSchemaResult::MigrationStarted
1195 };
1196 self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1197 self.schemas.insert(mem.to_string(), target_schema);
1200 self.invalidate_communities();
1201 self.persist_state()?;
1202 Ok(SetSchemaOutcome {
1203 mem: mem.to_string(),
1204 schema_pin: current_pin_display,
1205 migration_target: Some(target.as_display()),
1206 outcome,
1207 findings,
1208 })
1209 }
1210
1211 fn persist_mem_schema_pin(
1228 &mut self,
1229 mount_idx: usize,
1230 target: &memstead_schema::SchemaRef,
1231 ) -> Result<(), EngineError> {
1232 let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1233 if let Some(value) = value
1236 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1237 {
1238 self.mounts[mount_idx].mem_config = Some(cfg);
1239 }
1240 Ok(())
1241 }
1242
1243 pub fn export_markdown(
1272 &self,
1273 mem_filter: Option<&str>,
1274 schema_filter: Option<&str>,
1275 ) -> Result<crate::ops::ExportResult, EngineError> {
1276 use crate::workspace::MountStorage;
1277 let fallback = engine_fallback_type();
1278 let supported_backends = vec!["folder".to_string()];
1279
1280 if let Some(name) = mem_filter {
1281 let mount = self
1282 .mounts
1283 .iter()
1284 .find(|m| m.mount.mem == name)
1285 .ok_or_else(|| self.unknown_mem_error(name))?;
1286 if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1287 return Err(EngineError::MarkdownExportUnsupportedBackend {
1288 mem: name.to_string(),
1289 active_backend: mount.mount.storage.backend_id().to_string(),
1290 supported_backends,
1291 });
1292 }
1293 }
1294
1295 let mut total_written = 0;
1296 let mut total_unchanged = 0;
1297 let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1298
1299 for mount in &self.mounts {
1300 let mem_name = mount.mount.mem.as_str();
1301 if let Some(filter) = mem_filter
1302 && mem_name != filter
1303 {
1304 continue;
1305 }
1306 let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1307 skipped_mounts.push(crate::ops::SkippedMount {
1308 mem: mem_name.to_string(),
1309 active_backend: mount.mount.storage.backend_id().to_string(),
1310 reason: "backend_does_not_support_markdown_export".to_string(),
1311 });
1312 continue;
1313 };
1314 let schema = match self.schemas.get(mem_name) {
1315 Some(s) => s,
1316 None => continue,
1317 };
1318
1319 for entity in self.store.all_entities() {
1320 if entity.stub || entity.file_path.is_empty() {
1321 continue;
1322 }
1323 if entity.id.mem() != mem_name {
1324 continue;
1325 }
1326 if let Some(filter) = schema_filter
1327 && entity.entity_type != filter
1328 {
1329 continue;
1330 }
1331 let type_def = schema
1332 .get_type(&entity.entity_type)
1333 .unwrap_or_else(|| fallback.clone());
1334 let generated = generate_markdown(entity, type_def.as_ref());
1335
1336 let full_path = mem_dir.join(&entity.file_path);
1337 let needs_write = match std::fs::read_to_string(&full_path) {
1338 Ok(existing) => existing != generated,
1339 Err(_) => true,
1340 };
1341 if needs_write {
1342 let _ = crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref());
1343 total_written += 1;
1344 } else {
1345 total_unchanged += 1;
1346 }
1347 }
1348 }
1349
1350 Ok(crate::ops::ExportResult {
1351 written: total_written,
1352 unchanged: total_unchanged,
1353 skipped_mounts,
1354 })
1355 }
1356
1357 pub fn export_mem(
1375 &self,
1376 mem_name: &str,
1377 output_path: &std::path::Path,
1378 ) -> Result<crate::ops::MemExportResult, EngineError> {
1379 let mount = self
1380 .mounts
1381 .iter()
1382 .find(|m| m.mount.mem == mem_name)
1383 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1384 let config = self.mem_config_for(mem_name).ok_or_else(|| {
1385 EngineError::InvalidInput(format!(
1386 "mem '{mem_name}' has no loaded MemConfig — cannot export"
1387 ))
1388 })?;
1389 if config.version.is_none() {
1397 return Err(EngineError::MemConfigIncomplete {
1398 mem: mem_name.to_string(),
1399 missing_fields: vec!["version".to_string()],
1400 });
1401 }
1402 let workspace_root = self.workspace_root.as_deref();
1403 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1407 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1408 match &mount.mount.storage {
1409 MountStorage::Folder { path } => crate::ops::export::export_mem(
1410 path,
1411 config,
1412 output_path,
1413 workspace_root,
1414 workspace_schemas_dir,
1415 )
1416 .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1417 MountStorage::GitBranch { gitdir, branch } => {
1418 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1419 EngineError::Backend(BackendError::Other(
1420 "git-branch export hook not installed (full flavour not loaded)"
1421 .to_string(),
1422 ))
1423 })?;
1424 let provenance_bytes = mount
1428 .backend
1429 .read_provenance(None)
1430 .ok()
1431 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
1432 .and_then(|prov| prov.to_archive_bytes().ok());
1433 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1436 (hook.export)(
1437 gitdir,
1438 branch,
1439 mem_name,
1440 config,
1441 output_path,
1442 workspace_root,
1443 workspace_schemas_dir,
1444 provenance_bytes.as_deref(),
1445 anchors_bytes.as_deref(),
1446 )
1447 .map_err(EngineError::Backend)
1448 }
1449 MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1450 MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1455 "export not yet supported for in-memory backend".to_string(),
1456 ))),
1457 }
1458 }
1459
1460 pub fn record_pipeline_edit_provenance(
1484 &self,
1485 mem: &str,
1486 kind: &str,
1487 edits: &[(String, Option<Vec<u8>>)],
1488 note: Option<&str>,
1489 verb: &str,
1490 ) -> Result<(), crate::backend::BackendError> {
1491 match self.mounts.iter().find(|m| m.mount.mem == mem) {
1492 Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1493 None => Ok(()),
1494 }
1495 }
1496
1497 pub fn set_mem_version(
1498 &mut self,
1499 mem_name: &str,
1500 new_version: semver::Version,
1501 note: Option<&str>,
1502 ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1503 let mount_idx = self
1506 .mounts
1507 .iter()
1508 .position(|m| m.mount.mem == mem_name)
1509 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1510 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1511 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1512 }
1513
1514 let mut warnings = self.reload_if_stale(Some(mem_name));
1522 if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1527 warnings.push(w);
1528 }
1529
1530 let mounted = &mut self.mounts[mount_idx];
1531 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1532 EngineError::InvalidInput(format!(
1533 "mem '{mem_name}' has no loaded MemConfig — \
1534 cannot set version (initialize the mem via `memstead init` \
1535 or `memstead mem create` first)"
1536 ))
1537 })?;
1538 let old_version = config.version.clone();
1539 config.version = Some(new_version.clone());
1540
1541 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1542 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1543 })?;
1544 bytes.push(b'\n');
1545 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1546 mounted.mem_config = Some(config);
1547
1548 let new_head = mounted.backend.current_head().ok().flatten();
1554 if let Some(sha) = new_head {
1555 mounted.last_known_head = Some(sha);
1556 }
1557
1558 Ok(crate::ops::SetMemVersionOutcome {
1559 mem: mem_name.to_string(),
1560 old_version,
1561 new_version,
1562 warnings,
1563 })
1564 }
1565
1566 pub fn set_mem_description(
1573 &mut self,
1574 mem_name: &str,
1575 new_description: Option<String>,
1576 note: Option<&str>,
1577 ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1578 let mount_idx = self
1579 .mounts
1580 .iter()
1581 .position(|m| m.mount.mem == mem_name)
1582 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1583 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1584 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1585 }
1586
1587 let mut warnings = self.reload_if_stale(Some(mem_name));
1588 if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1589 warnings.push(w);
1590 }
1591
1592 let mounted = &mut self.mounts[mount_idx];
1593 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1594 EngineError::InvalidInput(format!(
1595 "mem '{mem_name}' has no loaded MemConfig — \
1596 cannot set description (initialize the mem via `memstead init` \
1597 or `memstead mem create` first)"
1598 ))
1599 })?;
1600 let old_description = config.description.clone();
1601 config.description = new_description.clone();
1602
1603 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1604 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1605 })?;
1606 bytes.push(b'\n');
1607 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1608 mounted.mem_config = Some(config);
1609
1610 let new_head = mounted.backend.current_head().ok().flatten();
1611 if let Some(sha) = new_head {
1612 mounted.last_known_head = Some(sha);
1613 }
1614
1615 Ok(crate::ops::SetMemDescriptionOutcome {
1616 mem: mem_name.to_string(),
1617 old_description,
1618 new_description,
1619 warnings,
1620 })
1621 }
1622
1623 pub fn set_mem_title(
1628 &mut self,
1629 mem_name: &str,
1630 new_title: Option<String>,
1631 note: Option<&str>,
1632 ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1633 let mount_idx = self
1634 .mounts
1635 .iter()
1636 .position(|m| m.mount.mem == mem_name)
1637 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1638 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1639 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1640 }
1641
1642 let mut warnings = self.reload_if_stale(Some(mem_name));
1643 if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1644 warnings.push(w);
1645 }
1646
1647 let mounted = &mut self.mounts[mount_idx];
1648 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1649 EngineError::InvalidInput(format!(
1650 "mem '{mem_name}' has no loaded MemConfig — cannot set title"
1651 ))
1652 })?;
1653 let old_title = config.title.clone();
1654 config.title = new_title.clone();
1655
1656 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1657 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1658 })?;
1659 bytes.push(b'\n');
1660 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1661 mounted.mem_config = Some(config);
1662
1663 let new_head = mounted.backend.current_head().ok().flatten();
1664 if let Some(sha) = new_head {
1665 mounted.last_known_head = Some(sha);
1666 }
1667
1668 Ok(crate::ops::SetMemTitleOutcome {
1669 mem: mem_name.to_string(),
1670 old_title,
1671 new_title,
1672 warnings,
1673 })
1674 }
1675
1676 pub fn set_mem_subject(
1680 &mut self,
1681 mem_name: &str,
1682 new_subject: Option<memstead_schema::MemSubject>,
1683 note: Option<&str>,
1684 ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1685 let mount_idx = self
1686 .mounts
1687 .iter()
1688 .position(|m| m.mount.mem == mem_name)
1689 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1690 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1691 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1692 }
1693
1694 let mut warnings = self.reload_if_stale(Some(mem_name));
1695 if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
1696 warnings.push(w);
1697 }
1698
1699 let mounted = &mut self.mounts[mount_idx];
1700 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1701 EngineError::InvalidInput(format!(
1702 "mem '{mem_name}' has no loaded MemConfig — cannot set subject"
1703 ))
1704 })?;
1705 let old_subject = config.subject.clone();
1706 config.subject = new_subject.clone();
1707
1708 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1709 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1710 })?;
1711 bytes.push(b'\n');
1712 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1713 mounted.mem_config = Some(config);
1714
1715 let new_head = mounted.backend.current_head().ok().flatten();
1716 if let Some(sha) = new_head {
1717 mounted.last_known_head = Some(sha);
1718 }
1719
1720 Ok(crate::ops::SetMemSubjectOutcome {
1721 mem: mem_name.to_string(),
1722 old_subject,
1723 new_subject,
1724 warnings,
1725 })
1726 }
1727
1728 pub fn set_mem_internal(
1740 &mut self,
1741 mem_name: &str,
1742 internal: bool,
1743 note: Option<&str>,
1744 ) -> Result<bool, EngineError> {
1745 let mount_idx = self
1746 .mounts
1747 .iter()
1748 .position(|m| m.mount.mem == mem_name)
1749 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1750 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1751 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1752 }
1753
1754 let _ = self.reload_if_stale(Some(mem_name));
1755
1756 let mounted = &mut self.mounts[mount_idx];
1757 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1758 EngineError::InvalidInput(format!(
1759 "mem '{mem_name}' has no loaded MemConfig — initialize the mem first"
1760 ))
1761 })?;
1762 if internal {
1763 config
1764 .extra
1765 .insert("internal".to_string(), serde_json::Value::Bool(true));
1766 } else {
1767 config.extra.remove("internal");
1768 }
1769
1770 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1771 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1772 })?;
1773 bytes.push(b'\n');
1774 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1775 mounted.mem_config = Some(config);
1776
1777 let new_head = mounted.backend.current_head().ok().flatten();
1778 if let Some(sha) = new_head {
1779 mounted.last_known_head = Some(sha);
1780 }
1781
1782 Ok(internal)
1783 }
1784
1785 pub fn set_mem_sync_state(
1810 &mut self,
1811 mem_name: &str,
1812 key: &str,
1813 token: &str,
1814 note: Option<&str>,
1815 ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
1816 let mount_idx = self
1819 .mounts
1820 .iter()
1821 .position(|m| m.mount.mem == mem_name)
1822 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1823 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1824 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1825 }
1826
1827 let mut warnings = self.reload_if_stale(Some(mem_name));
1831 if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
1832 warnings.push(w);
1833 }
1834
1835 let mounted = &mut self.mounts[mount_idx];
1836 let mut config = mounted.mem_config.clone().ok_or_else(|| {
1837 EngineError::InvalidInput(format!(
1838 "mem '{mem_name}' has no loaded MemConfig — \
1839 cannot set sync state (initialize the mem via `memstead init` \
1840 or `memstead mem create` first)"
1841 ))
1842 })?;
1843
1844 let removed;
1848 let previous;
1849 if token.is_empty() {
1850 previous = config.sync_state.remove(key);
1851 removed = previous.is_some();
1852 } else {
1853 previous = config.sync_state.insert(key.to_string(), token.to_string());
1854 removed = false;
1855 }
1856
1857 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1858 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1859 })?;
1860 bytes.push(b'\n');
1861 mounted.backend.write_mem_config_with_note(&bytes, note)?;
1862 mounted.mem_config = Some(config);
1863
1864 let new_head = mounted.backend.current_head().ok().flatten();
1867 if let Some(sha) = new_head {
1868 mounted.last_known_head = Some(sha);
1869 }
1870
1871 Ok(crate::ops::SetMemSyncStateOutcome {
1872 mem: mem_name.to_string(),
1873 key: key.to_string(),
1874 previous,
1875 removed,
1876 warnings,
1877 })
1878 }
1879
1880 pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
1900 if self.quarantine_reason(mem).is_some() {
1923 return self.reattach_quarantined_mem(mem);
1924 }
1925 let mut sink: Vec<WarningHint> = Vec::new();
1926 let result = self.reload_one_mem_inner(mem, &mut sink)?;
1927 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
1928 self.load_warnings
1929 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
1930 Ok(result)
1931 }
1932
1933 fn set_schema_on_quarantined(
1943 &mut self,
1944 mem: &str,
1945 target: &memstead_schema::SchemaRef,
1946 ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1947 use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1948 if self.resolve_schema_by_ref(target).is_none() {
1950 let consulted: Vec<_> = self
1951 .workspace_schemas
1952 .iter()
1953 .chain(self.builtin_schemas.iter())
1954 .cloned()
1955 .collect();
1956 return Err(EngineError::SchemaNotFound {
1957 mem: mem.to_string(),
1958 pin: target.as_display(),
1959 sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1960 &target.name,
1961 &target.version,
1962 &consulted,
1963 ),
1964 install_hint: None,
1965 }
1966 .with_schema_install_probe(self.workspace_root()));
1967 }
1968 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
1969 return Err(self.unknown_mem_error(mem));
1970 };
1971 self.quarantined[q_idx].mount.schema = Some(target.clone());
1975 self.quarantined[q_idx].mount.migration_target = None;
1976 if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
1977 let _ = bump_backend_schema_pin(backend.as_ref(), target);
1978 }
1979 self.persist_state()?;
1980 let _ = self.reattach_quarantined_mem(mem);
1985 Ok(SetSchemaOutcome {
1986 mem: mem.to_string(),
1987 schema_pin: target.as_display(),
1988 migration_target: None,
1989 outcome: SetSchemaResult::Switched,
1990 findings: Vec::new(),
1991 })
1992 }
1993
1994 fn reattach_quarantined_mem(
2002 &mut self,
2003 mem: &str,
2004 ) -> Result<crate::ops::ReloadResult, EngineError> {
2005 let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2006 return Err(self.unknown_mem_error(mem));
2007 };
2008 let mount = self.quarantined[q_idx].mount.clone();
2009
2010 let requarantine = |this: &mut Self, e: &EngineError| {
2011 this.quarantined[q_idx].reason_code = e.code().to_string();
2012 this.quarantined[q_idx].reason_message = e.to_string();
2013 };
2014
2015 let backend = match (self.backend_factory)(&mount) {
2016 Ok(b) => b,
2017 Err(e) => {
2018 let err = EngineError::Mem(e.to_string());
2019 requarantine(self, &err);
2020 return Err(self.unknown_mem_error(mem));
2021 }
2022 };
2023
2024 let last_known_head = backend.current_head().ok().flatten();
2026 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2027 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2028 memstead_schema::config::parse_mem_config(&value).ok()
2029 });
2030 let archive_provenance = backend
2031 .read_archive_provenance()
2032 .ok()
2033 .flatten()
2034 .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2035 let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2036 let effective_pin = mount
2037 .migration_target
2038 .clone()
2039 .or(config_pin)
2040 .or(mount.schema.clone());
2041 let Some(effective_pin) = effective_pin else {
2042 let err = EngineError::MemConfigIncomplete {
2043 mem: mem.to_string(),
2044 missing_fields: vec!["schema".to_string()],
2045 };
2046 requarantine(self, &err);
2047 return Err(self.unknown_mem_error(mem));
2048 };
2049 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2050 .workspace_schemas
2051 .iter()
2052 .chain(self.builtin_schemas.iter())
2053 .cloned()
2054 .collect();
2055 let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2056 Ok(s) => s,
2057 Err(sources) => {
2058 let err = EngineError::SchemaNotFound {
2059 mem: mem.to_string(),
2060 pin: effective_pin.as_display(),
2061 sources,
2062 install_hint: None,
2063 }
2064 .with_schema_install_probe(self.workspace_root());
2065 requarantine(self, &err);
2066 return Err(self.unknown_mem_error(mem));
2067 }
2068 };
2069
2070 self.quarantined.remove(q_idx);
2074 self.schemas.insert(mem.to_string(), schema);
2075 self.mounts.push(crate::engine::MountedBackend {
2076 mount,
2077 backend,
2078 last_known_head,
2079 mem_config,
2080 archive_provenance,
2081 });
2082 self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2083 &self.mounts,
2084 ));
2085 let mut sink: Vec<WarningHint> = Vec::new();
2086 match self.reload_one_mem_inner(mem, &mut sink) {
2087 Ok(result) => {
2088 self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2089 self.load_warnings
2090 .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2091 self.invalidate_communities();
2092 Ok(result)
2093 }
2094 Err(e) => {
2095 let mount_idx = self.mounts.len() - 1;
2096 let mounted = self.mounts.remove(mount_idx);
2097 self.schemas.remove(mem);
2098 self.mem_router = std::sync::Arc::new(
2099 crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2100 );
2101 self.quarantined.push(crate::engine::QuarantinedMem {
2102 mount: mounted.mount,
2103 reason_code: e.code().to_string(),
2104 reason_message: e.to_string(),
2105 });
2106 Err(e)
2107 }
2108 }
2109 }
2110
2111 fn reload_one_mem_inner(
2117 &mut self,
2118 mem: &str,
2119 warnings_sink: &mut Vec<WarningHint>,
2120 ) -> Result<crate::ops::ReloadResult, EngineError> {
2121 let mount_idx = self
2124 .mounts
2125 .iter()
2126 .position(|m| m.mount.mem == mem)
2127 .ok_or_else(|| self.unknown_mem_error(mem))?;
2128 let schema = self
2129 .schemas
2130 .get(mem)
2131 .cloned()
2132 .ok_or_else(|| self.unknown_mem_error(mem))?;
2133
2134 let pre: HashMap<EntityId, String> = self
2136 .store
2137 .all_entities()
2138 .filter(|e| !e.stub && e.mem == mem)
2139 .map(|e| (e.id.clone(), e.content_hash.clone()))
2140 .collect();
2141 let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2142
2143 let backend = self.mounts[mount_idx].backend.as_ref();
2146 let (entries, read_errors) = collect_source_entries(backend)?;
2147 let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2148
2149 let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2153 let known_suffixes: Vec<String> = mem_names
2154 .iter()
2155 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2156 .collect();
2157
2158 self.store.remove_entities_by_mem(mem);
2160 let fallback = engine_fallback_type();
2161 push_entities_into_store(
2162 &mut self.store,
2163 load_result.entities,
2164 fallback.as_ref(),
2165 Some(crate::entity::store_builder::LoadCollector {
2166 warnings: warnings_sink,
2167 known_suffixes: &known_suffixes,
2168 mem_names: &mem_names,
2169 }),
2170 );
2171 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2178 .mounts
2179 .iter()
2180 .map(|m| (m.mount.mem.clone(), m.mount.capability))
2181 .collect();
2182 crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2190 crate::entity::store_builder::validate_loaded_relations(
2191 &mut self.store,
2192 &self.schemas,
2193 &mount_caps,
2194 warnings_sink,
2195 );
2196 crate::entity::store_builder::remap_alias_target_edge_sources(
2197 &mut self.store,
2198 &self.schemas,
2199 );
2200 self.load_errors.extend(load_result.errors);
2206
2207 if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2215 && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2216 && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2217 {
2218 self.mounts[mount_idx].mem_config = Some(cfg);
2219 }
2220
2221 let mut added: Vec<EntityId> = Vec::new();
2223 let mut changed: Vec<EntityId> = Vec::new();
2224 for entity in self.store.all_entities() {
2225 if entity.stub || entity.mem != mem {
2226 continue;
2227 }
2228 match pre.get(&entity.id) {
2229 None => added.push(entity.id.clone()),
2230 Some(prev_hash) if prev_hash != &entity.content_hash => {
2231 changed.push(entity.id.clone());
2232 }
2233 Some(_) => {}
2234 }
2235 }
2236 let post_ids: std::collections::HashSet<EntityId> = self
2237 .store
2238 .all_entities()
2239 .filter(|e| !e.stub && e.mem == mem)
2240 .map(|e| e.id.clone())
2241 .collect();
2242 let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2243 added.sort_by(|a, b| a.0.cmp(&b.0));
2244 changed.sort_by(|a, b| a.0.cmp(&b.0));
2245 removed.sort_by(|a, b| a.0.cmp(&b.0));
2246
2247 self.invalidate_communities();
2248 self.invalidate_search_indexes();
2249
2250 Ok(crate::ops::ReloadResult {
2251 added,
2252 changed,
2253 removed,
2254 })
2255 }
2256
2257 pub fn reload_one_mem_report(
2285 &mut self,
2286 mem: &str,
2287 ) -> Result<crate::ops::ReloadReport, EngineError> {
2288 let tracks_head = self
2298 .mounts
2299 .iter()
2300 .find(|m| m.mount.mem == mem)
2301 .and_then(|m| m.backend.current_head().ok().flatten())
2302 .is_some();
2303 let head_before = if tracks_head {
2304 self.mounts
2305 .iter()
2306 .find(|m| m.mount.mem == mem)
2307 .and_then(|m| m.last_known_head.clone())
2308 .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2309 } else {
2310 crate::ops::EMPTY_TREE_SHA.to_string()
2311 };
2312
2313 let result = self.reload_one_mem(mem)?;
2314
2315 let head_after_raw = self
2322 .mounts
2323 .iter()
2324 .find(|m| m.mount.mem == mem)
2325 .and_then(|m| m.backend.current_head().ok().flatten());
2326 if let Some(new_head) = head_after_raw.clone()
2327 && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2328 {
2329 m.last_known_head = Some(new_head);
2330 }
2331 let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2332
2333 let entities_loaded = self
2334 .store
2335 .all_entities()
2336 .filter(|e| !e.stub && e.mem == mem)
2337 .count();
2338
2339 let mut changed_entity_ids: Vec<EntityId> = result
2344 .added
2345 .into_iter()
2346 .chain(result.changed)
2347 .chain(result.removed)
2348 .collect();
2349 changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2350
2351 Ok(crate::ops::ReloadReport {
2352 mem: mem.to_string(),
2353 head_before,
2354 head_after,
2355 entities_loaded,
2356 changed_entity_ids,
2357 })
2358 }
2359
2360 pub fn reload_each_writable_mem_reports(
2400 &mut self,
2401 ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2402 self.refresh_workspace_settings_if_possible();
2403 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2404 let mut out = Vec::with_capacity(names.len());
2405 for name in names {
2406 let report = self.reload_one_mem_report(&name)?;
2407 out.push(report);
2408 }
2409 Ok(out)
2410 }
2411
2412 fn refresh_workspace_settings_if_possible(&mut self) {
2424 let Some(root) = self.workspace_root.clone() else {
2425 return;
2426 };
2427 let store = crate::workspace_store::FileWorkspaceStore::new();
2428 let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2429 Ok(w) => w,
2430 Err(_) => return,
2431 };
2432 self.set_settings(workspace.settings);
2433 }
2434
2435 pub fn reload_each_writable_mem(
2451 &mut self,
2452 ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2453 let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2454 let mut sink = std::mem::take(&mut self.load_warnings);
2460 sink.clear();
2461 let mut out = Vec::with_capacity(names.len());
2462 let mut loop_err = None;
2463 for name in names {
2464 match self.reload_one_mem_inner(&name, &mut sink) {
2465 Ok(result) => out.push((name, result)),
2466 Err(e) => {
2467 loop_err = Some(e);
2468 break;
2469 }
2470 }
2471 }
2472 self.load_warnings = sink;
2473 if let Some(e) = loop_err {
2474 return Err(e);
2475 }
2476 Ok(out)
2477 }
2478}
2479
2480pub fn bump_backend_schema_pin(
2493 backend: &dyn crate::backend::MemBackend,
2494 target: &memstead_schema::SchemaRef,
2495) -> Result<Option<serde_json::Value>, EngineError> {
2496 let Some(bytes) = backend
2497 .read_mem_config()
2498 .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2499 else {
2500 return Ok(None);
2501 };
2502 let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2503 .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2504 value["schema"] = serde_json::Value::String(target.as_display());
2505 let new_bytes = serde_json::to_vec_pretty(&value)
2506 .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2507 backend
2508 .write_mem_config(&new_bytes)
2509 .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2510 Ok(Some(value))
2511}
2512
2513#[cfg(test)]
2514mod tests {
2515
2516 use tempfile::TempDir;
2517
2518 use crate::backend::{BackendError, MemBackend};
2519 use crate::engine::test_helpers::*;
2520 use crate::engine::{Engine, EngineError};
2521 use crate::mem::MemOrigin;
2522 use crate::ops::WarningHint;
2523 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
2524
2525 fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
2526 let manifest = format!(
2527 r#"name: {manifest_name}
2528version: 1.0.0
2529description: Install-gate test schema
2530when_to_use: Tests
2531types:
2532 - sample
2533relationships:
2534 mode: strict
2535 definitions:
2536 - name: PART_OF
2537 description: hier
2538 default_weight: 3.0
2539 - name: _default
2540 description: fallback
2541 default_weight: 1.0
2542community:
2543 resolution: 1.0
2544 seed: 42
2545"#
2546 );
2547 let type_yaml = format!(
2548 r#"name: sample
2549description: t
2550when_to_use: tests
2551sections:
2552 - key: body
2553 heading: {heading}
2554 required: true
2555 search_weight: 10.0
2556 catch_all: true
2557 write_rules: []
2558metadata_fields: []
2559title_weight: 100.0
2560text_fields:
2561 - body
2562hierarchy_relationship: PART_OF
2563no_self_loop_relationships: []
2564updatable_fields:
2565 - title
2566 - body
2567health_required_fields:
2568 - body
2569staleness_threshold_days: 90
2570write_rules: []
2571"#
2572 );
2573 vec![
2574 ("schema.yaml".to_string(), manifest.into_bytes()),
2575 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2576 ]
2577 }
2578
2579 #[test]
2583 fn install_gate_refuses_non_roundtrip_heading() {
2584 let ok =
2585 Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
2586 assert!(ok.is_ok(), "conforming package passes: {ok:?}");
2587
2588 let err = Engine::validate_schema_package(
2589 "gate",
2590 "1.0.0",
2591 &schema_package_files("Body Text", "gate"),
2592 )
2593 .expect_err("non-deriving heading must refuse install");
2594 match &err {
2595 EngineError::SchemaPackageInvalid { name, message, .. } => {
2596 assert_eq!(name, "gate");
2597 assert!(
2598 message.contains("'body'") && message.contains("'Body Text'"),
2599 "message names the offending tuple: {message}"
2600 );
2601 }
2602 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2603 }
2604 }
2605
2606 fn exemplar_package_files(
2613 section_key: &str,
2614 status_value: &str,
2615 rel_type: &str,
2616 ) -> Vec<(String, Vec<u8>)> {
2617 let manifest = r#"name: gate
2618version: 1.0.0
2619description: exemplar gate fixture
2620when_to_use: tests
2621types:
2622 - sample
2623 - other
2624relationships:
2625 mode: strict
2626 definitions:
2627 - name: PART_OF
2628 description: hier
2629 default_weight: 3.0
2630 - name: REFINES
2631 description: pinned
2632 default_weight: 1.0
2633 source_types: [other]
2634 - name: _default
2635 description: fallback
2636 default_weight: 1.0
2637community:
2638 resolution: 1.0
2639 seed: 42
2640"#
2641 .to_string();
2642 let type_yaml = format!(
2643 r#"name: sample
2644description: t
2645when_to_use: tests
2646sections:
2647 - key: body
2648 heading: Body
2649 required: true
2650 search_weight: 10.0
2651 catch_all: true
2652 write_rules: []
2653metadata_fields:
2654 - key: status
2655 description: workflow state
2656 field_type: string
2657 enum_values: [draft, final]
2658title_weight: 100.0
2659text_fields:
2660 - body
2661hierarchy_relationship: PART_OF
2662no_self_loop_relationships: []
2663updatable_fields:
2664 - title
2665 - body
2666health_required_fields:
2667 - body
2668staleness_threshold_days: 90
2669write_rules: []
2670exemplar:
2671 title: A Conforming Sample
2672 metadata:
2673 status: "{status_value}"
2674 sections:
2675 {section_key}: "One canonical body paragraph."
2676 relations:
2677 - to: parent-placeholder
2678 type: {rel_type}
2679"#
2680 );
2681 let other_yaml = r#"name: other
2682description: shape-pin partner
2683when_to_use: tests
2684sections:
2685 - key: body
2686 heading: Body
2687 required: true
2688 search_weight: 10.0
2689 catch_all: true
2690 write_rules: []
2691metadata_fields: []
2692title_weight: 100.0
2693text_fields:
2694 - body
2695hierarchy_relationship: PART_OF
2696no_self_loop_relationships: []
2697updatable_fields:
2698 - title
2699 - body
2700health_required_fields:
2701 - body
2702staleness_threshold_days: 90
2703write_rules: []
2704"#
2705 .to_string();
2706 vec![
2707 ("schema.yaml".to_string(), manifest.into_bytes()),
2708 ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2709 ("types/other.yaml".to_string(), other_yaml.into_bytes()),
2710 ]
2711 }
2712
2713 #[test]
2720 fn install_gate_validates_exemplars_through_the_real_create_path() {
2721 let ok = Engine::validate_schema_package(
2723 "gate",
2724 "1.0.0",
2725 &exemplar_package_files("body", "draft", "PART_OF"),
2726 );
2727 assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
2728
2729 let err = Engine::validate_schema_package(
2731 "gate",
2732 "1.0.0",
2733 &exemplar_package_files("bogus_section", "draft", "PART_OF"),
2734 )
2735 .expect_err("wrong section key must refuse");
2736 match &err {
2737 EngineError::SchemaPackageInvalid { message, .. } => {
2738 assert!(
2739 message.contains("'sample'") && message.contains("exemplar"),
2740 "names type and calls out the exemplar: {message}"
2741 );
2742 assert!(
2743 message.contains("UNKNOWN_SECTION")
2744 || message.contains("MISSING_REQUIRED_SECTION"),
2745 "carries the typed defect code: {message}"
2746 );
2747 }
2748 other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2749 }
2750
2751 let err = Engine::validate_schema_package(
2753 "gate",
2754 "1.0.0",
2755 &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
2756 )
2757 .expect_err("illegal enum value must refuse");
2758 assert!(
2759 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2760 if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
2761 "got {err:?}"
2762 );
2763
2764 let err = Engine::validate_schema_package(
2767 "gate",
2768 "1.0.0",
2769 &exemplar_package_files("body", "draft", "REFINES"),
2770 )
2771 .expect_err("relationship shape violation must refuse");
2772 assert!(
2773 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2774 if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
2775 "got {err:?}"
2776 );
2777 }
2778
2779 #[test]
2784 fn worked_example_package_exemplars_validate() {
2785 let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2786 .join("../memstead-schema/examples/minimal");
2787 let schema = std::sync::Arc::new(
2788 memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
2789 );
2790 assert!(
2791 schema.types.values().all(|td| td.exemplar.is_some()),
2792 "every worked-example type models the exemplar practice"
2793 );
2794 Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
2795 }
2796
2797 #[test]
2804 fn builtin_exemplars_validate_through_the_install_gate() {
2805 let schemas = memstead_schema::builtins::load_builtin_schemas()
2806 .expect("built-in schemas always load");
2807 for schema in &schemas {
2809 if let Err(defect) = Engine::validate_schema_exemplars(schema) {
2810 let (name, version) = schema.id();
2811 panic!("built-in {name}@{version}: {defect}");
2812 }
2813 }
2814 let mut newest: std::collections::HashMap<
2816 String,
2817 &std::sync::Arc<memstead_schema::Schema>,
2818 > = std::collections::HashMap::new();
2819 for schema in &schemas {
2820 let name = schema.manifest.name.clone();
2821 match newest.get(&name) {
2822 Some(cur) if cur.version >= schema.version => {}
2823 _ => {
2824 newest.insert(name, schema);
2825 }
2826 }
2827 }
2828 for (name, schema) in &newest {
2829 for (type_name, td) in &schema.types {
2830 assert!(
2831 td.exemplar.is_some(),
2832 "built-in {name}@{} type '{type_name}' has no exemplar — the \
2833 reference schemas model the practice completely",
2834 schema.version
2835 );
2836 }
2837 }
2838 }
2839
2840 #[test]
2845 fn exemplar_relation_targets_are_bare_placeholder_slugs() {
2846 let mut files = exemplar_package_files("body", "draft", "PART_OF");
2847 let patched = String::from_utf8(files[1].1.clone())
2848 .unwrap()
2849 .replace("to: parent-placeholder", "to: other--real-entity");
2850 files[1].1 = patched.into_bytes();
2851 let err = Engine::validate_schema_package("gate", "1.0.0", &files)
2852 .expect_err("mem-prefixed exemplar target must refuse");
2853 assert!(
2854 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2855 if message.contains("bare") && message.contains("'sample'")),
2856 "got {err:?}"
2857 );
2858 }
2859
2860 #[test]
2864 fn install_gate_refuses_manifest_identity_mismatch() {
2865 let err = Engine::validate_schema_package(
2866 "gate",
2867 "1.0.0",
2868 &schema_package_files("Body", "other"),
2869 )
2870 .expect_err("identity mismatch must refuse install");
2871 assert!(
2872 matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2873 if message.contains("other@1.0.0")),
2874 "got {err:?}"
2875 );
2876 }
2877
2878 #[test]
2879 fn reload_each_writable_mem_repopulates_load_warnings() {
2880 let tmp = TempDir::new().unwrap();
2884 let mem_dir = tmp.path().to_path_buf();
2885 let writer = FilesystemMemWriter::new(mem_dir.clone());
2886 let mut mount = folder_mount("specs", mem_dir.clone());
2889 mount.schema = Some("default@1.3.0".parse().unwrap());
2890 let mut engine =
2891 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2892 assert!(
2893 engine.load_warnings().is_empty(),
2894 "clean boot has no warnings"
2895 );
2896
2897 let body =
2899 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
2900 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
2901
2902 engine.reload_each_writable_mem().unwrap();
2903 let warnings = engine.load_warnings();
2904 assert!(
2905 warnings
2906 .iter()
2907 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
2908 "workspace-wide reload must repopulate load_warnings: {warnings:?}",
2909 );
2910 }
2911
2912 #[test]
2919 fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
2920 let tmp = TempDir::new().unwrap();
2921 let mem_dir = tmp.path().to_path_buf();
2922 let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
2924 std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
2925 let writer = FilesystemMemWriter::new(mem_dir.clone());
2926 let mut engine = Engine::from_mounts(vec![(
2927 folder_mount("specs", mem_dir.clone()),
2928 Box::new(writer) as Box<dyn MemBackend>,
2929 )])
2930 .unwrap();
2931 assert!(
2933 !engine
2934 .load_warnings()
2935 .iter()
2936 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
2937 "clean boot must not emit ParsedRelationInvalid; got: {:?}",
2938 engine.load_warnings()
2939 );
2940
2941 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";
2943 std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
2944
2945 engine.reload_each_writable_mem().unwrap();
2946
2947 let invalid: Vec<_> = engine
2948 .load_warnings()
2949 .iter()
2950 .filter_map(|w| match w {
2951 WarningHint::ParsedRelationInvalid {
2952 rel_type,
2953 reason,
2954 origin,
2955 ..
2956 } => Some((rel_type.clone(), reason.clone(), origin.clone())),
2957 _ => None,
2958 })
2959 .collect();
2960 assert_eq!(
2961 invalid.len(),
2962 1,
2963 "reload must surface the parse-time drift, got: {invalid:?}",
2964 );
2965 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
2966 assert_eq!(invalid[0].1, "unknown_rel_type");
2967 assert_eq!(invalid[0].2, "writable");
2968 }
2969
2970 #[test]
2971 fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
2972 let tmp = TempDir::new().unwrap();
2979 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
2980 let a_dir = tmp.path().join("a");
2981 std::fs::create_dir_all(&a_dir).unwrap();
2982 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
2983 let b_dir = tmp.path().join("b");
2984 std::fs::create_dir_all(&b_dir).unwrap();
2985 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
2986 let mut engine = Engine::from_mounts(vec![
2987 (
2988 folder_mount("alpha", a_dir.clone()),
2989 Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
2990 ),
2991 (
2992 folder_mount("beta", b_dir.clone()),
2993 Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
2994 ),
2995 ])
2996 .unwrap();
2997 let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
2998 let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
2999 assert!(
3000 pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3001 "boot must populate one warning per mem: {pre:?}"
3002 );
3003
3004 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3006 std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3007 engine.reload_one_mem("alpha").unwrap();
3008
3009 let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3010 assert!(
3011 !post.contains(&"alpha".to_string()),
3012 "reload must drop the healed mem's stale warning: {post:?}"
3013 );
3014 assert!(
3015 post.contains(&"beta".to_string()),
3016 "reload of alpha must not clear beta's slice: {post:?}"
3017 );
3018 }
3019
3020 #[test]
3021 fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3022 let tmp = TempDir::new().unwrap();
3027 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3028 let a_dir = tmp.path().join("a");
3029 std::fs::create_dir_all(&a_dir).unwrap();
3030 std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3031 let b_dir = tmp.path().join("b");
3032 std::fs::create_dir_all(&b_dir).unwrap();
3033 std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3034 let mut engine = Engine::from_mounts(vec![
3035 (
3036 folder_mount("alpha", a_dir.clone()),
3037 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3038 ),
3039 (
3040 folder_mount("beta", b_dir.clone()),
3041 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3042 ),
3043 ])
3044 .unwrap();
3045 assert!(
3046 engine
3047 .load_warnings()
3048 .iter()
3049 .any(|w| w.source_mem() == Some("alpha")),
3050 "boot must carry alpha-sourced warnings"
3051 );
3052
3053 engine.unregister_writable_mem("alpha").unwrap();
3054
3055 let post = engine.load_warnings();
3056 assert!(
3057 !post.iter().any(|w| w.source_mem() == Some("alpha")),
3058 "delete must purge the removed mem's warnings: {post:?}"
3059 );
3060 assert!(
3061 post.iter().any(|w| w.source_mem() == Some("beta")),
3062 "delete of alpha must keep beta's warnings: {post:?}"
3063 );
3064 }
3065
3066 #[test]
3067 fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3068 let tmp = TempDir::new().unwrap();
3074 let a_dir = tmp.path().join("a");
3075 std::fs::create_dir_all(&a_dir).unwrap();
3076 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";
3077 std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3078 let b_dir = tmp.path().join("b");
3079 std::fs::create_dir_all(&b_dir).unwrap();
3080 let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3081 std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3082 let mut engine = Engine::from_mounts(vec![
3083 (
3084 folder_mount("alpha", a_dir.clone()),
3085 Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3086 ),
3087 (
3088 folder_mount("beta", b_dir.clone()),
3089 Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3090 ),
3091 ])
3092 .unwrap();
3093 let alpha_sourced = |engine: &Engine| {
3094 engine
3095 .load_warnings()
3096 .iter()
3097 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3098 };
3099 assert!(
3100 alpha_sourced(&engine),
3101 "boot must flag alpha's invalid row: {:?}",
3102 engine.load_warnings()
3103 );
3104
3105 engine.unregister_writable_mem("beta").unwrap();
3106
3107 assert!(
3108 alpha_sourced(&engine),
3109 "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3110 engine.load_warnings()
3111 );
3112 }
3113
3114 #[test]
3122 fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3123 let tmp = TempDir::new().unwrap();
3124 let mem_dir = tmp.path().to_path_buf();
3125 let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3126 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3127 let writer = FilesystemMemWriter::new(mem_dir.clone());
3128 let mut engine = Engine::from_mounts(vec![(
3129 folder_mount("specs", mem_dir.clone()),
3130 Box::new(writer) as Box<dyn MemBackend>,
3131 )])
3132 .unwrap();
3133 assert!(
3134 !engine.load_warnings().is_empty(),
3135 "boot must populate load_warnings"
3136 );
3137
3138 let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3141 std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3142 engine.reload_each_writable_mem_reports().unwrap();
3143 assert!(
3144 engine.load_warnings().is_empty(),
3145 "reports sweep must drop healed warnings: {:?}",
3146 engine.load_warnings()
3147 );
3148
3149 std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3151 engine.reload_each_writable_mem_reports().unwrap();
3152 assert!(
3153 engine
3154 .load_warnings()
3155 .iter()
3156 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3157 "reports sweep must surface fresh drift: {:?}",
3158 engine.load_warnings()
3159 );
3160 }
3161
3162 #[test]
3170 fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3171 let tmp = TempDir::new().unwrap();
3172 let a_dir = tmp.path().join("a");
3173 let b_dir = tmp.path().join("b");
3174 std::fs::create_dir_all(&a_dir).unwrap();
3175 std::fs::create_dir_all(&b_dir).unwrap();
3176 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3177 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3178 let mut engine = Engine::from_mounts(vec![
3179 (
3180 folder_mount("specs", a_dir),
3181 Box::new(a_writer) as Box<dyn MemBackend>,
3182 ),
3183 (
3184 folder_mount("memos", b_dir),
3185 Box::new(b_writer) as Box<dyn MemBackend>,
3186 ),
3187 ])
3188 .unwrap();
3189
3190 let mut settings = crate::workspace::WorkspaceSettings::default();
3192 settings.cross_mem_links.insert(
3193 "specs".to_string(),
3194 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3195 );
3196 engine.set_settings(settings);
3197
3198 let (actor, client) = cli_actor();
3199 let source = engine
3200 .create_entity(
3201 empty_create_args("specs", "Source"),
3202 actor,
3203 Some(&client),
3204 None,
3205 )
3206 .unwrap();
3207 let target = engine
3208 .create_entity(
3209 empty_create_args("memos", "Target"),
3210 actor,
3211 Some(&client),
3212 None,
3213 )
3214 .unwrap();
3215 engine
3216 .relate_entity(
3217 crate::engine::RelateEntityArgs {
3218 source: source.id.clone(),
3219 expected_hash: Some(source.content_hash.clone()),
3220 rel_type: "USES".to_string(),
3221 target: target.id.clone(),
3222 remove: false,
3223 description: None,
3224 dry_run: false,
3225 },
3226 actor,
3227 Some(&client),
3228 None,
3229 )
3230 .unwrap();
3231
3232 let has_edge = |e: &Engine| {
3234 let out = e
3235 .store()
3236 .outgoing(&source.id)
3237 .iter()
3238 .any(|edge| edge.target == target.id);
3239 let inc = e
3240 .store()
3241 .incoming(&target.id)
3242 .iter()
3243 .any(|edge| edge.from == source.id);
3244 (out, inc)
3245 };
3246
3247 assert_eq!(
3248 has_edge(&engine),
3249 (true, true),
3250 "edge must be indexed in both directions after relate",
3251 );
3252
3253 engine.reload_one_mem("memos").unwrap();
3255 assert_eq!(
3256 has_edge(&engine),
3257 (true, true),
3258 "cross-mem edge into B must survive a per-mem reload of B",
3259 );
3260
3261 engine.reload_each_writable_mem().unwrap();
3264 assert_eq!(
3265 has_edge(&engine),
3266 (true, true),
3267 "per-mem and workspace reload converge on the same edge",
3268 );
3269
3270 assert!(
3273 engine
3274 .store()
3275 .get(&source.id)
3276 .unwrap()
3277 .relationships
3278 .iter()
3279 .any(|r| r.target == target.id),
3280 "source record must retain the relationship throughout",
3281 );
3282 }
3283
3284 #[test]
3290 fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3291 let tmp = TempDir::new().unwrap();
3292 let a_dir = tmp.path().join("a");
3293 let b_dir = tmp.path().join("b");
3294 std::fs::create_dir_all(&a_dir).unwrap();
3295 std::fs::create_dir_all(&b_dir).unwrap();
3296 let a_writer = FilesystemMemWriter::new(a_dir.clone());
3297 let b_writer = FilesystemMemWriter::new(b_dir.clone());
3298 let mut engine = Engine::from_mounts(vec![
3299 (
3300 folder_mount("specs", a_dir),
3301 Box::new(a_writer) as Box<dyn MemBackend>,
3302 ),
3303 (
3304 folder_mount("memos", b_dir),
3305 Box::new(b_writer) as Box<dyn MemBackend>,
3306 ),
3307 ])
3308 .unwrap();
3309 let mut settings = crate::workspace::WorkspaceSettings::default();
3310 settings.cross_mem_links.insert(
3311 "specs".to_string(),
3312 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3313 );
3314 engine.set_settings(settings);
3315
3316 let (actor, client) = cli_actor();
3317 let source = engine
3318 .create_entity(
3319 empty_create_args("specs", "Source"),
3320 actor,
3321 Some(&client),
3322 None,
3323 )
3324 .unwrap();
3325 let target = engine
3326 .create_entity(
3327 empty_create_args("memos", "Target"),
3328 actor,
3329 Some(&client),
3330 None,
3331 )
3332 .unwrap();
3333 engine
3334 .relate_entity(
3335 crate::engine::RelateEntityArgs {
3336 source: source.id.clone(),
3337 expected_hash: Some(source.content_hash.clone()),
3338 rel_type: "USES".to_string(),
3339 target: target.id.clone(),
3340 remove: false,
3341 description: None,
3342 dry_run: false,
3343 },
3344 actor,
3345 Some(&client),
3346 None,
3347 )
3348 .unwrap();
3349
3350 engine.reload_one_mem("specs").unwrap();
3351
3352 let out = engine
3353 .store()
3354 .outgoing(&source.id)
3355 .iter()
3356 .any(|edge| edge.target == target.id);
3357 let inc = engine
3358 .store()
3359 .incoming(&target.id)
3360 .iter()
3361 .any(|edge| edge.from == source.id);
3362 assert!(
3363 out && inc,
3364 "outgoing cross-mem edge must survive a source-mem reload"
3365 );
3366 }
3367
3368 #[test]
3369 fn workspace_root_setter_round_trips() {
3370 let tmp = TempDir::new().unwrap();
3371 let mem_dir = tmp.path().to_path_buf();
3372 let writer = FilesystemMemWriter::new(mem_dir.clone());
3373 let mut engine = Engine::from_mounts(vec![(
3374 folder_mount("specs", mem_dir),
3375 Box::new(writer) as Box<dyn MemBackend>,
3376 )])
3377 .unwrap();
3378 let root = tmp.path().to_path_buf();
3379 engine.set_workspace_root(root.clone());
3380 assert_eq!(engine.workspace_root(), Some(root.as_path()));
3381 }
3382
3383 #[test]
3384 fn export_mem_folder_backend_produces_archive() {
3385 let tmp = TempDir::new().unwrap();
3389 let mem_dir = tmp.path().join("specs");
3390 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3391 let config_body = r#"{
3392 "format": 1,
3393 "schema": "default@1.0.0",
3394 "version": "1.0.0"
3395 }"#;
3396 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3397
3398 let writer = FilesystemMemWriter::new(mem_dir.clone());
3399 let engine = Engine::from_mounts(vec![(
3400 folder_mount("specs", mem_dir.clone()),
3401 Box::new(writer) as Box<dyn MemBackend>,
3402 )])
3403 .unwrap();
3404
3405 let archive_path = tmp.path().join("specs.mem");
3406 let result = engine.export_mem("specs", &archive_path).unwrap();
3407 assert!(archive_path.exists(), "archive must exist on disk");
3408 assert!(result.size_bytes > 0);
3409 assert_eq!(result.entity_count, 0);
3412 }
3413
3414 #[test]
3415 fn export_mem_unknown_mem_returns_unknown_mem() {
3416 let tmp = TempDir::new().unwrap();
3417 let mem_dir = tmp.path().to_path_buf();
3418 let writer = FilesystemMemWriter::new(mem_dir.clone());
3419 let engine = Engine::from_mounts(vec![(
3420 folder_mount("specs", mem_dir),
3421 Box::new(writer) as Box<dyn MemBackend>,
3422 )])
3423 .unwrap();
3424 let output = tmp.path().join("out.mem");
3425 let err = engine.export_mem("missing", &output).unwrap_err();
3426 assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3427 }
3428
3429 #[test]
3430 fn export_mem_missing_config_returns_invalid_input() {
3431 let tmp = TempDir::new().unwrap();
3435 let mem_dir = tmp.path().to_path_buf();
3436 let writer = FilesystemMemWriter::new(mem_dir.clone());
3437 let engine = Engine::from_mounts(vec![(
3438 folder_mount("specs", mem_dir),
3439 Box::new(writer) as Box<dyn MemBackend>,
3440 )])
3441 .unwrap();
3442 let output = tmp.path().join("out.mem");
3443 let err = engine.export_mem("specs", &output).unwrap_err();
3444 assert!(matches!(err, EngineError::InvalidInput(_)));
3445 }
3446
3447 #[test]
3448 fn export_mem_archive_backend_returns_sealed() {
3449 let tmp = TempDir::new().unwrap();
3452 let archive_path = build_archive(
3453 tmp.path(),
3454 "ext",
3455 &[(
3456 ".memstead/config.json",
3457 b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3458 )],
3459 );
3460 let engine = Engine::from_mounts(vec![(
3461 archive_mount("ext", archive_path.clone()),
3462 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3463 )])
3464 .unwrap();
3465 let output = tmp.path().join("out.mem");
3466 let err = engine.export_mem("ext", &output).unwrap_err();
3467 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3468 }
3469
3470 #[test]
3471 fn export_markdown_writes_unchanged_files_zero_writes() {
3472 let tmp = TempDir::new().unwrap();
3477 let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3478 let result = engine.export_markdown(None, None).unwrap();
3479 assert_eq!(
3480 result.written, 0,
3481 "freshly-created entity's file already matches generated markdown"
3482 );
3483 assert_eq!(
3484 result.unchanged, 1,
3485 "the one seeded entity counts as unchanged"
3486 );
3487 assert!(
3488 result.skipped_mounts.is_empty(),
3489 "folder-only workspace has no skipped mounts"
3490 );
3491 }
3492
3493 #[test]
3494 fn export_markdown_skips_non_folder_mounts() {
3495 let tmp = TempDir::new().unwrap();
3499 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3500 let engine = Engine::from_mounts(vec![(
3501 archive_mount("ext", archive_path.clone()),
3502 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3503 )])
3504 .unwrap();
3505 let result = engine.export_markdown(None, None).unwrap();
3506 assert_eq!(result.written, 0);
3507 assert_eq!(result.unchanged, 0);
3508 assert_eq!(
3509 result.skipped_mounts.len(),
3510 1,
3511 "archive mount is in the skipped list"
3512 );
3513 let entry = &result.skipped_mounts[0];
3514 assert_eq!(entry.mem, "ext");
3515 assert_eq!(entry.active_backend, "archive");
3516 assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
3517 }
3518
3519 #[test]
3520 fn export_markdown_per_mem_refuses_on_incompatible_backend() {
3521 let tmp = TempDir::new().unwrap();
3525 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3526 let engine = Engine::from_mounts(vec![(
3527 archive_mount("ext", archive_path.clone()),
3528 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3529 )])
3530 .unwrap();
3531 let err = engine.export_markdown(Some("ext"), None).unwrap_err();
3532 assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
3533 let details = err.details();
3534 assert_eq!(details["mem"], "ext");
3535 assert_eq!(details["active_backend"], "archive");
3536 assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
3537 }
3538
3539 #[test]
3540 fn register_writable_mem_adds_mount_and_router_entry() {
3541 let tmp = TempDir::new().unwrap();
3544 let mem_a = tmp.path().join("a");
3545 std::fs::create_dir_all(&mem_a).unwrap();
3546 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3547
3548 let mut engine = Engine::from_mounts(vec![(
3549 folder_mount("alpha", mem_a),
3550 Box::new(writer_a) as Box<dyn MemBackend>,
3551 )])
3552 .unwrap();
3553 assert!(engine.mem_router().is_writable("alpha"));
3554
3555 let mem_b = tmp.path().join("b");
3556 std::fs::create_dir_all(&mem_b).unwrap();
3557 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3558
3559 engine
3560 .register_writable_mem(
3561 folder_mount("beta", mem_b.clone()),
3562 Box::new(writer_b) as Box<dyn MemBackend>,
3563 MemOrigin::ExplicitToml,
3564 )
3565 .unwrap();
3566
3567 assert!(engine.mem_router().is_writable("alpha"));
3569 assert!(engine.mem_router().is_writable("beta"));
3570 assert!(engine.mem_router().is_visible("beta"));
3571
3572 assert!(engine.mount("beta").is_some());
3574 assert!(engine.schemas().contains_key("beta"));
3575
3576 assert_eq!(
3578 engine.mem_router().dir_for_mem("beta"),
3579 Some(mem_b.as_path()),
3580 );
3581 }
3582
3583 #[test]
3589 fn register_writable_mem_resolves_schema_from_mem_config() {
3590 let tmp = TempDir::new().unwrap();
3591 let mem_a = tmp.path().join("a");
3592 std::fs::create_dir_all(&mem_a).unwrap();
3593 let mut engine = Engine::from_mounts(vec![(
3594 folder_mount("alpha", mem_a.clone()),
3595 Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
3596 )])
3597 .unwrap();
3598
3599 let mem_b = tmp.path().join("b");
3600 std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
3601 std::fs::write(
3602 mem_b.join(".memstead").join("config.json"),
3603 r#"{"schema":"software@0.1.0"}"#,
3604 )
3605 .unwrap();
3606 let mount_b = crate::workspace::Mount {
3607 mem: "beta".to_string(),
3608 schema: Some(memstead_schema::SchemaRef::new(
3609 "totally-not-a-schema",
3610 semver::Version::new(9, 9, 9),
3611 )),
3612 storage: crate::workspace::MountStorage::Folder {
3613 path: mem_b.clone(),
3614 },
3615 capability: crate::workspace::MountCapability::Write,
3616 lifecycle: crate::workspace::MountLifecycle::Eager,
3617 cross_linkable: true,
3618 migration_target: None,
3619 };
3620 engine
3621 .register_writable_mem(
3622 mount_b,
3623 Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
3624 MemOrigin::ExplicitToml,
3625 )
3626 .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
3627
3628 assert!(engine.schemas().contains_key("beta"));
3629 let surfaced = engine.load_warnings().iter().any(|w| {
3630 matches!(
3631 w,
3632 WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
3633 if mem == "beta"
3634 && config_pin == "software@0.1.0"
3635 && mount_pin == "totally-not-a-schema@9.9.9"
3636 )
3637 });
3638 assert!(
3639 surfaced,
3640 "SchemaPinMismatch must surface for beta: {:?}",
3641 engine.load_warnings(),
3642 );
3643 }
3644
3645 #[test]
3646 fn register_writable_mem_rejects_existing_name() {
3647 let tmp = TempDir::new().unwrap();
3650 let mem_a = tmp.path().join("a");
3651 std::fs::create_dir_all(&mem_a).unwrap();
3652 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3653
3654 let mut engine = Engine::from_mounts(vec![(
3655 folder_mount("alpha", mem_a),
3656 Box::new(writer_a) as Box<dyn MemBackend>,
3657 )])
3658 .unwrap();
3659 let mount_count_pre = engine.mounts().len();
3660
3661 let mem_collide = tmp.path().join("alpha-2");
3662 std::fs::create_dir_all(&mem_collide).unwrap();
3663 let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
3664
3665 let err = engine
3666 .register_writable_mem(
3667 folder_mount("alpha", mem_collide),
3668 Box::new(writer_collide) as Box<dyn MemBackend>,
3669 MemOrigin::ExplicitToml,
3670 )
3671 .unwrap_err();
3672 match err {
3673 EngineError::MemNameCollision {
3674 name,
3675 source_origin,
3676 } => {
3677 assert_eq!(name, "alpha");
3678 assert!(
3683 source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
3684 );
3685 }
3686 other => panic!("expected MemNameCollision, got {other:?}"),
3687 }
3688
3689 assert_eq!(engine.mounts().len(), mount_count_pre);
3691 }
3692
3693 #[test]
3694 fn register_writable_mem_loads_entities_into_store() {
3695 let tmp = TempDir::new().unwrap();
3698 let mem_a = tmp.path().join("a");
3699 std::fs::create_dir_all(&mem_a).unwrap();
3700 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3701
3702 let mut engine = Engine::from_mounts(vec![(
3703 folder_mount("alpha", mem_a),
3704 Box::new(writer_a) as Box<dyn MemBackend>,
3705 )])
3706 .unwrap();
3707 let pre_count = engine.store().all_entities().count();
3708
3709 let mem_b = tmp.path().join("b");
3711 std::fs::create_dir_all(&mem_b).unwrap();
3712 std::fs::write(
3713 mem_b.join("b1.md"),
3714 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
3715 )
3716 .unwrap();
3717 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3718
3719 engine
3720 .register_writable_mem(
3721 folder_mount("beta", mem_b),
3722 Box::new(writer_b) as Box<dyn MemBackend>,
3723 MemOrigin::ExplicitToml,
3724 )
3725 .unwrap();
3726
3727 let post_count = engine.store().all_entities().count();
3728 assert!(post_count > pre_count, "register must load entities");
3729 let beta_count = engine
3730 .store()
3731 .all_entities()
3732 .filter(|e| e.mem == "beta")
3733 .count();
3734 assert_eq!(beta_count, 1);
3735 }
3736
3737 #[test]
3738 fn register_then_unregister_round_trips() {
3739 let tmp = TempDir::new().unwrap();
3743 let mem_a = tmp.path().join("a");
3744 std::fs::create_dir_all(&mem_a).unwrap();
3745 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3746
3747 let mut engine = Engine::from_mounts(vec![(
3748 folder_mount("alpha", mem_a),
3749 Box::new(writer_a) as Box<dyn MemBackend>,
3750 )])
3751 .unwrap();
3752 let pre_mounts = engine.mounts().len();
3753
3754 let mem_b = tmp.path().join("b");
3755 std::fs::create_dir_all(&mem_b).unwrap();
3756 let writer_b = FilesystemMemWriter::new(mem_b);
3757
3758 engine
3759 .register_writable_mem(
3760 folder_mount("beta", tmp.path().join("b")),
3761 Box::new(writer_b) as Box<dyn MemBackend>,
3762 MemOrigin::ExplicitToml,
3763 )
3764 .unwrap();
3765 assert_eq!(engine.mounts().len(), pre_mounts + 1);
3766
3767 let removed = engine.unregister_writable_mem("beta").unwrap();
3768 assert!(removed.is_some());
3769 assert_eq!(engine.mounts().len(), pre_mounts);
3770 assert!(!engine.mem_router().is_writable("beta"));
3771 }
3772
3773 #[test]
3774 fn unregister_writable_mem_returns_false_for_unknown_name() {
3775 let tmp = TempDir::new().unwrap();
3779 let mem_dir = tmp.path().to_path_buf();
3780 let writer = FilesystemMemWriter::new(mem_dir.clone());
3781 let mut engine = Engine::from_mounts(vec![(
3782 folder_mount("specs", mem_dir),
3783 Box::new(writer) as Box<dyn MemBackend>,
3784 )])
3785 .unwrap();
3786 let removed = engine.unregister_writable_mem("missing").unwrap();
3787 assert!(removed.is_none(), "unknown mem returns Ok(None)");
3788 assert!(engine.mem_router().is_writable("specs"));
3790 }
3791
3792 #[test]
3793 fn unregister_writable_mem_drops_mount_and_router_entry() {
3794 let tmp = TempDir::new().unwrap();
3799 let mem_a = tmp.path().join("a");
3800 std::fs::create_dir_all(&mem_a).unwrap();
3801 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3802 let mem_b = tmp.path().join("b");
3803 std::fs::create_dir_all(&mem_b).unwrap();
3804 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3805
3806 let mut engine = Engine::from_mounts(vec![
3807 (
3808 folder_mount("alpha", mem_a),
3809 Box::new(writer_a) as Box<dyn MemBackend>,
3810 ),
3811 (
3812 folder_mount("beta", mem_b),
3813 Box::new(writer_b) as Box<dyn MemBackend>,
3814 ),
3815 ])
3816 .unwrap();
3817
3818 let removed = engine.unregister_writable_mem("alpha").unwrap();
3819 assert!(removed.is_some());
3820
3821 assert!(!engine.mem_router().is_writable("alpha"));
3823 assert!(!engine.mem_router().is_visible("alpha"));
3824 assert!(engine.mount("alpha").is_none());
3825
3826 assert!(engine.mem_router().is_writable("beta"));
3828 assert!(engine.mount("beta").is_some());
3829 }
3830
3831 #[test]
3832 fn unregister_writable_mem_drops_entities_for_that_mem_only() {
3833 let tmp = TempDir::new().unwrap();
3837 let mem_a = tmp.path().join("a");
3838 std::fs::create_dir_all(&mem_a).unwrap();
3839 std::fs::write(
3840 mem_a.join("a1.md"),
3841 "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
3842 )
3843 .unwrap();
3844 let writer_a = FilesystemMemWriter::new(mem_a.clone());
3845
3846 let mem_b = tmp.path().join("b");
3847 std::fs::create_dir_all(&mem_b).unwrap();
3848 std::fs::write(
3849 mem_b.join("b1.md"),
3850 "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
3851 )
3852 .unwrap();
3853 let writer_b = FilesystemMemWriter::new(mem_b.clone());
3854
3855 let mut engine = Engine::from_mounts(vec![
3856 (
3857 folder_mount("alpha", mem_a),
3858 Box::new(writer_a) as Box<dyn MemBackend>,
3859 ),
3860 (
3861 folder_mount("beta", mem_b),
3862 Box::new(writer_b) as Box<dyn MemBackend>,
3863 ),
3864 ])
3865 .unwrap();
3866
3867 let pre_total = engine.store().all_entities().count();
3868 assert!(pre_total >= 2, "both mems must load entities");
3869
3870 engine.unregister_writable_mem("alpha").unwrap();
3871
3872 let alpha_remaining = engine
3874 .store()
3875 .all_entities()
3876 .filter(|e| e.mem == "alpha")
3877 .count();
3878 assert_eq!(alpha_remaining, 0);
3879
3880 let beta_remaining = engine
3882 .store()
3883 .all_entities()
3884 .filter(|e| e.mem == "beta")
3885 .count();
3886 assert!(beta_remaining > 0, "beta entities must survive");
3887 }
3888 #[test]
3889 fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
3890 let tmp = TempDir::new().unwrap();
3891 let mut engine = build_demo_engine(&tmp);
3892 let result = engine
3893 .reload_one_mem("specs")
3894 .expect("reload on stable disk must succeed");
3895 assert!(result.added.is_empty(), "added: {:?}", result.added);
3896 assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
3897 assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
3898 }
3899
3900 #[test]
3901 fn reload_one_mem_picks_up_external_addition() {
3902 let tmp = TempDir::new().unwrap();
3903 let mut engine = build_demo_engine(&tmp);
3904 std::fs::write(
3907 tmp.path().join("external.md"),
3908 "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
3909 )
3910 .unwrap();
3911 let result = engine.reload_one_mem("specs").unwrap();
3912 assert_eq!(
3913 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
3914 vec!["specs--external"]
3915 );
3916 assert!(result.changed.is_empty());
3917 assert!(result.removed.is_empty());
3918 assert!(
3920 engine
3921 .get_entity(&crate::EntityId::new("specs", "external"))
3922 .is_some()
3923 );
3924 }
3925
3926 #[test]
3927 fn reload_one_mem_picks_up_external_removal() {
3928 let tmp = TempDir::new().unwrap();
3929 let mut engine = build_demo_engine(&tmp);
3930 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
3933 let result = engine.reload_one_mem("specs").unwrap();
3934 assert!(result.added.is_empty());
3935 assert!(result.changed.is_empty());
3936 assert_eq!(
3937 result
3938 .removed
3939 .iter()
3940 .map(|i| i.as_ref())
3941 .collect::<Vec<_>>(),
3942 vec!["specs--lonely-three"]
3943 );
3944 }
3945
3946 #[test]
3947 fn reload_one_mem_picks_up_external_change() {
3948 let tmp = TempDir::new().unwrap();
3949 let mut engine = build_demo_engine(&tmp);
3950 std::fs::write(
3953 tmp.path().join("source-one.md"),
3954 "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
3955 )
3956 .unwrap();
3957 let result = engine.reload_one_mem("specs").unwrap();
3958 assert!(result.added.is_empty());
3959 assert_eq!(
3960 result
3961 .changed
3962 .iter()
3963 .map(|i| i.as_ref())
3964 .collect::<Vec<_>>(),
3965 vec!["specs--source-one"]
3966 );
3967 assert!(result.removed.is_empty());
3968 }
3969
3970 #[test]
3971 fn reload_one_mem_rejects_unknown_mem() {
3972 let tmp = TempDir::new().unwrap();
3973 let mut engine = build_demo_engine(&tmp);
3974 let err = engine.reload_one_mem("nope").unwrap_err();
3975 match err {
3976 EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
3977 other => panic!("expected UnknownMem, got {other:?}"),
3978 }
3979 }
3980
3981 #[test]
3982 fn reload_each_writable_mem_returns_one_entry_per_mount() {
3983 let tmp = TempDir::new().unwrap();
3984 let mut engine = build_demo_engine(&tmp);
3985 let reports = engine
3986 .reload_each_writable_mem()
3987 .expect("batch reload on stable disk must succeed");
3988 assert_eq!(reports.len(), 1);
3989 assert_eq!(reports[0].0, "specs");
3990 assert!(reports[0].1.added.is_empty());
3991 assert!(reports[0].1.changed.is_empty());
3992 assert!(reports[0].1.removed.is_empty());
3993 }
3994
3995 #[test]
3998 fn settings_default_to_empty_on_fresh_engine() {
3999 let tmp = TempDir::new().unwrap();
4000 let engine = build_demo_engine(&tmp);
4001 let s = engine.settings();
4002 assert!(s.mem_create_rules.is_empty());
4003 assert!(s.mem_delete_rules.is_empty());
4004 assert!(s.cross_mem_links.is_empty());
4005 }
4006
4007 #[test]
4008 fn set_settings_replaces_workspace_policy() {
4009 use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4010 let tmp = TempDir::new().unwrap();
4011 let mut engine = build_demo_engine(&tmp);
4012 let mut settings = WorkspaceSettings::default();
4013 settings.mem_create_rules.push(CreateRuleSetting {
4014 pattern: "exec-*".to_string(),
4015 schemas: vec!["default@1.0.0".to_string()],
4016 default_cross_links: None,
4017 });
4018 settings.mem_delete_rules.push(DeleteRuleSetting {
4019 pattern: "exec-*".to_string(),
4020 });
4021 engine.set_settings(settings);
4022 assert_eq!(engine.settings().mem_create_rules.len(), 1);
4023 assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4024 assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4025 assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4026 }
4027
4028 #[test]
4031 fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4032 let tmp = TempDir::new().unwrap();
4033 let mut engine = build_demo_engine(&tmp);
4034 std::fs::write(
4036 tmp.path().join("new-via-disk.md"),
4037 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4038 )
4039 .unwrap();
4040 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4041 std::fs::write(
4042 tmp.path().join("source-one.md"),
4043 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4044 )
4045 .unwrap();
4046
4047 let reports = engine.reload_each_writable_mem().unwrap();
4048 assert_eq!(reports.len(), 1);
4049 let (mem, result) = &reports[0];
4050 assert_eq!(mem, "specs");
4051 assert_eq!(
4052 result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4053 vec!["specs--new-via-disk"]
4054 );
4055 assert_eq!(
4056 result
4057 .removed
4058 .iter()
4059 .map(|i| i.as_ref())
4060 .collect::<Vec<_>>(),
4061 vec!["specs--lonely-three"]
4062 );
4063 assert_eq!(
4064 result
4065 .changed
4066 .iter()
4067 .map(|i| i.as_ref())
4068 .collect::<Vec<_>>(),
4069 vec!["specs--source-one"]
4070 );
4071 }
4072
4073 #[test]
4076 fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4077 let tmp = TempDir::new().unwrap();
4085 let mut engine = build_demo_engine(&tmp);
4086 let report = engine.reload_one_mem_report("specs").unwrap();
4087 assert_eq!(report.mem, "specs");
4088 assert_eq!(
4089 report.head_before, report.head_after,
4090 "unchanged disk → stable cursor"
4091 );
4092 assert!(
4093 crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4094 "folder heads carry the changelog-ts cursor, got {}",
4095 report.head_after
4096 );
4097 assert_eq!(report.entities_loaded, 3);
4100 assert!(report.changed_entity_ids.is_empty());
4102 }
4103
4104 #[test]
4105 fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4106 let tmp = TempDir::new().unwrap();
4111 let mut engine = build_demo_engine(&tmp);
4112 std::fs::write(
4113 tmp.path().join("new-via-disk.md"),
4114 "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4115 )
4116 .unwrap();
4117 std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4118 std::fs::write(
4119 tmp.path().join("source-one.md"),
4120 "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4121 )
4122 .unwrap();
4123
4124 let report = engine.reload_one_mem_report("specs").unwrap();
4125 assert_eq!(report.mem, "specs");
4126 let ids: Vec<&str> = report
4127 .changed_entity_ids
4128 .iter()
4129 .map(|id| id.as_ref())
4130 .collect();
4131 assert_eq!(
4133 ids,
4134 vec![
4135 "specs--lonely-three",
4136 "specs--new-via-disk",
4137 "specs--source-one",
4138 ]
4139 );
4140 }
4141
4142 #[test]
4143 fn reload_one_mem_report_rejects_unknown_mem() {
4144 let tmp = TempDir::new().unwrap();
4145 let mut engine = build_demo_engine(&tmp);
4146 let err = engine.reload_one_mem_report("missing").unwrap_err();
4147 assert!(matches!(err, EngineError::UnknownMem(_)));
4148 }
4149
4150 #[test]
4151 fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4152 let tmp = TempDir::new().unwrap();
4153 let mut engine = build_demo_engine(&tmp);
4154 let reports = engine.reload_each_writable_mem_reports().unwrap();
4155 assert_eq!(reports.len(), 1);
4156 assert_eq!(reports[0].mem, "specs");
4157 assert_eq!(reports[0].entities_loaded, 3);
4158 }
4159
4160 #[test]
4167 fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4168 let tmp = TempDir::new().unwrap();
4169
4170 let memstead_dir = tmp.path().join(".memstead");
4173 std::fs::create_dir_all(&memstead_dir).unwrap();
4174 let workspace_toml = memstead_dir.join("workspace.toml");
4175 std::fs::write(
4176 &workspace_toml,
4177 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4178 )
4179 .unwrap();
4180 let mounts_json = memstead_dir.join("state").join("mounts.json");
4181 std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4182 let mem_dir = tmp.path().join("specs");
4183 std::fs::create_dir_all(&mem_dir).unwrap();
4184 let mounts_body = format!(
4185 r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4186 mem_dir.display(),
4187 );
4188 std::fs::write(&mounts_json, mounts_body).unwrap();
4189
4190 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4191 assert!(
4192 engine.settings().mem_create_rules.is_empty(),
4193 "boot-time settings carry no create rules"
4194 );
4195
4196 std::fs::write(
4198 &workspace_toml,
4199 "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",
4200 )
4201 .unwrap();
4202
4203 engine.reload_each_writable_mem_reports().unwrap();
4204
4205 let rules = &engine.settings().mem_create_rules;
4206 assert_eq!(
4207 rules.len(),
4208 1,
4209 "workspace-wide reload must refresh the policy"
4210 );
4211 assert_eq!(rules[0].pattern, "exec-*");
4212 }
4213
4214 const MIG_TYPE_TAIL: &str = r#"sections:
4219 - key: body
4220 heading: Body
4221 required: true
4222 search_weight: 10.0
4223 catch_all: true
4224 write_rules: []
4225title_weight: 100.0
4226text_fields:
4227 - body
4228hierarchy_relationship: _default
4229no_self_loop_relationships: []
4230updatable_fields: []
4231health_required_fields: []
4232staleness_threshold_days: 90
4233write_rules: []
4234"#;
4235
4236 fn mig_manifest(name: &str, version: &str) -> String {
4241 format!(
4242 r#"name: {name}
4243version: {version}
4244description: migration test schema
4245when_to_use: tests
4246types:
4247 - doc
4248relationships:
4249 mode: strict
4250 definitions:
4251 - name: USES
4252 description: link
4253 default_weight: 1.0
4254 - name: _default
4255 description: fallback
4256 default_weight: 1.0
4257community:
4258 resolution: 1.0
4259 seed: 42
4260"#
4261 )
4262 }
4263
4264 fn mig_type_yaml(with_status: bool) -> String {
4265 let metadata = if with_status {
4266 "metadata_fields:\n - key: status\n description: Lifecycle state\n field_type: string\n required: true\n enum_values:\n - open\n - closed\n"
4267 } else {
4268 "metadata_fields: []\n"
4269 };
4270 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4271 }
4272
4273 fn write_mig_schema(
4274 root: &std::path::Path,
4275 dir: &str,
4276 name: &str,
4277 version: &str,
4278 with_status: bool,
4279 ) {
4280 let d = root.join(dir);
4281 std::fs::create_dir_all(d.join("types")).unwrap();
4282 std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4283 std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4284 }
4285
4286 fn migration_engine() -> (tempfile::TempDir, Engine) {
4291 let tmp = tempfile::TempDir::new().unwrap();
4292 let schemas_dir = tmp.path().join("schemas");
4293 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4294 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4295 write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
4296 let mem_dir = tmp.path().join("mem");
4297 std::fs::create_dir_all(&mem_dir).unwrap();
4298 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4299 let mut mount = folder_mount("specs", mem_dir);
4300 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4301 let mut engine = Engine::from_mounts_with_schemas_dir(
4302 vec![(
4303 mount,
4304 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4305 )],
4306 Some(&schemas_dir),
4307 )
4308 .unwrap();
4309 for title in ["One", "Two"] {
4310 let mut args = empty_create_args("specs", title);
4311 args.entity_type = "doc".to_string();
4312 args.sections =
4313 indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
4314 engine
4315 .create_entity(args, crate::vcs::Actor::Cli, None, None)
4316 .expect("conformant create under mig-a");
4317 }
4318 (tmp, engine)
4319 }
4320
4321 fn sref(s: &str) -> memstead_schema::SchemaRef {
4322 s.parse().unwrap()
4323 }
4324
4325 #[test]
4326 fn set_schema_noop_on_current_pin() {
4327 let (_tmp, mut engine) = migration_engine();
4328 let out = engine
4329 .set_mem_schema("specs", &sref("mig-a@0.1.0"))
4330 .unwrap();
4331 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
4332 assert_eq!(out.schema_pin, "mig-a@0.1.0");
4333 assert_eq!(out.migration_target, None);
4334 assert!(out.findings.is_empty());
4335 }
4336
4337 #[test]
4338 fn set_schema_switches_immediately_when_integral() {
4339 let (_tmp, mut engine) = migration_engine();
4342 let out = engine
4343 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4344 .unwrap();
4345 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4346 assert_eq!(out.schema_pin, "mig-a@0.2.0");
4347 assert_eq!(out.migration_target, None);
4348 assert!(out.findings.is_empty());
4349 assert_eq!(
4350 engine.schema_pin("specs").unwrap().as_display(),
4351 "mig-a@0.2.0"
4352 );
4353 assert!(engine.migration_target("specs").is_none());
4354 }
4355
4356 #[test]
4362 fn set_schema_switch_persists_pin_into_backend_config() {
4363 let tmp = tempfile::TempDir::new().unwrap();
4364 let schemas_dir = tmp.path().join("schemas");
4365 write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4366 write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4367 let mem_dir = tmp.path().join("mem");
4368 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4369 std::fs::write(
4371 mem_dir.join(".memstead").join("config.json"),
4372 br#"{"schema":"mig-a@0.1.0"}"#,
4373 )
4374 .unwrap();
4375 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4376 let mut mount = folder_mount("specs", mem_dir.clone());
4377 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4378 let mut engine = Engine::from_mounts_with_schemas_dir(
4379 vec![(
4380 mount,
4381 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4382 )],
4383 Some(&schemas_dir),
4384 )
4385 .unwrap();
4386
4387 let out = engine
4388 .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4389 .unwrap();
4390 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4391
4392 let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
4395 let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
4396 assert_eq!(
4397 cfg["schema"], "mig-a@0.2.0",
4398 "atomic switch must update the authoritative backend config"
4399 );
4400 }
4401
4402 #[test]
4403 fn set_schema_unknown_target_refuses_schema_not_found() {
4404 let (_tmp, mut engine) = migration_engine();
4405 let err = engine
4406 .set_mem_schema("specs", &sref("nope@9.9.9"))
4407 .unwrap_err();
4408 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
4409 assert!(engine.migration_target("specs").is_none());
4411 }
4412
4413 #[test]
4414 fn set_schema_migration_lifecycle_end_to_end() {
4415 let (_tmp, mut engine) = migration_engine();
4416 let target = sref("mig-b@0.1.0");
4417
4418 let out = engine.set_mem_schema("specs", &target).unwrap();
4420 assert_eq!(
4421 out.outcome,
4422 crate::engine::SetSchemaResult::MigrationStarted
4423 );
4424 assert_eq!(out.schema_pin, "mig-a@0.1.0");
4425 assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
4426 assert_eq!(out.findings.len(), 2, "both entities lack `status`");
4427 assert!(
4428 out.findings
4429 .iter()
4430 .all(|f| f.code == "REQUIRED_FIELD_UNSET")
4431 );
4432
4433 let one = crate::entity::EntityId::new("specs", "one");
4435 assert!(engine.store().get(&one).is_some());
4436
4437 let out = engine.set_mem_schema("specs", &target).unwrap();
4439 assert_eq!(
4440 out.outcome,
4441 crate::engine::SetSchemaResult::MigrationPending
4442 );
4443 assert_eq!(out.findings.len(), 2);
4444
4445 let mut bad = crate::engine::UpdateEntityArgs {
4449 anchors: Vec::new(),
4450 id: one.clone(),
4451 expected_hash: None,
4452 sections: indexmap::IndexMap::new(),
4453 append_sections: indexmap::IndexMap::new(),
4454 patch_sections: indexmap::IndexMap::new(),
4455 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
4456 metadata_unset: Vec::new(),
4457 declare_relations: Vec::new(),
4458 dry_run: false,
4459 relations_unset: Vec::new(),
4460 anchors_unset: Vec::new(),
4461 };
4462 let err = engine
4463 .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
4464 .unwrap_err();
4465 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
4466 bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
4467 engine
4468 .update_entity(bad, crate::vcs::Actor::Cli, None, None)
4469 .expect("repair write validated against the migration target");
4470
4471 let out = engine.set_mem_schema("specs", &target).unwrap();
4473 assert_eq!(
4474 out.outcome,
4475 crate::engine::SetSchemaResult::MigrationPending
4476 );
4477 assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
4478
4479 let two = crate::entity::EntityId::new("specs", "two");
4481 let repair = crate::engine::UpdateEntityArgs {
4482 anchors: Vec::new(),
4483 id: two.clone(),
4484 expected_hash: None,
4485 sections: indexmap::IndexMap::new(),
4486 append_sections: indexmap::IndexMap::new(),
4487 patch_sections: indexmap::IndexMap::new(),
4488 metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
4489 metadata_unset: Vec::new(),
4490 declare_relations: Vec::new(),
4491 dry_run: false,
4492 relations_unset: Vec::new(),
4493 anchors_unset: Vec::new(),
4494 };
4495 engine
4496 .update_entity(repair, crate::vcs::Actor::Cli, None, None)
4497 .unwrap();
4498 let out = engine.set_mem_schema("specs", &target).unwrap();
4499 assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4500 assert_eq!(out.schema_pin, "mig-b@0.1.0");
4501 assert_eq!(out.migration_target, None);
4502 assert!(out.findings.is_empty());
4503 assert_eq!(
4504 engine.schema_pin("specs").unwrap().as_display(),
4505 "mig-b@0.1.0"
4506 );
4507 assert!(engine.migration_target("specs").is_none());
4508 }
4509
4510 #[test]
4515 fn relations_unset_works_during_migration_without_mode_flag() {
4516 let (_tmp, mut engine) = migration_engine();
4517 let one = crate::entity::EntityId::new("specs", "one");
4518 let two = crate::entity::EntityId::new("specs", "two");
4519 engine
4520 .relate_entity(
4521 crate::engine::RelateEntityArgs {
4522 source: one.clone(),
4523 expected_hash: None,
4524 rel_type: "USES".to_string(),
4525 target: two.clone(),
4526 remove: false,
4527 description: None,
4528 dry_run: false,
4529 },
4530 crate::vcs::Actor::Cli,
4531 None,
4532 None,
4533 )
4534 .unwrap();
4535 let shut = engine
4537 .update_entity(
4538 crate::engine::UpdateEntityArgs {
4539 anchors: Vec::new(),
4540 id: one.clone(),
4541 expected_hash: None,
4542 sections: indexmap::IndexMap::new(),
4543 append_sections: indexmap::IndexMap::new(),
4544 patch_sections: indexmap::IndexMap::new(),
4545 metadata: indexmap::IndexMap::new(),
4546 metadata_unset: Vec::new(),
4547 declare_relations: Vec::new(),
4548 dry_run: false,
4549 relations_unset: vec![crate::ops::RelationUnsetArg {
4550 rel_type: "USES".to_string(),
4551 target: two.clone(),
4552 }],
4553 anchors_unset: Vec::new(),
4554 },
4555 crate::vcs::Actor::Cli,
4556 None,
4557 None,
4558 )
4559 .unwrap_err();
4560 assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
4561
4562 engine
4566 .set_mem_schema("specs", &sref("mig-b@0.1.0"))
4567 .unwrap();
4568 engine
4569 .update_entity(
4570 crate::engine::UpdateEntityArgs {
4571 anchors: Vec::new(),
4572 id: one.clone(),
4573 expected_hash: None,
4574 sections: indexmap::IndexMap::new(),
4575 append_sections: indexmap::IndexMap::new(),
4576 patch_sections: indexmap::IndexMap::new(),
4577 metadata: indexmap::IndexMap::from_iter([(
4578 "status".to_string(),
4579 "open".to_string(),
4580 )]),
4581 metadata_unset: Vec::new(),
4582 declare_relations: Vec::new(),
4583 dry_run: false,
4584 relations_unset: vec![crate::ops::RelationUnsetArg {
4585 rel_type: "USES".to_string(),
4586 target: two.clone(),
4587 }],
4588 anchors_unset: Vec::new(),
4589 },
4590 crate::vcs::Actor::Cli,
4591 None,
4592 None,
4593 )
4594 .expect("repair-shaped update lands during migration without a flag");
4595 let entity = engine.store().get(&one).unwrap();
4596 assert!(entity.relationships.is_empty());
4597 }
4598
4599 #[test]
4604 fn boot_resumes_dual_pin_validation_against_target() {
4605 let (tmp, engine) = migration_engine();
4606 drop(engine);
4607 let schemas_dir = tmp.path().join("schemas");
4608 let mem_dir = tmp.path().join("mem");
4609 let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4610 let mut mount = folder_mount("specs", mem_dir);
4611 mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4612 mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
4613 let engine = Engine::from_mounts_with_schemas_dir(
4614 vec![(
4615 mount,
4616 Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4617 )],
4618 Some(&schemas_dir),
4619 )
4620 .unwrap();
4621 let (name, version) = {
4623 let s = engine.schema_for("specs").unwrap();
4624 let (n, v) = s.id();
4625 (n.to_string(), v.to_string())
4626 };
4627 assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
4628 assert_eq!(
4631 engine.schema_pin("specs").unwrap().as_display(),
4632 "mig-a@0.1.0"
4633 );
4634 assert_eq!(
4635 engine.migration_target("specs").unwrap().as_display(),
4636 "mig-b@0.1.0"
4637 );
4638 }
4639
4640 #[test]
4650 fn every_lifecycle_setter_refuses_on_read_only_mount() {
4651 let tmp = TempDir::new().unwrap();
4652 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4653 let mut engine = Engine::from_mounts(vec![(
4654 archive_mount("ext", archive_path.clone()),
4655 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4656 )])
4657 .unwrap();
4658
4659 let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
4660 let attempts: Vec<(&str, EngineError)> = vec![
4661 (
4662 "set_mem_schema",
4663 engine.set_mem_schema("ext", &default_pin).unwrap_err(),
4664 ),
4665 (
4666 "set_mem_version",
4667 engine
4668 .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
4669 .unwrap_err(),
4670 ),
4671 (
4672 "set_mem_description",
4673 engine
4674 .set_mem_description("ext", Some("x".into()), None)
4675 .unwrap_err(),
4676 ),
4677 (
4678 "set_mem_title",
4679 engine
4680 .set_mem_title("ext", Some("x".into()), None)
4681 .unwrap_err(),
4682 ),
4683 (
4684 "set_mem_subject",
4685 engine.set_mem_subject("ext", None, None).unwrap_err(),
4686 ),
4687 (
4688 "set_mem_internal",
4689 engine.set_mem_internal("ext", true, None).unwrap_err(),
4690 ),
4691 (
4692 "set_mem_sync_state",
4693 engine
4694 .set_mem_sync_state("ext", "k", "t", None)
4695 .unwrap_err(),
4696 ),
4697 ];
4698 for (setter, err) in attempts {
4699 match err {
4700 EngineError::ReadOnlyMount(v) => {
4701 assert_eq!(v, "ext", "{setter} must name the refused mem")
4702 }
4703 other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
4704 }
4705 }
4706 }
4707}