1use std::cell::OnceCell;
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use memstead_schema::Schema;
21
22use crate::backend::MemBackend;
23use crate::engine_fallback_type;
24use crate::entity::loader::parse_entries;
25use crate::entity::source::{SourceEntry, SourceReadError};
26use crate::entity::store_builder::push_entities_into_store;
27use crate::mem::{MemOrigin, MemRouterSnapshot};
28use crate::ops::WarningHint;
29use crate::store::Store;
30use crate::workspace::{Mount, MountCapability, MountStorage, WorkspaceSettings};
31
32use super::{BootError, Engine, EngineError, MountedBackend};
33
34impl Engine {
35 pub fn from_mounts(mounts: Vec<(Mount, Box<dyn MemBackend>)>) -> Result<Self, EngineError> {
47 Self::from_mounts_inner(mounts, Vec::new(), Vec::new())
48 }
49
50 pub fn from_mounts_with_schemas_dir(
61 mounts: Vec<(Mount, Box<dyn MemBackend>)>,
62 schemas_dir: Option<&Path>,
63 ) -> Result<Self, EngineError> {
64 let (extra_schemas, failed) = load_workspace_schemas_with_failures(schemas_dir);
65 Self::from_mounts_inner(mounts, extra_schemas, failed)
66 }
67
68 pub fn from_mounts_with_schemas_dir_and_extra(
77 mounts: Vec<(Mount, Box<dyn MemBackend>)>,
78 schemas_dir: Option<&Path>,
79 mut extra: Vec<Arc<memstead_schema::Schema>>,
80 ) -> Result<Self, EngineError> {
81 let (mut local, failed) = load_workspace_schemas_with_failures(schemas_dir);
82 local.append(&mut extra);
83 Self::from_mounts_inner(mounts, local, failed)
84 }
85
86 pub(crate) fn from_mounts_inner(
87 mounts: Vec<(Mount, Box<dyn MemBackend>)>,
88 extra_schemas: Vec<Arc<memstead_schema::Schema>>,
89 failed_schema_packages: Vec<FailedSchemaPackage>,
90 ) -> Result<Self, EngineError> {
91 let mut seen: std::collections::HashSet<String> =
92 std::collections::HashSet::with_capacity(mounts.len());
93 let mut mounted: Vec<MountedBackend> = Vec::with_capacity(mounts.len());
94 for (mount, backend) in mounts {
95 if !seen.insert(mount.mem.clone()) {
96 return Err(EngineError::DuplicateMem(mount.mem));
97 }
98 let last_known_head = backend.current_head().ok().flatten();
104 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
113 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
114 memstead_schema::config::parse_mem_config(&value).ok()
115 });
116 let archive_provenance =
121 backend
122 .read_archive_provenance()
123 .ok()
124 .flatten()
125 .and_then(|bytes| {
126 memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok()
127 });
128 mounted.push(MountedBackend {
129 mount,
130 backend,
131 last_known_head,
132 mem_config,
133 archive_provenance,
134 });
135 }
136
137 let builtin_schemas_only = memstead_schema::builtins::load_builtin_schemas()
147 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
148 let workspace_schemas = extra_schemas.clone();
151 let mut catalogue: Vec<Arc<memstead_schema::Schema>> =
152 Vec::with_capacity(extra_schemas.len() + builtin_schemas_only.len());
153 catalogue.extend(extra_schemas);
154 catalogue.extend(builtin_schemas_only.clone());
155 let builtin_schemas = catalogue;
156 let mut store = Store::new();
157 let mut load_errors: Vec<(PathBuf, String)> = Vec::new();
158 let mut schemas: HashMap<String, Arc<Schema>> = HashMap::with_capacity(mounted.len());
159 let fallback = engine_fallback_type();
160
161 let mem_names: Vec<String> = mounted.iter().map(|m| m.mount.mem.clone()).collect();
168 let known_suffixes: Vec<String> = mem_names
169 .iter()
170 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
171 .collect();
172 let mut load_warnings: Vec<WarningHint> = Vec::new();
173
174 let mut quarantined: Vec<crate::engine::QuarantinedMem> = Vec::new();
181 let mut quarantined_idx: std::collections::HashSet<usize> =
182 std::collections::HashSet::new();
183
184 for (m_idx, m) in mounted.iter().enumerate() {
185 let config_pin = m.mem_config.as_ref().and_then(|c| c.schema.as_ref());
194 let mount_pin = m.mount.schema.as_ref();
195 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
199 && cfg != mp
200 {
201 load_warnings.push(WarningHint::SchemaPinMismatch {
202 mem: m.mount.mem.clone(),
203 config_pin: cfg.as_display(),
204 mount_pin: mp.as_display(),
205 });
206 }
207 if let Some(stamp) = m
219 .mem_config
220 .as_ref()
221 .and_then(|c| c.mutation_stamp.as_ref())
222 && stamp.engine_version != crate::build_info::full_version()
223 {
224 load_warnings.push(WarningHint::EngineVersionSkew {
225 mem: m.mount.mem.clone(),
226 stamped_engine: stamp.engine_version.clone(),
227 running_engine: crate::build_info::full_version().to_string(),
228 stamped_schema: stamp.schema.clone(),
229 });
230 }
231 let settled_pin = config_pin.or(mount_pin);
234 let Some(effective_pin) = m.mount.migration_target.as_ref().or(settled_pin) else {
237 let e = EngineError::MemConfigIncomplete {
239 mem: m.mount.mem.clone(),
240 missing_fields: vec!["schema".to_string()],
241 };
242 quarantined.push(crate::engine::QuarantinedMem {
243 mount: m.mount.clone(),
244 reason_code: e.code().to_string(),
245 reason_message: e.to_string(),
246 });
247 quarantined_idx.insert(m_idx);
248 continue;
249 };
250 let schema = match SchemaResolver::new(&builtin_schemas).resolve(effective_pin) {
251 Ok(schema) => schema,
252 Err(sources) => {
253 let failed = failed_schema_packages.iter().find(|f| {
261 f.name.as_deref() == Some(effective_pin.name.as_str())
262 && f.version
263 .as_deref()
264 .is_none_or(|v| v == effective_pin.version.to_string())
265 });
266 let (reason_code, reason_message) = match failed {
267 Some(f) => (
268 "SCHEMA_LOAD_FAILED".to_string(),
269 format!(
270 "schema package at {} failed to load: {}",
271 f.path.display(),
272 f.error
273 ),
274 ),
275 None => {
276 let e = EngineError::SchemaNotFound {
277 mem: m.mount.mem.clone(),
278 pin: effective_pin.as_display(),
279 sources,
280 install_hint: None,
281 };
282 (e.code().to_string(), e.to_string())
283 }
284 };
285 quarantined.push(crate::engine::QuarantinedMem {
286 mount: m.mount.clone(),
287 reason_code,
288 reason_message,
289 });
290 quarantined_idx.insert(m_idx);
291 continue;
292 }
293 };
294 schemas.insert(m.mount.mem.clone(), schema.clone());
295
296 let locally_installed = workspace_schemas.iter().any(|s| {
306 s.manifest.name == effective_pin.name && s.version == effective_pin.version
307 });
308 let is_builtin = builtin_schemas_only.iter().any(|s| {
309 s.manifest.name == effective_pin.name && s.version == effective_pin.version
310 });
311 if !locally_installed
312 && is_builtin
313 && let Some(newest) =
314 newest_builtin_version(&effective_pin.name, &builtin_schemas_only)
315 && *newest > effective_pin.version
316 {
317 load_warnings.push(WarningHint::SchemaGenerationsBehind {
318 mem: m.mount.mem.clone(),
319 pinned: effective_pin.as_display(),
320 newest: newest.to_string(),
321 });
322 }
323
324 if let Err(memstead_schema::SchemaLoadError::SectionHeadingMismatch { violations }) =
329 memstead_schema::check_section_heading_roundtrip(&schema)
330 {
331 let (name, version) = schema.id();
332 load_warnings.push(WarningHint::SchemaHeadingRoundtripViolation {
333 mem: m.mount.mem.clone(),
334 schema_ref: format!("{name}@{version}"),
335 violations: violations.iter().map(Into::into).collect(),
336 });
337 }
338
339 let (entries, read_errors) = match collect_source_entries(m.backend.as_ref()) {
340 Ok(pair) => pair,
341 Err(e) => {
342 quarantined.push(crate::engine::QuarantinedMem {
345 mount: m.mount.clone(),
346 reason_code: e.code().to_string(),
347 reason_message: e.to_string(),
348 });
349 quarantined_idx.insert(m_idx);
350 schemas.remove(&m.mount.mem);
351 continue;
352 }
353 };
354 let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
355 push_entities_into_store(
361 &mut store,
362 load_result.entities,
363 fallback.as_ref(),
364 Some(crate::entity::store_builder::LoadCollector {
365 warnings: &mut load_warnings,
366 known_suffixes: &known_suffixes,
367 mem_names: &mem_names,
368 }),
369 );
370 load_errors.extend(load_result.errors);
371 }
372
373 if !quarantined_idx.is_empty() {
378 let mut keep_idx = 0usize;
379 mounted.retain(|_| {
380 let keep = !quarantined_idx.contains(&keep_idx);
381 keep_idx += 1;
382 keep
383 });
384 }
385
386 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
394 mounted
395 .iter()
396 .map(|m| (m.mount.mem.clone(), m.mount.capability))
397 .collect();
398 crate::entity::store_builder::validate_loaded_relations(
399 &mut store,
400 &schemas,
401 &mount_caps,
402 &mut load_warnings,
403 );
404
405 crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
410
411 load_warnings.retain(|w| match w {
420 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
421 store.get(resolved_id).is_none_or(|e| e.stub)
422 }
423 _ => true,
424 });
425
426 let mem_router = build_mem_router_from_mounts(&mounted);
432
433 Ok(Self {
434 mounts: mounted,
435 store,
436 schemas,
437 workspace_schemas,
438 builtin_schemas: builtin_schemas_only,
439 load_errors,
440 community_memo: OnceCell::new(),
441 #[cfg(not(target_arch = "wasm32"))]
442 search_indexes_memo: OnceCell::new(),
443 settings: WorkspaceSettings::default(),
444 create_rule_set_memo: OnceCell::new(),
445 declared_origins: HashMap::new(),
446 workspace_root: None,
447 load_warnings,
448 quarantined,
449 boot_diagnosis: None,
450 pipeline_configs: crate::pipeline_store::BindingConfigs::default(),
451 mem_router: Arc::new(mem_router),
452 backend_factory: crate::workspace_store::instantiate_lean_backend,
453 git_branch_ops: None,
454 event_subscribers: Arc::new(std::sync::Mutex::new(
455 crate::engine::events::SubscriberRegistry::new(),
456 )),
457 pending_mem_changed: Vec::new(),
458 mutation_clock: Arc::new(std::time::SystemTime::now),
459 current_role: crate::vcs::Role::Unspecified,
460 })
461 }
462
463 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
482 use crate::workspace_store::{
483 FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
484 instantiate_lean_backend,
485 };
486
487 let workspace = match detect_layout(workspace_root) {
488 Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
492 Some(ws) => ws,
493 None => {
494 return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
495 }
496 },
497 Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
498 };
499
500 let settings = workspace.settings.clone();
501 let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
502 Vec::with_capacity(workspace.mounts.len());
503 let mut instantiate_quarantine: Vec<crate::engine::QuarantinedMem> = Vec::new();
507 for mount in workspace.mounts {
508 match instantiate_lean_backend(&mount) {
509 Ok(backend) => mounts.push((mount, backend)),
510 Err(e) => instantiate_quarantine.push(crate::engine::QuarantinedMem {
511 reason_code: e.code().to_string(),
512 reason_message: e.to_string(),
513 mount,
514 }),
515 }
516 }
517 let fixed_dir = workspace_root.join(".memstead").join("schemas");
526 let (local, failed) = load_workspace_schemas_with_failures(Some(fixed_dir.as_path()));
527 let mut engine = Engine::from_mounts_inner(mounts, local, failed)
530 .map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
531 engine.quarantined.extend(instantiate_quarantine);
532 engine.set_settings(settings);
533 engine.workspace_root = Some(workspace_root.to_path_buf());
534 engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
545 workspace_root,
546 )?);
547 let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
552 Ok(engine)
553 }
554}
555
556pub(crate) fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
589 let mut router = MemRouterSnapshot::new();
590 for m in mounts {
591 match m.mount.capability {
592 MountCapability::Write => {
593 let dir: Option<PathBuf> = match &m.mount.storage {
594 MountStorage::Folder { path } => Some(path.clone()),
595 MountStorage::GitBranch { .. } => None,
596 MountStorage::Archive { .. } => None,
597 MountStorage::InMemory => None,
601 };
602 router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
603 }
604 MountCapability::ReadOnly => match &m.mount.storage {
605 MountStorage::Archive { path } => {
606 router.add_read_only(m.mount.mem.clone(), path.clone());
607 }
608 MountStorage::Folder { path } => {
609 router.add_read_only(m.mount.mem.clone(), path.clone());
610 }
611 MountStorage::GitBranch { gitdir, .. } => {
612 router.add_read_only(m.mount.mem.clone(), gitdir.clone());
613 }
614 MountStorage::InMemory => {}
620 },
621 }
622 }
623 router
624}
625
626pub fn resolve_builtin_schema_pin_pub(
631 pin: &memstead_schema::SchemaRef,
632 catalogue: &[Arc<memstead_schema::Schema>],
633) -> Option<Arc<memstead_schema::Schema>> {
634 resolve_builtin_schema_pin(pin, catalogue)
635}
636
637fn newest_builtin_version<'a>(
642 name: &str,
643 builtins: &'a [Arc<memstead_schema::Schema>],
644) -> Option<&'a semver::Version> {
645 builtins
646 .iter()
647 .filter(|s| s.manifest.name == name)
648 .map(|s| &s.version)
649 .max()
650}
651
652pub struct SchemaResolver<'a> {
667 catalogue: &'a [Arc<memstead_schema::Schema>],
668}
669
670impl<'a> SchemaResolver<'a> {
671 pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
674 Self { catalogue }
675 }
676
677 pub fn resolve(
681 &self,
682 pin: &memstead_schema::SchemaRef,
683 ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
684 {
685 resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
686 crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
687 &pin.name,
688 &pin.version,
689 self.catalogue,
690 )
691 })
692 }
693}
694
695pub fn load_workspace_schemas(
703 schemas_dir: Option<&Path>,
704) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
705 Ok(load_workspace_schemas_with_failures(schemas_dir).0)
706}
707
708#[derive(Debug, Clone)]
715pub struct FailedSchemaPackage {
716 pub path: PathBuf,
717 pub name: Option<String>,
718 pub version: Option<String>,
719 pub error: String,
721}
722
723pub fn load_workspace_schemas_with_failures(
729 schemas_dir: Option<&Path>,
730) -> (Vec<Arc<memstead_schema::Schema>>, Vec<FailedSchemaPackage>) {
731 let Some(dir) = schemas_dir else {
732 return (Vec::new(), Vec::new());
733 };
734 if !dir.is_dir() {
735 return (Vec::new(), Vec::new());
736 }
737 let entries = match std::fs::read_dir(dir) {
738 Ok(e) => e,
739 Err(_) => return (Vec::new(), Vec::new()),
740 };
741 let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
742 let mut failures: Vec<FailedSchemaPackage> = Vec::new();
743 for entry in entries.flatten() {
744 let path = entry.path();
745 if !path.is_dir() {
746 continue;
747 }
748 if !path.join("schema.yaml").is_file() {
749 continue;
750 }
751 match memstead_schema::load_schema_from_dir(&path) {
752 Ok(schema) => schemas.push(Arc::new(schema)),
753 Err(e) => {
754 let header = std::fs::read_to_string(path.join("schema.yaml")).unwrap_or_default();
758 let peek = |k: &str| {
759 header
760 .lines()
761 .find_map(|l| l.strip_prefix(&format!("{k}:")))
762 .map(|v| v.trim().trim_matches('"').to_string())
763 .filter(|v| !v.is_empty())
764 };
765 failures.push(FailedSchemaPackage {
766 path: path.clone(),
767 name: peek("name"),
768 version: peek("version"),
769 error: e.to_string(),
770 });
771 }
772 }
773 }
774 (schemas, failures)
775}
776
777pub(super) fn resolve_builtin_schema_pin(
778 pin: &memstead_schema::SchemaRef,
779 catalogue: &[Arc<memstead_schema::Schema>],
780) -> Option<Arc<memstead_schema::Schema>> {
781 catalogue
782 .iter()
783 .find(|s| {
784 let id = s.id();
785 id.0 == pin.name && id.1 == pin.version
786 })
787 .cloned()
788}
789
790pub(super) fn collect_source_entries(
791 backend: &dyn MemBackend,
792) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
793 let paths = backend.list_entities()?;
794 let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
795 let mut errors: Vec<SourceReadError> = Vec::new();
796 for path in paths {
797 match backend.read_entity(&path) {
798 Ok(Some(bytes)) => match String::from_utf8(bytes) {
799 Ok(content) => entries.push(SourceEntry {
800 relative_path: path.to_string_lossy().into_owned(),
801 source_path: path.clone(),
802 content,
803 }),
804 Err(e) => errors.push(SourceReadError {
805 source_path: path,
806 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
807 }),
808 },
809 Ok(None) => {
810 }
812 Err(e) => errors.push(SourceReadError {
813 source_path: path,
814 error: std::io::Error::other(e.to_string()),
815 }),
816 }
817 }
818 Ok((entries, errors))
819}
820
821#[cfg(test)]
822mod tests {
823
824 use std::path::Path;
825
826 use memstead_schema::SchemaRef;
827 use tempfile::TempDir;
828
829 use crate::backend::MemBackend;
830 use crate::engine::test_helpers::*;
831 use crate::engine::{Engine, EngineError};
832 use crate::ops::WarningHint;
833 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
834 use crate::vcs::CommitContext;
835 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
836
837 #[test]
841 fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
842 let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
843 let resolver = super::SchemaResolver::new(&catalogue);
844
845 let ok: SchemaRef = "default@1.0.0".parse().unwrap();
846 assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
847
848 let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
849 let sources = resolver.resolve(&miss).unwrap_err();
850 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
851 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
852 assert!(sources.iter().all(|s| !s.pinned_version_match));
853 }
854
855 #[test]
856 fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
857 let engine = Engine::from_mounts(Vec::new()).unwrap();
858 assert!(engine.mem_names().is_empty());
859 match engine.list_entities("missing") {
860 Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
861 other => panic!("expected UnknownMem, got {other:?}"),
862 }
863 }
864
865 #[test]
866 fn duplicate_mem_names_rejected_at_construction() {
867 let tmp = TempDir::new().unwrap();
868 let writer1: Box<dyn MemBackend> =
869 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
870 let writer2: Box<dyn MemBackend> =
871 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
872 let err = Engine::from_mounts(vec![
873 (folder_mount("specs", tmp.path().to_path_buf()), writer1),
874 (folder_mount("specs", tmp.path().to_path_buf()), writer2),
875 ])
876 .unwrap_err();
877 assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
878 }
879
880 #[test]
881 fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
882 let tmp = TempDir::new().unwrap();
888 let mem_dir = tmp.path().to_path_buf();
889 let body =
890 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
891 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
892
893 let writer = FilesystemMemWriter::new(mem_dir.clone());
894 let engine = Engine::from_mounts(vec![(
895 folder_mount("specs", mem_dir),
896 Box::new(writer) as Box<dyn MemBackend>,
897 )])
898 .unwrap();
899
900 let warnings = engine.load_warnings();
901 assert!(
902 warnings
903 .iter()
904 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
905 "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
906 );
907 }
908
909 #[test]
917 fn generation_behind_hint_fires_for_old_builtin_pin_only() {
918 let tmp = TempDir::new().unwrap();
920 let mem_dir = tmp.path().to_path_buf();
921 let writer = FilesystemMemWriter::new(mem_dir.clone());
922 let mut engine = Engine::from_mounts(vec![(
923 folder_mount("specs", mem_dir),
924 Box::new(writer) as Box<dyn MemBackend>,
925 )])
926 .unwrap();
927 let behind: Vec<_> = engine
928 .load_warnings()
929 .iter()
930 .filter_map(|w| match w {
931 WarningHint::SchemaGenerationsBehind {
932 mem,
933 pinned,
934 newest,
935 } => Some((mem.clone(), pinned.clone(), newest.clone())),
936 _ => None,
937 })
938 .collect();
939 assert_eq!(
940 behind,
941 vec![(
942 "specs".to_string(),
943 "default@1.0.0".to_string(),
944 "1.3.0".to_string()
945 )],
946 "old built-in pin must surface the generation-behind hint"
947 );
948 assert!(
949 engine
950 .health()
951 .warnings
952 .iter()
953 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
954 "the hint rides health without an include gate"
955 );
956 engine
958 .create_entity_with_ctx(
959 crate::engine::CreateEntityArgs {
960 anchors: Vec::new(),
961 mem: "specs".to_string(),
962 title: "Still writable".to_string(),
963 entity_type: "spec".to_string(),
964 sections: indexmap::IndexMap::from_iter([
965 ("identity".to_string(), "i".to_string()),
966 ("purpose".to_string(), "p".to_string()),
967 ]),
968 metadata: indexmap::IndexMap::new(),
969 relations: Vec::new(),
970 dry_run: false,
971 },
972 &crate::vcs::CommitContext::internal(),
973 )
974 .expect("generation-behind hint must never block mutations");
975
976 let tmp = TempDir::new().unwrap();
978 let mem_dir = tmp.path().to_path_buf();
979 let writer = FilesystemMemWriter::new(mem_dir.clone());
980 let mut mount = folder_mount("specs", mem_dir);
981 mount.schema = Some("default@1.3.0".parse().unwrap());
982 let engine =
983 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
984 assert!(
985 !engine
986 .load_warnings()
987 .iter()
988 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
989 "newest built-in pin must stay silent: {:?}",
990 engine.load_warnings()
991 );
992 let health_json = serde_json::to_string(&engine.health().warnings).unwrap();
993 assert!(
994 !health_json.contains("SCHEMA_GENERATIONS_BEHIND"),
995 "newest pin: health output carries no generation hint"
996 );
997 }
998
999 #[test]
1003 fn newest_builtin_version_orders_by_semver_not_string() {
1004 let manifest = |version: &str| {
1005 format!(
1006 r#"name: gen-test
1007version: {version}
1008description: test
1009when_to_use: test
1010types:
1011 - note
1012relationships:
1013 mode: strict
1014 definitions:
1015 - name: _default
1016 description: default
1017 default_weight: 1.0
1018 - name: PART_OF
1019 description: hier
1020 default_weight: 3.0
1021community:
1022 resolution: 1.0
1023 seed: 42
1024"#
1025 )
1026 };
1027 let type_yaml = r#"name: note
1028description: test
1029when_to_use: test
1030sections:
1031 - key: body
1032 heading: Body
1033 required: true
1034 search_weight: 10.0
1035 catch_all: true
1036metadata_fields: []
1037title_weight: 1.0
1038text_fields: [body]
1039hierarchy_relationship: PART_OF
1040no_self_loop_relationships: []
1041updatable_fields: [title, body]
1042health_required_fields: [body]
1043staleness_threshold_days: 30
1044write_rules: []
1045"#;
1046 let types = vec![("note".to_string(), type_yaml.to_string())];
1047 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = ["0.9.0", "0.10.0", "0.2.0"]
1048 .iter()
1049 .map(|v| {
1050 std::sync::Arc::new(
1051 memstead_schema::load_schema_from_memory(&manifest(v), &types)
1052 .expect("fixture schema loads"),
1053 )
1054 })
1055 .collect();
1056 let newest = super::newest_builtin_version("gen-test", &catalogue)
1057 .expect("name present in catalogue");
1058 assert_eq!(newest.to_string(), "0.10.0", "semver, not string, ordering");
1059 assert!(super::newest_builtin_version("absent", &catalogue).is_none());
1060 }
1061
1062 #[test]
1068 fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
1069 let tmp = TempDir::new().unwrap();
1070 let mem_dir = tmp.path().to_path_buf();
1071 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1074 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1075 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1076 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1077
1078 let writer = FilesystemMemWriter::new(mem_dir.clone());
1079 let engine = Engine::from_mounts(vec![(
1080 folder_mount("specs", mem_dir),
1081 Box::new(writer) as Box<dyn MemBackend>,
1082 )])
1083 .unwrap();
1084
1085 let source_id = crate::entity::EntityId::new("specs", "source");
1086 let target_id = crate::entity::EntityId::new("specs", "target");
1087 let source_entity = engine.get_entity(&source_id).expect("source loaded");
1088 assert!(
1091 source_entity.relationships.is_empty(),
1092 "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
1093 source_entity.relationships,
1094 );
1095 let outgoing: Vec<_> = engine
1097 .store()
1098 .outgoing(&source_id)
1099 .iter()
1100 .filter(|e| e.rel_type == "MADE_UP_TYPE")
1101 .collect();
1102 assert!(
1103 outgoing.is_empty(),
1104 "MADE_UP_TYPE edge must be dropped from the store"
1105 );
1106 let parsed_invalid: Vec<_> = engine
1108 .load_warnings()
1109 .iter()
1110 .filter_map(|w| match w {
1111 WarningHint::ParsedRelationInvalid {
1112 entity_id,
1113 rel_type,
1114 target,
1115 reason,
1116 origin,
1117 recovery,
1118 } => Some((
1119 entity_id.clone(),
1120 rel_type.clone(),
1121 target.clone(),
1122 reason.clone(),
1123 origin.clone(),
1124 recovery.clone(),
1125 )),
1126 _ => None,
1127 })
1128 .collect();
1129 assert_eq!(
1130 parsed_invalid.len(),
1131 1,
1132 "expected one warning, got {parsed_invalid:?}"
1133 );
1134 assert_eq!(parsed_invalid[0].0, source_id);
1135 assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
1136 assert_eq!(parsed_invalid[0].2, target_id);
1137 assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
1138 assert_eq!(parsed_invalid[0].4, "writable");
1139 let recovery = parsed_invalid[0]
1141 .5
1142 .as_ref()
1143 .expect("writable-origin warning must carry recovery");
1144 assert_eq!(
1145 recovery.kind,
1146 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1147 );
1148 assert_eq!(recovery.source_id, parsed_invalid[0].0);
1149 assert_eq!(recovery.target_id, parsed_invalid[0].2);
1150 assert_eq!(recovery.rel_type, parsed_invalid[0].1);
1151 }
1152
1153 #[test]
1161 fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
1162 let tmp = TempDir::new().unwrap();
1163 let mem_dir = tmp.path().to_path_buf();
1164 let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
1168 let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
1169 std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
1170 std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
1171
1172 let writer = FilesystemMemWriter::new(mem_dir.clone());
1173 let engine = Engine::from_mounts(vec![(
1174 folder_mount("specs", mem_dir),
1175 Box::new(writer) as Box<dyn MemBackend>,
1176 )])
1177 .unwrap();
1178
1179 let alpha_id = crate::entity::EntityId::new("specs", "alpha");
1180 let beta_id = crate::entity::EntityId::new("specs", "beta");
1181
1182 assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
1185 assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
1186
1187 let surviving: Vec<_> = engine
1190 .store()
1191 .all_entities()
1192 .flat_map(|e| {
1193 engine
1194 .store()
1195 .outgoing(&e.id)
1196 .iter()
1197 .filter(|edge| edge.rel_type == "PART_OF")
1198 .map(|edge| (e.id.clone(), edge.target.clone()))
1199 .collect::<Vec<_>>()
1200 })
1201 .collect();
1202 assert_eq!(
1203 surviving.len(),
1204 1,
1205 "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
1206 );
1207
1208 let cycle_drops: Vec<_> = engine
1211 .load_warnings()
1212 .iter()
1213 .filter_map(|w| match w {
1214 WarningHint::ParsedRelationInvalid {
1215 entity_id,
1216 rel_type,
1217 target,
1218 reason,
1219 ..
1220 } if reason == "cycle" => {
1221 Some((entity_id.clone(), rel_type.clone(), target.clone()))
1222 }
1223 _ => None,
1224 })
1225 .collect();
1226 assert_eq!(
1227 cycle_drops.len(),
1228 1,
1229 "exactly one cycle warning must fire, got {cycle_drops:?}",
1230 );
1231 let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
1233 assert_eq!(dropped_rel_type, "PART_OF");
1234 let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
1235 let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
1236 assert!(
1237 is_alpha_to_beta || is_beta_to_alpha,
1238 "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
1239 );
1240 assert_ne!(
1242 (&surviving[0].0, &surviving[0].1),
1243 (dropped_from, dropped_to),
1244 "surviving edge must differ from the dropped one",
1245 );
1246 }
1247
1248 #[test]
1256 fn nested_prefix_warning_exempts_real_cross_mem_target_loaded_later() {
1257 let tmp = TempDir::new().unwrap();
1258 let project_dir = tmp.path().join("project");
1259 let registry_dir = tmp.path().join("registry");
1260 std::fs::create_dir_all(&project_dir).unwrap();
1261 std::fs::create_dir_all(®istry_dir).unwrap();
1262
1263 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nReal: [[registry--registry-service]]. Drifted: [[registry--never-created]].\n";
1266 std::fs::write(project_dir.join("source.md"), source).unwrap();
1267
1268 let service = "---\ntype: spec\n---\n# Registry Service\n\n## Identity\n\nA real entity.\n";
1271 std::fs::write(registry_dir.join("registry-service.md"), service).unwrap();
1272
1273 let engine = Engine::from_mounts(vec![
1274 (
1275 folder_mount("project", project_dir.clone()),
1276 Box::new(FilesystemMemWriter::new(project_dir)) as Box<dyn MemBackend>,
1277 ),
1278 (
1279 folder_mount("registry", registry_dir.clone()),
1280 Box::new(FilesystemMemWriter::new(registry_dir)) as Box<dyn MemBackend>,
1281 ),
1282 ])
1283 .unwrap();
1284
1285 let nested: Vec<_> = engine
1286 .load_warnings()
1287 .iter()
1288 .filter_map(|w| match w {
1289 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1290 Some(resolved_id.to_string())
1291 }
1292 _ => None,
1293 })
1294 .collect();
1295 assert!(
1296 !nested.contains(&"registry--registry-service".to_string()),
1297 "a valid cross-mem id resolving to a real entity must not warn, got {nested:?}",
1298 );
1299 assert!(
1300 nested.contains(&"registry--never-created".to_string()),
1301 "a genuinely unresolved nested-prefix link must keep its warning, got {nested:?}",
1302 );
1303 }
1304
1305 #[test]
1315 fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
1316 use crate::engine::test_helpers::write_schema_files_with_default_type;
1317
1318 let tmp = TempDir::new().unwrap();
1319 let schemas_dir = tmp.path().join("schemas");
1320 std::fs::create_dir_all(&schemas_dir).unwrap();
1321 let manifest = r#"name: shape-test
1325version: 0.1.0
1326description: shape-constraint schema
1327when_to_use: tests
1328types:
1329 - doc
1330 - actor
1331relationships:
1332 mode: strict
1333 definitions:
1334 - name: OWNS
1335 description: actor owns doc
1336 default_weight: 1.0
1337 source_types: [actor]
1338 target_types: [doc]
1339 - name: _default
1340 description: fallback
1341 default_weight: 1.0
1342community:
1343 resolution: 1.0
1344 seed: 42
1345"#;
1346 write_schema_files_with_default_type(
1347 &schemas_dir,
1348 "shape-test",
1349 manifest,
1350 &["doc", "actor"],
1351 );
1352
1353 let mem_dir = tmp.path().join("mem");
1354 std::fs::create_dir_all(&mem_dir).unwrap();
1355 let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
1359 let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
1360 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1361 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1362
1363 let writer = FilesystemMemWriter::new(mem_dir.clone());
1364 let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
1365 let mount = Mount {
1366 mem: "specs".to_string(),
1367 schema: Some(pin),
1368 storage: MountStorage::Folder { path: mem_dir },
1369 capability: MountCapability::Write,
1370 lifecycle: MountLifecycle::Eager,
1371 cross_linkable: true,
1372 migration_target: None,
1373 };
1374 let engine = Engine::from_mounts_with_schemas_dir(
1375 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1376 Some(&schemas_dir),
1377 )
1378 .unwrap();
1379
1380 let source_id = crate::entity::EntityId::new("specs", "source");
1381 let target_id = crate::entity::EntityId::new("specs", "target");
1382
1383 let shape_drops: Vec<_> = engine
1384 .load_warnings()
1385 .iter()
1386 .filter_map(|w| match w {
1387 WarningHint::ParsedRelationInvalid {
1388 entity_id,
1389 rel_type,
1390 target,
1391 reason,
1392 origin,
1393 recovery,
1394 } if reason == "shape" => Some((
1395 entity_id.clone(),
1396 rel_type.clone(),
1397 target.clone(),
1398 origin.clone(),
1399 recovery.clone(),
1400 )),
1401 _ => None,
1402 })
1403 .collect();
1404 assert_eq!(
1405 shape_drops.len(),
1406 1,
1407 "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
1408 engine.load_warnings(),
1409 );
1410 let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
1411 shape_drops.into_iter().next().unwrap();
1412 assert_eq!(drop_from, source_id);
1413 assert_eq!(drop_type, "OWNS");
1414 assert_eq!(drop_to, target_id);
1415 assert_eq!(drop_origin, "writable");
1416 let recovery = drop_recovery.expect("writable origin must carry recovery");
1419 assert_eq!(
1420 recovery.kind,
1421 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1422 );
1423 assert_eq!(recovery.source_id, source_id);
1424 assert_eq!(recovery.target_id, target_id);
1425 assert_eq!(recovery.rel_type, "OWNS");
1426 }
1427
1428 #[test]
1434 fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
1435 let tmp = TempDir::new().unwrap();
1436 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1440 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1441 let archive_path = build_archive(
1442 tmp.path(),
1443 "ext",
1444 &[
1445 ("target.md", target.as_bytes()),
1446 ("source.md", source.as_bytes()),
1447 ],
1448 );
1449
1450 let engine = Engine::from_mounts(vec![(
1451 archive_mount("external", archive_path.clone()),
1452 Box::new(ArchiveBackend::new(archive_path)),
1453 )])
1454 .unwrap();
1455
1456 let invalid: Vec<_> = engine
1457 .load_warnings()
1458 .iter()
1459 .filter_map(|w| match w {
1460 WarningHint::ParsedRelationInvalid {
1461 rel_type,
1462 reason,
1463 origin,
1464 recovery,
1465 ..
1466 } => Some((
1467 rel_type.clone(),
1468 reason.clone(),
1469 origin.clone(),
1470 recovery.clone(),
1471 )),
1472 _ => None,
1473 })
1474 .collect();
1475 assert_eq!(
1476 invalid.len(),
1477 1,
1478 "expected one parse-time drop on the readonly mount, got {invalid:?}",
1479 );
1480 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1481 assert_eq!(invalid[0].1, "unknown_rel_type");
1482 assert_eq!(invalid[0].2, "readonly");
1483 assert!(
1484 invalid[0].3.is_none(),
1485 "readonly-origin warning must omit the recovery hint, got {:?}",
1486 invalid[0].3,
1487 );
1488 }
1489
1490 #[test]
1491 fn load_on_init_populates_store_from_folder_mount() {
1492 let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1495
1496 let tmp = TempDir::new().unwrap();
1497 let mem_dir = tmp.path().to_path_buf();
1498 let writer = FilesystemMemWriter::new(mem_dir.clone());
1499 <FilesystemMemWriter as MemWriter>::write_entity(
1500 &writer,
1501 Path::new("hello.md"),
1502 body.as_bytes(),
1503 )
1504 .unwrap();
1505 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1506 .unwrap();
1507
1508 let engine = Engine::from_mounts(vec![(
1509 folder_mount("specs", mem_dir),
1510 Box::new(writer) as Box<dyn MemBackend>,
1511 )])
1512 .unwrap();
1513
1514 assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1516 let id = crate::EntityId::new("specs", "hello");
1517 let entity = engine.get_entity(&id).expect("entity must be present");
1518 assert_eq!(entity.title, "Hello");
1519 assert_eq!(entity.entity_type, "spec");
1520 assert!(engine.load_errors().is_empty());
1521 assert_eq!(engine.schemas().len(), 1);
1523 assert!(engine.schemas().contains_key("specs"));
1524 }
1525
1526 #[test]
1527 fn load_on_init_populates_store_from_archive_mount() {
1528 let body =
1529 "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1530
1531 let tmp = TempDir::new().unwrap();
1532 let archive_path =
1533 build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1534
1535 let engine = Engine::from_mounts(vec![(
1536 archive_mount("external", archive_path.clone()),
1537 Box::new(ArchiveBackend::new(archive_path)),
1538 )])
1539 .unwrap();
1540
1541 let id = crate::EntityId::new("external", "from-archive");
1542 let entity = engine.get_entity(&id).expect("entity must be present");
1543 assert_eq!(entity.title, "From Archive");
1544 assert!(engine.load_errors().is_empty());
1545 }
1546
1547 #[test]
1548 fn load_on_init_populates_store_from_heterogeneous_mounts() {
1549 let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1550 let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1551
1552 let tmp = TempDir::new().unwrap();
1553
1554 let folder_dir = tmp.path().join("folder-mem");
1555 std::fs::create_dir_all(&folder_dir).unwrap();
1556 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1557 <FilesystemMemWriter as MemWriter>::write_entity(
1558 &folder_writer,
1559 Path::new("local.md"),
1560 folder_body.as_bytes(),
1561 )
1562 .unwrap();
1563 <FilesystemMemWriter as MemWriter>::commit(
1564 &folder_writer,
1565 "seed",
1566 &CommitContext::internal(),
1567 )
1568 .unwrap();
1569
1570 let archive_path = build_archive(
1571 tmp.path(),
1572 "external",
1573 &[("external.md", archive_body.as_bytes())],
1574 );
1575
1576 let engine = Engine::from_mounts(vec![
1577 (
1578 folder_mount("local", folder_dir),
1579 Box::new(folder_writer) as Box<dyn MemBackend>,
1580 ),
1581 (
1582 archive_mount("external", archive_path.clone()),
1583 Box::new(ArchiveBackend::new(archive_path)),
1584 ),
1585 ])
1586 .unwrap();
1587
1588 assert_eq!(engine.store().len(), 2);
1590 assert!(
1591 engine
1592 .get_entity(&crate::EntityId::new("local", "local"))
1593 .is_some()
1594 );
1595 assert!(
1596 engine
1597 .get_entity(&crate::EntityId::new("external", "external"))
1598 .is_some()
1599 );
1600 }
1601
1602 #[test]
1603 fn load_on_init_collects_per_file_parse_errors_without_failing() {
1604 let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1608 let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1609
1610 let tmp = TempDir::new().unwrap();
1611 let mem_dir = tmp.path().to_path_buf();
1612 let writer = FilesystemMemWriter::new(mem_dir.clone());
1613 <FilesystemMemWriter as MemWriter>::write_entity(
1614 &writer,
1615 Path::new("good.md"),
1616 good.as_bytes(),
1617 )
1618 .unwrap();
1619 <FilesystemMemWriter as MemWriter>::write_entity(
1620 &writer,
1621 Path::new("bad.md"),
1622 bad.as_bytes(),
1623 )
1624 .unwrap();
1625 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1626 .unwrap();
1627
1628 let engine = Engine::from_mounts(vec![(
1629 folder_mount("specs", mem_dir),
1630 Box::new(writer) as Box<dyn MemBackend>,
1631 )])
1632 .unwrap();
1633
1634 assert!(
1636 engine
1637 .get_entity(&crate::EntityId::new("specs", "good"))
1638 .is_some(),
1639 "good.md must parse and reach the store"
1640 );
1641 let bad_known_to_engine = engine
1646 .get_entity(&crate::EntityId::new("specs", "bad"))
1647 .is_some()
1648 || !engine.load_errors().is_empty();
1649 assert!(
1650 bad_known_to_engine,
1651 "bad.md must either parse or surface in load_errors"
1652 );
1653 }
1654
1655 #[test]
1656 fn empty_mount_list_yields_empty_store() {
1657 let engine = Engine::from_mounts(Vec::new()).unwrap();
1658 assert!(engine.store().is_empty());
1659 assert!(engine.schemas().is_empty());
1660 assert!(engine.load_errors().is_empty());
1661 }
1662
1663 #[test]
1666 fn from_workspace_root_errors_for_empty_layout() {
1667 let tmp = TempDir::new().unwrap();
1668 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1669 match err {
1670 crate::BootError::NotInitialised(p) => {
1671 assert_eq!(p, tmp.path());
1672 }
1673 other => panic!("expected NotInitialised, got {other:?}"),
1674 }
1675 }
1676
1677 #[test]
1678 fn from_workspace_root_loads_new_two_layer_layout() {
1679 let tmp = TempDir::new().unwrap();
1680 let mem_dir = tmp.path().join("mem");
1681 std::fs::create_dir_all(&mem_dir).unwrap();
1682 std::fs::write(
1683 mem_dir.join("hello.md"),
1684 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1685 )
1686 .unwrap();
1687
1688 let memstead = tmp.path().join(".memstead");
1689 std::fs::create_dir_all(&memstead).unwrap();
1690 std::fs::write(
1691 memstead.join("workspace.toml"),
1692 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1693 )
1694 .unwrap();
1695 use crate::workspace_store::WorkspaceStoreAdapter;
1698 let store = crate::FileWorkspaceStore::new();
1699 store
1700 .save_state(
1701 tmp.path(),
1702 &crate::workspace::Workspace {
1703 mounts: vec![folder_mount("specs", mem_dir)],
1704 settings: crate::workspace::WorkspaceSettings::default(),
1705 },
1706 )
1707 .unwrap();
1708
1709 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1710 assert_eq!(engine.mem_names(), vec!["specs"]);
1711 let entity = engine
1712 .get_entity(&crate::EntityId::new("specs", "hello"))
1713 .expect("seeded entity must load through from_workspace_root");
1714 assert_eq!(entity.title, "Hello");
1715 }
1716
1717 #[test]
1722 fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1723 use crate::pipeline::{MediumType, Projection};
1724 let tmp = TempDir::new().unwrap();
1725 let mem_dir = tmp.path().join("mem");
1726 std::fs::create_dir_all(&mem_dir).unwrap();
1727
1728 let memstead = tmp.path().join(".memstead");
1729 std::fs::create_dir_all(&memstead).unwrap();
1730 std::fs::write(
1731 memstead.join("workspace.toml"),
1732 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1733 )
1734 .unwrap();
1735 use crate::workspace_store::WorkspaceStoreAdapter;
1736 crate::FileWorkspaceStore::new()
1737 .save_state(
1738 tmp.path(),
1739 &crate::workspace::Workspace {
1740 mounts: vec![folder_mount("specs", mem_dir)],
1741 settings: crate::workspace::WorkspaceSettings::default(),
1742 },
1743 )
1744 .unwrap();
1745
1746 crate::pipeline_store::write_binding(
1748 tmp.path(),
1749 "specs",
1750 "graph",
1751 &sample_v2_binding("specs"),
1752 )
1753 .unwrap();
1754
1755 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1756 let pc = engine.pipeline_configs();
1757 assert_eq!(pc.bindings.len(), 1, "one binding enumerated");
1758 assert_eq!(pc.bindings[0].mem, "specs");
1759 assert_eq!(pc.bindings[0].name, "graph");
1760 assert_eq!(pc.bindings[0].config.destination_mem, "specs");
1761 assert_eq!(
1762 pc.bindings[0].config.sources[0].medium_type,
1763 MediumType::Codebase
1764 );
1765
1766 crate::pipeline_store::write_projection(
1773 tmp.path(),
1774 "specs",
1775 "legacy",
1776 &Projection {
1777 intent: None,
1778 source_facets: vec!["view".to_string()],
1779 reference_mems: Vec::new(),
1780 destination_mem: "specs".to_string(),
1781 rules: None,
1782 },
1783 )
1784 .unwrap();
1785 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1786 let pc = engine.pipeline_configs();
1787 assert_eq!(pc.bindings.len(), 1, "the healthy binding still serves");
1788 assert_eq!(pc.quarantined.len(), 1, "the legacy one quarantines");
1789 assert_eq!(pc.quarantined[0].name, "legacy");
1790 assert_eq!(pc.quarantined[0].reason_code, "PROJECTION_STORE_LEGACY");
1791 assert!(
1792 pc.quarantined[0]
1793 .reason_message
1794 .contains("memstead projection migrate"),
1795 "quarantine reason names the migrate command, got: {}",
1796 pc.quarantined[0].reason_message
1797 );
1798 }
1799
1800 fn sample_v2_binding(dest: &str) -> crate::binding::Binding {
1803 v2_binding_with_pointer(dest, "..")
1804 }
1805
1806 fn v2_binding_with_pointer(dest: &str, pointer: &str) -> crate::binding::Binding {
1807 use crate::binding::{BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations};
1808 use crate::pipeline::{IngestTrigger, MediumType, Source};
1809 Binding {
1810 version: BINDING_VERSION,
1811 intent: None,
1812 sources: vec![Source {
1813 name: "src".to_string(),
1814 medium_type: MediumType::Codebase,
1815 pointer: pointer.to_string(),
1816 change_detection: None,
1817 scope: Vec::new(),
1818 engagement: None,
1819 preparation: None,
1820 }],
1821 reference_mems: Vec::new(),
1822 destination_mem: dest.to_string(),
1823 deny_paths: Vec::new(),
1824 coverage_semantics: None,
1825 rules: None,
1826 prune: None,
1827 operations: Operations {
1828 build: Some(BuildOperation {
1829 mode: BuildMode::Discovery,
1830 trigger: IngestTrigger::Loop,
1831 batch_size: 10,
1832 post_actions: None,
1833 }),
1834 sync: None,
1835 verify: None,
1836 },
1837 }
1838 }
1839
1840 #[test]
1848 fn entity_anchors_resolve_live_state_for_path_medium() {
1849 use crate::anchor::{AnchorInput, AnchorState};
1850 use crate::vcs::Actor;
1851 use crate::workspace_store::WorkspaceStoreAdapter;
1852 use indexmap::IndexMap;
1853
1854 let tmp = TempDir::new().unwrap();
1855 let mem_dir = tmp.path().join("mem");
1856 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1857 std::fs::write(
1858 mem_dir.join(".memstead").join("config.json"),
1859 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1860 )
1861 .unwrap();
1862
1863 let memstead = tmp.path().join(".memstead");
1864 std::fs::create_dir_all(&memstead).unwrap();
1865 std::fs::write(
1866 memstead.join("workspace.toml"),
1867 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1868 )
1869 .unwrap();
1870 crate::FileWorkspaceStore::new()
1871 .save_state(
1872 tmp.path(),
1873 &crate::workspace::Workspace {
1874 mounts: vec![folder_mount("specs", mem_dir.clone())],
1875 settings: crate::workspace::WorkspaceSettings::default(),
1876 },
1877 )
1878 .unwrap();
1879
1880 crate::pipeline_store::write_binding(
1886 tmp.path(),
1887 "specs",
1888 "graph",
1889 &v2_binding_with_pointer("specs", "src"),
1890 )
1891 .unwrap();
1892 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1893 std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
1894
1895 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
1896
1897 let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
1898 artifact: Some(artifact.to_string()),
1899 grain: Some("file".to_string()),
1900 class: Some(class.to_string()),
1901 hash: hash.map(str::to_string),
1902 hash_stability: Some("stable".to_string()),
1903 ..Default::default()
1904 };
1905 let mut sections = IndexMap::new();
1906 sections.insert("identity".to_string(), "Covers src.".to_string());
1907 sections.insert("purpose".to_string(), "Track sources.".to_string());
1908 let present_hash = crate::anchor::prepared_content_hash(
1911 &std::fs::read(tmp.path().join("src").join("present.rs")).unwrap(),
1912 );
1913 let created = engine
1914 .create_entity(
1915 crate::CreateEntityArgs {
1916 mem: "specs".to_string(),
1917 title: "Covers".to_string(),
1918 entity_type: "spec".to_string(),
1919 sections,
1920 metadata: IndexMap::new(),
1921 relations: Vec::new(),
1922 anchors: vec![
1923 anchor("src/present.rs", "anchored", Some(&present_hash)), anchor("src/present.rs", "informed-by", None), anchor("src/gone.rs", "anchored", Some("h2")), anchor("src/present.rs", "derived", Some("stale")), ],
1928 dry_run: false,
1929 },
1930 Actor::Agent,
1931 None,
1932 None,
1933 )
1934 .unwrap();
1935
1936 let resolved = engine.entity_anchors_resolved(&created.id);
1937 assert_eq!(resolved.len(), 4);
1938 let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
1939 resolved
1940 .iter()
1941 .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
1942 .and_then(|r| r.state)
1943 };
1944 assert_eq!(
1945 state_of(
1946 "src/present.rs",
1947 crate::anchor::AnchorProvenanceClass::Anchored
1948 ),
1949 Some(AnchorState::Resolves),
1950 "recorded hash matches the observed prepared form → resolves"
1951 );
1952 assert_eq!(
1953 state_of(
1954 "src/present.rs",
1955 crate::anchor::AnchorProvenanceClass::Derived
1956 ),
1957 Some(AnchorState::Drifted),
1958 "recorded hash mismatches the observed prepared form on a stable medium → drifted"
1959 );
1960 assert_eq!(
1961 state_of(
1962 "src/present.rs",
1963 crate::anchor::AnchorProvenanceClass::InformedBy
1964 ),
1965 Some(AnchorState::Resolves),
1966 "present non-hash anchor resolves on existence"
1967 );
1968 assert_eq!(
1969 state_of(
1970 "src/gone.rs",
1971 crate::anchor::AnchorProvenanceClass::Anchored
1972 ),
1973 Some(AnchorState::Orphaned),
1974 "absent artifact is orphaned"
1975 );
1976 }
1977
1978 #[test]
1983 fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
1984 use crate::workspace_store::WorkspaceStoreAdapter;
1985
1986 let tmp = TempDir::new().unwrap();
1987 let mem_dir = tmp.path().join("mem");
1988 std::fs::create_dir_all(&mem_dir).unwrap();
1989 let memstead = tmp.path().join(".memstead");
1990 std::fs::create_dir_all(&memstead).unwrap();
1991 std::fs::write(
1992 memstead.join("workspace.toml"),
1993 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1994 )
1995 .unwrap();
1996 crate::FileWorkspaceStore::new()
1997 .save_state(
1998 tmp.path(),
1999 &crate::workspace::Workspace {
2000 mounts: vec![folder_mount("specs", mem_dir)],
2001 settings: crate::workspace::WorkspaceSettings::default(),
2002 },
2003 )
2004 .unwrap();
2005
2006 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2007 assert!(engine.pipeline_configs().bindings.is_empty());
2008
2009 engine
2011 .add_projection_json(
2012 "specs",
2013 "graph",
2014 r#"{
2015 "sources": [{ "name": "src", "type": "codebase", "pointer": "..",
2016 "scope": [{ "path": "**/*.rs", "mode": "allow" }] }],
2017 "destination_mem": "specs"
2018 }"#,
2019 None,
2020 )
2021 .unwrap();
2022 assert_eq!(engine.pipeline_configs().bindings.len(), 1);
2024 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph");
2025 assert_eq!(
2026 engine.pipeline_configs().bindings[0].config.sources[0].name,
2027 "src"
2028 );
2029
2030 let err = engine
2032 .add_projection_json("specs", "bad", "{ not json", None)
2033 .unwrap_err();
2034 assert!(
2035 matches!(
2036 err,
2037 crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
2038 ),
2039 "got {err:?}"
2040 );
2041
2042 engine
2044 .update_projection_json("specs", "graph", r#"{"intent":"i2"}"#, None)
2045 .unwrap();
2046 assert_eq!(
2047 engine.pipeline_configs().bindings[0]
2048 .config
2049 .intent
2050 .as_deref(),
2051 Some("i2")
2052 );
2053
2054 engine
2056 .rename_projection("specs", "graph", "graph2", None)
2057 .unwrap();
2058 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph2");
2059
2060 let json = engine.pipeline_configs_json();
2063 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2064 assert!(parsed.get("mediums").is_none(), "no mediums key: {json}");
2065 assert!(parsed.get("facets").is_none(), "no facets key: {json}");
2066 let bindings = parsed["bindings"].as_array().unwrap();
2067 assert_eq!(bindings.len(), 1);
2068 assert_eq!(bindings[0]["name"], "graph2");
2069 assert_eq!(bindings[0]["config"]["sources"][0]["type"], "codebase");
2070
2071 engine.delete_projection("specs", "graph2", None).unwrap();
2072 assert!(engine.pipeline_configs().bindings.is_empty());
2073 }
2074
2075 #[test]
2081 fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
2082 use crate::engine::test_helpers::write_schema_files_with_default_type;
2083
2084 let tmp = TempDir::new().unwrap();
2085 let mem_dir = tmp.path().join("mem");
2086 std::fs::create_dir_all(&mem_dir).unwrap();
2087
2088 let authored_dir = tmp.path().join(".memstead").join("schemas");
2090 let manifest = r#"name: authored
2091version: 0.1.0
2092description: an authored-in-workspace test schema
2093when_to_use: tests
2094types:
2095 - doc
2096relationships:
2097 mode: strict
2098 definitions:
2099 - name: _default
2100 description: fallback
2101 default_weight: 1.0
2102community:
2103 resolution: 1.0
2104 seed: 42
2105"#;
2106 write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
2107
2108 let memstead = tmp.path().join(".memstead");
2110 std::fs::create_dir_all(&memstead).unwrap();
2111 std::fs::write(
2112 memstead.join("workspace.toml"),
2113 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2114 )
2115 .unwrap();
2116 let mount = Mount {
2117 mem: "specs".to_string(),
2118 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2119 storage: MountStorage::Folder { path: mem_dir },
2120 capability: MountCapability::Write,
2121 lifecycle: MountLifecycle::Eager,
2122 cross_linkable: true,
2123 migration_target: None,
2124 };
2125 use crate::workspace_store::WorkspaceStoreAdapter;
2126 crate::FileWorkspaceStore::new()
2127 .save_state(
2128 tmp.path(),
2129 &crate::workspace::Workspace {
2130 mounts: vec![mount],
2131 settings: crate::workspace::WorkspaceSettings::default(),
2132 },
2133 )
2134 .unwrap();
2135
2136 let engine = Engine::from_workspace_root(tmp.path())
2139 .expect("authored schema at .memstead/schemas/ must resolve at boot");
2140 assert_eq!(engine.mem_names(), vec!["specs"]);
2141 }
2142
2143 #[test]
2149 fn health_reports_authoring_drift_for_stamped_schemas_only() {
2150 use crate::engine::test_helpers::write_schema_files_with_default_type;
2151
2152 let tmp = TempDir::new().unwrap();
2153 let mem_dir = tmp.path().join("mem");
2154 std::fs::create_dir_all(&mem_dir).unwrap();
2155 let manifest = r#"name: authored
2156version: 0.1.0
2157description: an authored-in-workspace test schema
2158when_to_use: tests
2159types:
2160 - doc
2161relationships:
2162 mode: strict
2163 definitions:
2164 - name: _default
2165 description: fallback
2166 default_weight: 1.0
2167community:
2168 resolution: 1.0
2169 seed: 42
2170"#;
2171 let sealed_root = tmp.path().join(".memstead").join("schemas");
2174 write_schema_files_with_default_type(&sealed_root, "authored@0.1.0", manifest, &["doc"]);
2175 let author_root = tmp.path().join("author");
2176 write_schema_files_with_default_type(&author_root, "authored@0.1.0", manifest, &["doc"]);
2177 let authoring_dir = author_root.join("authored@0.1.0");
2178 let sealed_dir = sealed_root.join("authored@0.1.0");
2179 let stamp_path = sealed_dir.join(memstead_schema::INSTALL_PROVENANCE_FILE);
2181 std::fs::write(
2182 &stamp_path,
2183 serde_json::to_vec_pretty(&serde_json::json!({
2184 "authoring_path": authoring_dir.display().to_string(),
2185 }))
2186 .unwrap(),
2187 )
2188 .unwrap();
2189
2190 let memstead = tmp.path().join(".memstead");
2191 std::fs::write(
2192 memstead.join("workspace.toml"),
2193 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2194 )
2195 .unwrap();
2196 let mount = Mount {
2197 mem: "specs".to_string(),
2198 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2199 storage: MountStorage::Folder { path: mem_dir },
2200 capability: MountCapability::Write,
2201 lifecycle: MountLifecycle::Eager,
2202 cross_linkable: true,
2203 migration_target: None,
2204 };
2205 use crate::workspace_store::WorkspaceStoreAdapter;
2206 crate::FileWorkspaceStore::new()
2207 .save_state(
2208 tmp.path(),
2209 &crate::workspace::Workspace {
2210 mounts: vec![mount],
2211 settings: crate::workspace::WorkspaceSettings::default(),
2212 },
2213 )
2214 .unwrap();
2215 let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
2216 let drift_codes = |e: &Engine| -> Vec<String> {
2217 e.health()
2218 .warnings
2219 .iter()
2220 .filter(|w| w.code().starts_with("SCHEMA_AUTHORING_SOURCE_"))
2221 .map(|w| w.code().to_string())
2222 .collect()
2223 };
2224
2225 let sealed_before = std::fs::read(sealed_dir.join("schema.yaml")).unwrap();
2228 let author_before = std::fs::read(authoring_dir.join("schema.yaml")).unwrap();
2229 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2230 assert_eq!(
2231 std::fs::read(sealed_dir.join("schema.yaml")).unwrap(),
2232 sealed_before,
2233 "health must not touch the sealed copy"
2234 );
2235 assert_eq!(
2236 std::fs::read(authoring_dir.join("schema.yaml")).unwrap(),
2237 author_before,
2238 "health must not touch the authoring copy"
2239 );
2240
2241 std::fs::write(
2245 authoring_dir.join("schema.yaml"),
2246 format!(
2247 "# yaml-language-server: $schema=../../.memstead/meta-schemas/schema-manifest.json\n# cosmetic comment\n{manifest}"
2248 ),
2249 )
2250 .unwrap();
2251 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2252
2253 std::fs::write(
2256 authoring_dir.join("schema.yaml"),
2257 manifest.replace(
2258 "an authored-in-workspace test schema",
2259 "a semantically different description",
2260 ),
2261 )
2262 .unwrap();
2263 let warnings = engine.health().warnings;
2264 let diverged = warnings
2265 .iter()
2266 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED")
2267 .expect("semantic change must surface as DIVERGED");
2268 let d = serde_json::to_value(diverged).unwrap();
2269 assert_eq!(d["details"]["schema_ref"], "authored@0.1.0");
2270 assert_eq!(d["details"]["mems"], serde_json::json!(["specs"]));
2271
2272 std::fs::remove_dir_all(&authoring_dir).unwrap();
2274 let warnings = engine.health().warnings;
2275 let missing = warnings
2276 .iter()
2277 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_MISSING")
2278 .expect("vanished authoring package must surface as MISSING");
2279 let m = serde_json::to_value(missing).unwrap();
2280 assert_eq!(m["details"]["schema_ref"], "authored@0.1.0");
2281 assert_eq!(m["details"]["mems"], serde_json::json!(["specs"]));
2282 assert_eq!(
2283 m["details"]["stamped_path"],
2284 authoring_dir.display().to_string()
2285 );
2286 assert!(
2287 !warnings
2288 .iter()
2289 .any(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED"),
2290 "missing and diverged are distinct findings"
2291 );
2292
2293 std::fs::remove_file(&stamp_path).unwrap();
2295 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2296 }
2297
2298 #[test]
2303 fn full_refresh_is_additive_and_reports_skipped_removals() {
2304 use crate::engine::test_helpers::write_schema_files_with_default_type;
2305 use crate::workspace_store::WorkspaceStoreAdapter;
2306
2307 let tmp = TempDir::new().unwrap();
2308 let root = tmp.path();
2309 let mem_a = root.join("mem-a");
2310 std::fs::create_dir_all(&mem_a).unwrap();
2311 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2312 std::fs::write(
2313 root.join(".memstead").join("workspace.toml"),
2314 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2315 )
2316 .unwrap();
2317 let mount = |mem: &str, dir: &Path, schema: &str, version: semver::Version| Mount {
2318 mem: mem.to_string(),
2319 schema: Some(SchemaRef::new(schema, version)),
2320 storage: MountStorage::Folder {
2321 path: dir.to_path_buf(),
2322 },
2323 capability: MountCapability::Write,
2324 lifecycle: MountLifecycle::Eager,
2325 cross_linkable: true,
2326 migration_target: None,
2327 };
2328 let save = |mounts: Vec<Mount>| {
2329 crate::FileWorkspaceStore::new()
2330 .save_state(
2331 root,
2332 &crate::workspace::Workspace {
2333 mounts,
2334 settings: crate::workspace::WorkspaceSettings::default(),
2335 },
2336 )
2337 .unwrap();
2338 };
2339 save(vec![mount(
2340 "specs",
2341 &mem_a,
2342 "default",
2343 semver::Version::new(1, 0, 0),
2344 )]);
2345 let mut engine = Engine::from_workspace_root(root).expect("workspace boots");
2346 assert_eq!(engine.mem_names(), vec!["specs"]);
2347
2348 let manifest = r#"name: authored
2351version: 0.1.0
2352description: an out-of-band installed schema
2353when_to_use: tests
2354types:
2355 - doc
2356relationships:
2357 mode: strict
2358 definitions:
2359 - name: _default
2360 description: fallback
2361 default_weight: 1.0
2362community:
2363 resolution: 1.0
2364 seed: 42
2365"#;
2366 write_schema_files_with_default_type(
2367 &root.join(".memstead").join("schemas"),
2368 "authored@0.1.0",
2369 manifest,
2370 &["doc"],
2371 );
2372 let mem_b = root.join("mem-b");
2373 std::fs::create_dir_all(&mem_b).unwrap();
2374 save(vec![
2375 mount("specs", &mem_a, "default", semver::Version::new(1, 0, 0)),
2376 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2377 ]);
2378
2379 let (actor, client) = cli_actor();
2382 let mut pre = crate::engine::test_helpers::empty_create_args("notes", "Too Early");
2383 pre.entity_type = "doc".to_string();
2384 pre.sections = indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2385 let err = engine
2386 .create_entity(pre.clone(), actor, Some(&client), None)
2387 .unwrap_err();
2388 assert_eq!(err.code(), "UNKNOWN_MEM", "{err:?}");
2389 assert!(
2390 !engine
2391 .workspace_schemas()
2392 .iter()
2393 .any(|s| s.id().0 == "authored"),
2394 "schema catalogue is fixed pre-refresh"
2395 );
2396
2397 let report = engine.full_refresh();
2399 assert_eq!(report.schemas_added, vec!["authored@0.1.0".to_string()]);
2400 assert_eq!(report.mems_mounted, vec!["notes".to_string()]);
2401 assert!(report.schema_removals_skipped.is_empty(), "{report:?}");
2402 assert!(report.mem_removals_skipped.is_empty(), "{report:?}");
2403 assert!(report.failures.is_empty(), "{report:?}");
2404 engine
2405 .create_entity(pre, actor, Some(&client), None)
2406 .expect("newly mounted mem accepts writes after the refresh");
2407
2408 std::fs::remove_dir_all(
2411 root.join(".memstead")
2412 .join("schemas")
2413 .join("authored@0.1.0"),
2414 )
2415 .unwrap();
2416 save(vec![mount(
2417 "notes",
2418 &mem_b,
2419 "authored",
2420 semver::Version::new(0, 1, 0),
2421 )]);
2422 let report = engine.full_refresh();
2423 assert_eq!(report.mem_removals_skipped, vec!["specs".to_string()]);
2424 assert_eq!(
2425 report.schema_removals_skipped,
2426 vec!["authored@0.1.0".to_string()]
2427 );
2428 assert!(report.schemas_added.is_empty());
2429 assert!(report.mems_mounted.is_empty());
2430 engine
2433 .create_entity(
2434 crate::engine::test_helpers::empty_create_args("specs", "Still Here"),
2435 actor,
2436 Some(&client),
2437 None,
2438 )
2439 .expect("skipped-removal mem stays writable");
2440 let mut into_notes =
2441 crate::engine::test_helpers::empty_create_args("notes", "Still Resolvable");
2442 into_notes.entity_type = "doc".to_string();
2443 into_notes.sections =
2444 indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2445 engine
2446 .create_entity(into_notes, actor, Some(&client), None)
2447 .expect("removed-from-source schema stays resolvable");
2448
2449 let broken = root.join("broken-mem");
2452 std::fs::write(&broken, b"not a directory").unwrap();
2453 save(vec![
2454 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2455 mount("broken", &broken, "default", semver::Version::new(1, 0, 0)),
2456 ]);
2457 let report = engine.full_refresh();
2458 assert!(
2459 report.failures.iter().any(|f| f.item == "mount:broken"),
2460 "failed mount must be reported per-item: {report:?}"
2461 );
2462 assert!(
2463 !report.mems_mounted.contains(&"broken".to_string()),
2464 "a failed mount never surfaces as newly available"
2465 );
2466 assert!(
2467 engine
2468 .get_entity(&crate::EntityId::new("broken", "anything"))
2469 .is_none()
2470 && engine.mem_names().contains(&"notes"),
2471 "other mounts unaffected"
2472 );
2473 }
2474
2475 #[test]
2476 fn from_workspace_root_propagates_mem_management_settings() {
2477 let tmp = TempDir::new().unwrap();
2482 let mem_dir = tmp.path().join("mem");
2483 std::fs::create_dir_all(&mem_dir).unwrap();
2484
2485 let memstead = tmp.path().join(".memstead");
2486 std::fs::create_dir_all(&memstead).unwrap();
2487 std::fs::write(
2488 memstead.join("workspace.toml"),
2489 r#"format = "memstead-git-branch-2"
2490
2491[persistence_adapter]
2492name = "file-two-layer"
2493
2494[[mem_management.create]]
2495pattern = "exec-*"
2496schemas = ["default@1.0.0"]
2497
2498[[mem_management.delete]]
2499pattern = "exec-*"
2500"#,
2501 )
2502 .unwrap();
2503 use crate::workspace_store::WorkspaceStoreAdapter;
2504 let store = crate::FileWorkspaceStore::new();
2505 store
2506 .save_state(
2507 tmp.path(),
2508 &crate::workspace::Workspace {
2509 mounts: vec![folder_mount("specs", mem_dir)],
2510 settings: crate::workspace::WorkspaceSettings::default(),
2511 },
2512 )
2513 .unwrap();
2514
2515 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2516 let s = engine.settings();
2517 assert_eq!(s.mem_create_rules.len(), 1);
2518 assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
2519 assert_eq!(
2520 s.mem_create_rules[0].schemas,
2521 vec!["default@1.0.0".to_string()]
2522 );
2523 assert_eq!(s.mem_delete_rules.len(), 1);
2524 assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
2525 }
2526
2527 #[test]
2535 fn from_mounts_quarantines_unknown_schema_pin() {
2536 let tmp = TempDir::new().unwrap();
2537 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
2538 let mount = Mount {
2539 mem: "specs".to_string(),
2540 schema: Some(SchemaRef::new(
2541 "totally-not-a-schema",
2542 semver::Version::new(1, 0, 0),
2543 )),
2544 storage: MountStorage::Folder {
2545 path: tmp.path().to_path_buf(),
2546 },
2547 capability: MountCapability::Write,
2548 lifecycle: MountLifecycle::Eager,
2549 cross_linkable: true,
2550 migration_target: None,
2551 };
2552 let engine = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2553 .expect("a broken mem quarantines, never fails the workspace");
2554
2555 let roster = engine.quarantined_mems();
2556 assert_eq!(roster.len(), 1);
2557 assert_eq!(roster[0].mount.mem, "specs");
2558 assert_eq!(roster[0].reason_code, "SCHEMA_NOT_FOUND");
2559 assert!(
2560 roster[0].reason_message.contains("totally-not-a-schema"),
2561 "reason carries the failing pin: {}",
2562 roster[0].reason_message
2563 );
2564 assert!(engine.mounts().iter().all(|m| m.mem != "specs"));
2566 let err = engine.unknown_mem_error("specs");
2569 assert_eq!(err.code(), "MEM_QUARANTINED");
2570 assert!(
2571 err.to_string().contains("SCHEMA_NOT_FOUND"),
2572 "quarantine refusal carries the underlying reason: {err}"
2573 );
2574 let health = engine.health();
2576 assert_eq!(health.quarantined.len(), 1);
2577 assert_eq!(health.quarantined[0].reason_code, "SCHEMA_NOT_FOUND");
2578 }
2579
2580 #[test]
2586 fn reload_returns_repaired_mem_from_quarantine() {
2587 let tmp = TempDir::new().unwrap();
2588 let dir = tmp.path().to_path_buf();
2589 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2590 let config_path = dir.join(".memstead").join("config.json");
2591 std::fs::write(&config_path, r#"{ "schema": "ghost@1.0.0" }"#).unwrap();
2592 let writer = FilesystemMemWriter::new(dir.clone());
2593 let mount = Mount {
2594 mem: "specs".to_string(),
2595 schema: None,
2596 storage: MountStorage::Folder { path: dir },
2597 capability: MountCapability::Write,
2598 lifecycle: MountLifecycle::Eager,
2599 cross_linkable: true,
2600 migration_target: None,
2601 };
2602 let mut engine =
2603 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2604 assert_eq!(engine.quarantined_mems().len(), 1);
2605 let err = engine.reload_one_mem("specs").unwrap_err();
2608 assert_eq!(err.code(), "MEM_QUARANTINED");
2609 assert_eq!(engine.quarantined_mems().len(), 1);
2610
2611 std::fs::write(&config_path, r#"{ "schema": "default@1.0.0" }"#).unwrap();
2614 engine
2615 .reload_one_mem("specs")
2616 .expect("repaired mem re-attaches on reload");
2617 assert!(
2618 engine.quarantined_mems().is_empty(),
2619 "roster entry disappears after re-attach"
2620 );
2621 let mut sections = indexmap::IndexMap::new();
2623 sections.insert("identity".to_string(), "back".to_string());
2624 sections.insert("purpose".to_string(), "post-repair service".to_string());
2625 engine
2626 .create_entity_with_ctx(
2627 crate::engine::CreateEntityArgs {
2628 anchors: Vec::new(),
2629 mem: "specs".to_string(),
2630 title: "Back".to_string(),
2631 entity_type: "spec".to_string(),
2632 sections,
2633 metadata: indexmap::IndexMap::new(),
2634 relations: Vec::new(),
2635 dry_run: false,
2636 },
2637 &crate::vcs::CommitContext::internal(),
2638 )
2639 .expect("reattached mem serves writes");
2640 }
2641
2642 #[test]
2647 fn cross_mem_link_into_quarantined_mem_degrades_without_cascade() {
2648 let tmp = TempDir::new().unwrap();
2649 let healthy_dir = tmp.path().join("healthy");
2650 std::fs::create_dir_all(&healthy_dir).unwrap();
2651 std::fs::write(
2652 healthy_dir.join("linker.md"),
2653 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n\
2654 # Linker\n\n## Identity\n\nsee [[badpin:target]] for detail.\n",
2655 )
2656 .unwrap();
2657 let badpin_dir = tmp.path().join("badpin");
2658 std::fs::create_dir_all(&badpin_dir).unwrap();
2659 let mount = |mem: &str, dir: std::path::PathBuf, pin: &str| {
2660 (
2661 Mount {
2662 mem: mem.to_string(),
2663 schema: Some(SchemaRef::new(pin, semver::Version::new(1, 0, 0))),
2664 storage: MountStorage::Folder { path: dir.clone() },
2665 capability: MountCapability::Write,
2666 lifecycle: MountLifecycle::Eager,
2667 cross_linkable: true,
2668 migration_target: None,
2669 },
2670 Box::new(FilesystemMemWriter::new(dir)) as Box<dyn MemBackend>,
2671 )
2672 };
2673 let engine = Engine::from_mounts(vec![
2674 mount("healthy", healthy_dir, "default"),
2675 mount("badpin", badpin_dir, "ghost"),
2676 ])
2677 .expect("boot survives the cross-mem link into the quarantined mem");
2678 assert_eq!(engine.quarantined_mems().len(), 1);
2679 let linker = engine
2683 .get_entity(&crate::EntityId::new("healthy", "linker"))
2684 .expect("linking entity loads");
2685 assert_eq!(linker.entity_type, "spec");
2686 assert!(
2687 engine
2688 .get_entity(&crate::EntityId::new("badpin", "target"))
2689 .is_none_or(|e| e.stub),
2690 "the quarantined-side target is at most a stub, never real data"
2691 );
2692 }
2693
2694 #[test]
2700 fn old_key_authored_schema_quarantines_pinning_mem_never_workspace() {
2701 let tmp = TempDir::new().unwrap();
2702 let root = tmp.path();
2703 std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
2705 std::fs::write(
2706 root.join(".memstead").join("workspace.toml"),
2707 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2708 )
2709 .unwrap();
2710 for m in ["healthy", "oldkey"] {
2711 std::fs::create_dir_all(root.join(m)).unwrap();
2712 }
2713 std::fs::write(
2714 root.join(".memstead").join("state").join("mounts.json"),
2715 r#"{ "format": "memstead-mounts-3", "mounts": [
2716 { "mem": "healthy", "schema": "default@1.1.0", "storage": { "type": "folder", "path": "healthy" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true },
2717 { "mem": "oldkey", "schema": "fieldschema@0.1.0", "storage": { "type": "folder", "path": "oldkey" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
2718 ] }"#,
2719 )
2720 .unwrap();
2721 let pkg = root
2723 .join(".memstead")
2724 .join("schemas")
2725 .join("fieldschema@0.1.0");
2726 std::fs::create_dir_all(pkg.join("types")).unwrap();
2727 std::fs::write(
2728 pkg.join("schema.yaml"),
2729 "name: fieldschema\nversion: 0.1.0\ndescription: field schema\nwhen_to_use: tests\ntypes:\n - thing\nrelationships:\n mode: strict\n definitions:\n - name: PART_OF\n description: h\n default_weight: 1.0\n acyclic: true\n - name: _default\n description: f\n default_weight: 1.0\ncommunity:\n resolution: 1.0\n seed: 42\n",
2730 )
2731 .unwrap();
2732 std::fs::write(
2733 pkg.join("types").join("thing.yaml"),
2734 "name: thing\ndescription: t\nwhen_to_use: h\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\npropagating_relationships: []\nupdatable_fields:\n - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n",
2735 )
2736 .unwrap();
2737
2738 let engine = Engine::from_workspace_root(root)
2739 .expect("the old-key schema quarantines its mem, never the workspace");
2740 assert!(engine.mounts().iter().any(|m| m.mem == "healthy"));
2741 let q = engine
2742 .quarantine_reason("oldkey")
2743 .expect("oldkey mem is quarantined");
2744 assert_eq!(q.reason_code, "SCHEMA_LOAD_FAILED");
2745 assert!(
2746 q.reason_message.contains("no_self_loop_relationships"),
2747 "quarantine reason is the rename error naming the new key: {}",
2748 q.reason_message
2749 );
2750 }
2751
2752 #[test]
2757 fn ingest_0_3_entries_are_leaves_prior_version_unchanged() {
2758 let entry_md = "---\ntype: coverage_gap\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nstatus: open\n---\n# Gap\n\n## Area\n\nan uncovered area.\n";
2759 let boot = |pin: &str| {
2760 let tmp = TempDir::new().unwrap();
2761 let dir = tmp.path().to_path_buf();
2762 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2763 std::fs::write(
2764 dir.join(".memstead").join("config.json"),
2765 format!("{{ \"schema\": \"{pin}\" }}"),
2766 )
2767 .unwrap();
2768 std::fs::write(dir.join("gap.md"), entry_md).unwrap();
2769 let writer = FilesystemMemWriter::new(dir.clone());
2770 let mount = Mount {
2771 mem: "proc".to_string(),
2772 schema: None,
2773 storage: MountStorage::Folder { path: dir },
2774 capability: MountCapability::Write,
2775 lifecycle: MountLifecycle::Eager,
2776 cross_linkable: true,
2777 migration_target: None,
2778 };
2779 let engine =
2780 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2781 .unwrap();
2782 (engine.health(), tmp)
2783 };
2784
2785 let (health_new, _t1) = boot("ingest@0.3.0");
2786 assert_eq!(
2787 health_new.orphan_count, 0,
2788 "0.3.0 entry types are leaves — zero false orphans"
2789 );
2790 assert_eq!(
2791 health_new
2792 .leaf_entities_by_type
2793 .get("ingest@0.3.0:coverage_gap"),
2794 Some(&1),
2795 "the population stays visible: {:?}",
2796 health_new.leaf_entities_by_type
2797 );
2798
2799 let (health_old, _t2) = boot("ingest@0.2.0");
2800 assert_eq!(
2801 health_old.orphan_count, 1,
2802 "the prior version's behaviour is unchanged"
2803 );
2804 assert!(health_old.leaf_entities_by_type.is_empty());
2805 }
2806
2807 #[test]
2813 fn broken_mem_quarantines_while_healthy_siblings_serve() {
2814 let tmp = TempDir::new().unwrap();
2815 let make_mount = |mem: &str, pin: Option<SchemaRef>| {
2816 let dir = tmp.path().join(mem);
2817 std::fs::create_dir_all(&dir).unwrap();
2818 let writer = FilesystemMemWriter::new(dir.clone());
2819 (
2820 Mount {
2821 mem: mem.to_string(),
2822 schema: pin,
2823 storage: MountStorage::Folder { path: dir },
2824 capability: MountCapability::Write,
2825 lifecycle: MountLifecycle::Eager,
2826 cross_linkable: true,
2827 migration_target: None,
2828 },
2829 Box::new(writer) as Box<dyn MemBackend>,
2830 )
2831 };
2832 let healthy = make_mount(
2833 "healthy",
2834 Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
2835 );
2836 let bad_pin = make_mount(
2837 "badpin",
2838 Some(SchemaRef::new("ghost", semver::Version::new(1, 0, 0))),
2839 );
2840 let missing_pin = make_mount("nopin", None);
2841 let bad_io_path = tmp.path().join("badio");
2844 std::fs::write(&bad_io_path, "not a directory").unwrap();
2845 let bad_io = (
2846 Mount {
2847 mem: "badio".to_string(),
2848 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
2849 storage: MountStorage::Folder {
2850 path: bad_io_path.clone(),
2851 },
2852 capability: MountCapability::Write,
2853 lifecycle: MountLifecycle::Eager,
2854 cross_linkable: true,
2855 migration_target: None,
2856 },
2857 Box::new(FilesystemMemWriter::new(bad_io_path)) as Box<dyn MemBackend>,
2858 );
2859 let mut engine = Engine::from_mounts(vec![healthy, bad_pin, missing_pin, bad_io])
2860 .expect("mixed workspace boots");
2861
2862 let codes: std::collections::HashMap<String, String> = engine
2864 .quarantined_mems()
2865 .iter()
2866 .map(|q| (q.mount.mem.clone(), q.reason_code.clone()))
2867 .collect();
2868 assert_eq!(
2869 codes.get("badpin").map(String::as_str),
2870 Some("SCHEMA_NOT_FOUND")
2871 );
2872 assert_eq!(
2873 codes.get("nopin").map(String::as_str),
2874 Some("MEM_CONFIG_INCOMPLETE")
2875 );
2876 assert!(
2877 codes.contains_key("badio"),
2878 "backend read failure quarantines too: {codes:?}"
2879 );
2880
2881 let mut sections = indexmap::IndexMap::new();
2883 sections.insert("identity".to_string(), "alive".to_string());
2884 sections.insert("purpose".to_string(), "proof of service".to_string());
2885 let created = engine
2886 .create_entity_with_ctx(
2887 crate::engine::CreateEntityArgs {
2888 anchors: Vec::new(),
2889 mem: "healthy".to_string(),
2890 title: "Alive".to_string(),
2891 entity_type: "spec".to_string(),
2892 sections,
2893 metadata: indexmap::IndexMap::new(),
2894 relations: Vec::new(),
2895 dry_run: false,
2896 },
2897 &crate::vcs::CommitContext::internal(),
2898 )
2899 .expect("healthy mem serves writes");
2900 assert_eq!(created.id.to_string(), "healthy--alive");
2901
2902 let mut sections = indexmap::IndexMap::new();
2904 sections.insert("identity".to_string(), "x".to_string());
2905 let err = engine
2906 .create_entity_with_ctx(
2907 crate::engine::CreateEntityArgs {
2908 anchors: Vec::new(),
2909 mem: "badpin".to_string(),
2910 title: "Nope".to_string(),
2911 entity_type: "spec".to_string(),
2912 sections,
2913 metadata: indexmap::IndexMap::new(),
2914 relations: Vec::new(),
2915 dry_run: false,
2916 },
2917 &crate::vcs::CommitContext::internal(),
2918 )
2919 .unwrap_err();
2920 assert_eq!(err.code(), "MEM_QUARANTINED");
2921 }
2922
2923 #[test]
2930 fn mem_config_schema_is_authoritative_over_mount_pin() {
2931 let tmp = TempDir::new().unwrap();
2932 let mem_dir = tmp.path().to_path_buf();
2933 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2934 std::fs::write(
2935 mem_dir.join(".memstead").join("config.json"),
2936 r#"{"schema":"software@0.1.0"}"#,
2937 )
2938 .unwrap();
2939 let writer = FilesystemMemWriter::new(mem_dir.clone());
2940 let mount = Mount {
2941 mem: "specs".to_string(),
2942 schema: Some(SchemaRef::new(
2943 "totally-not-a-schema",
2944 semver::Version::new(9, 9, 9),
2945 )),
2946 storage: MountStorage::Folder { path: mem_dir },
2947 capability: MountCapability::Write,
2948 lifecycle: MountLifecycle::Eager,
2949 cross_linkable: true,
2950 migration_target: None,
2951 };
2952 let engine = Engine::from_mounts(vec![(
2953 mount,
2954 Box::new(writer) as Box<dyn MemBackend>,
2955 )])
2956 .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
2957
2958 let mismatch = engine
2959 .load_warnings()
2960 .iter()
2961 .find_map(|w| match w {
2962 WarningHint::SchemaPinMismatch {
2963 mem,
2964 config_pin,
2965 mount_pin,
2966 } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
2967 _ => None,
2968 })
2969 .expect("SchemaPinMismatch warning must surface naming both pins");
2970 assert_eq!(mismatch.0, "specs");
2971 assert_eq!(mismatch.1, "software@0.1.0");
2972 assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
2973 }
2974
2975 #[test]
2976 fn from_workspace_root_quarantines_git_branch_mount_on_lean() {
2977 let tmp = TempDir::new().unwrap();
2978 let memstead = tmp.path().join(".memstead");
2979 std::fs::create_dir_all(&memstead).unwrap();
2980 std::fs::write(
2981 memstead.join("workspace.toml"),
2982 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2983 )
2984 .unwrap();
2985 let state_dir = memstead.join("state");
2988 std::fs::create_dir_all(&state_dir).unwrap();
2989 std::fs::write(
2990 state_dir.join("mounts.json"),
2991 r#"{
2992 "format": "memstead-mounts-3",
2993 "mounts": [
2994 {
2995 "mem": "specs",
2996 "schema": "default@1.0.0",
2997 "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
2998 "capability": "write",
2999 "lifecycle": "eager",
3000 "cross_linkable": true
3001 }
3002 ]
3003 }"#,
3004 )
3005 .unwrap();
3006 let engine = Engine::from_workspace_root(tmp.path())
3014 .expect("lean boot quarantines the git-branch mount, never fails the workspace");
3015 let roster = engine.quarantined_mems();
3016 assert_eq!(roster.len(), 1);
3017 assert_eq!(roster[0].mount.mem, "specs");
3018 assert_eq!(roster[0].reason_code, "UNSUPPORTED_WORKSPACE_SHAPE");
3019 assert_eq!(engine.unknown_mem_error("specs").code(), "MEM_QUARANTINED");
3020 }
3021
3022 #[test]
3023 fn from_workspace_root_roots_standalone_folder_mem() {
3024 let tmp = TempDir::new().unwrap();
3028 let root = tmp.path();
3029 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3030 std::fs::write(
3031 root.join(".memstead").join("config.json"),
3032 r#"{"schema":"default@1.0.0"}"#,
3033 )
3034 .unwrap();
3035 std::fs::write(
3037 root.join("hello.md"),
3038 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3039 )
3040 .unwrap();
3041
3042 let engine = Engine::from_workspace_root(root)
3043 .expect("a bare folder mem must root as a one-mount workspace");
3044 assert_eq!(engine.status().mem_count, 1, "exactly one mount");
3045 assert!(
3046 engine.status().entity_count >= 1,
3047 "the standalone mem's entity must load"
3048 );
3049 }
3050
3051 #[test]
3052 fn from_workspace_root_still_rejects_truly_empty_dir() {
3053 let tmp = TempDir::new().unwrap();
3057 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
3058 assert!(
3059 matches!(err, crate::BootError::NotInitialised(_)),
3060 "got {err:?}"
3061 );
3062 }
3063
3064 #[test]
3071 fn boot_keeps_loading_violating_schema_and_surfaces_health_finding() {
3072 let tmp = TempDir::new().unwrap();
3073 let schemas_dir = tmp.path().join("schemas");
3074 let pkg = schemas_dir.join("debate");
3075 std::fs::create_dir_all(pkg.join("types")).unwrap();
3076 std::fs::write(
3077 pkg.join("schema.yaml"),
3078 r#"name: debate
3079version: 0.1.0
3080description: sealed-violator fixture
3081when_to_use: tests
3082types:
3083 - question
3084relationships:
3085 mode: strict
3086 definitions:
3087 - name: PART_OF
3088 description: hier
3089 default_weight: 3.0
3090 - name: _default
3091 description: fallback
3092 default_weight: 1.0
3093community:
3094 resolution: 1.0
3095 seed: 42
3096"#,
3097 )
3098 .unwrap();
3099 std::fs::write(
3100 pkg.join("types").join("question.yaml"),
3101 r#"name: question
3102description: t
3103when_to_use: tests
3104sections:
3105 - key: answers
3106 heading: Answers argued
3107 required: true
3108 search_weight: 10.0
3109 write_rules: []
3110 - key: notes
3111 heading: Notes
3112 required: false
3113 search_weight: 3.0
3114 catch_all: true
3115 write_rules: []
3116metadata_fields: []
3117title_weight: 100.0
3118text_fields:
3119 - answers
3120 - notes
3121hierarchy_relationship: PART_OF
3122no_self_loop_relationships: []
3123updatable_fields:
3124 - title
3125 - answers
3126 - notes
3127health_required_fields:
3128 - answers
3129staleness_threshold_days: 90
3130write_rules: []
3131"#,
3132 )
3133 .unwrap();
3134
3135 let mem_dir = tmp.path().join("mem");
3136 std::fs::create_dir_all(&mem_dir).unwrap();
3137 std::fs::write(
3138 mem_dir.join("q.md"),
3139 "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n",
3140 )
3141 .unwrap();
3142
3143 let writer = FilesystemMemWriter::new(mem_dir.clone());
3144 let mount = Mount {
3145 mem: "debate-mem".to_string(),
3146 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3147 storage: MountStorage::Folder { path: mem_dir },
3148 capability: MountCapability::Write,
3149 lifecycle: MountLifecycle::Eager,
3150 cross_linkable: true,
3151 migration_target: None,
3152 };
3153 let engine = Engine::from_mounts_with_schemas_dir(
3154 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3155 Some(&schemas_dir),
3156 )
3157 .expect("a violating sealed schema must keep loading, never refuse boot");
3158
3159 assert!(
3161 engine.status().entity_count >= 1,
3162 "entities load despite the schema violation"
3163 );
3164
3165 let hits: Vec<_> = engine
3167 .load_warnings()
3168 .iter()
3169 .filter_map(|w| match w {
3170 WarningHint::SchemaHeadingRoundtripViolation {
3171 mem,
3172 schema_ref,
3173 violations,
3174 } => Some((mem.clone(), schema_ref.clone(), violations.clone())),
3175 _ => None,
3176 })
3177 .collect();
3178 assert_eq!(
3179 hits.len(),
3180 1,
3181 "exactly one schema-level finding; all warnings = {:?}",
3182 engine.load_warnings()
3183 );
3184 let (mem, schema_ref, violations) = &hits[0];
3185 assert_eq!(mem, "debate-mem");
3186 assert_eq!(schema_ref, "debate@0.1.0");
3187 assert_eq!(violations.len(), 1);
3188 assert_eq!(violations[0].type_name, "question");
3189 assert_eq!(violations[0].key, "answers");
3190 assert_eq!(violations[0].heading, "Answers argued");
3191 assert_eq!(violations[0].derived_key, "answers_argued");
3192 }
3193
3194 #[test]
3202 fn sealed_violator_mem_still_serves_writes() {
3203 let tmp = TempDir::new().unwrap();
3205 let schemas_dir = tmp.path().join("schemas");
3206 let pkg = schemas_dir.join("debate");
3207 std::fs::create_dir_all(pkg.join("types")).unwrap();
3208 std::fs::write(
3209 pkg.join("schema.yaml"),
3210 r#"name: debate
3211version: 0.1.0
3212description: sealed-violator fixture
3213when_to_use: tests
3214types:
3215 - question
3216relationships:
3217 mode: strict
3218 definitions:
3219 - name: PART_OF
3220 description: hier
3221 default_weight: 3.0
3222 - name: _default
3223 description: fallback
3224 default_weight: 1.0
3225community:
3226 resolution: 1.0
3227 seed: 42
3228"#,
3229 )
3230 .unwrap();
3231 std::fs::write(
3232 pkg.join("types").join("question.yaml"),
3233 r#"name: question
3234description: t
3235when_to_use: tests
3236sections:
3237 - key: answers
3238 heading: Answers argued
3239 required: true
3240 search_weight: 10.0
3241 write_rules: []
3242 - key: notes
3243 heading: Notes
3244 required: false
3245 search_weight: 3.0
3246 catch_all: true
3247 write_rules: []
3248metadata_fields: []
3249title_weight: 100.0
3250text_fields:
3251 - answers
3252 - notes
3253hierarchy_relationship: PART_OF
3254no_self_loop_relationships: []
3255updatable_fields:
3256 - title
3257 - answers
3258 - notes
3259health_required_fields:
3260 - answers
3261staleness_threshold_days: 90
3262write_rules: []
3263"#,
3264 )
3265 .unwrap();
3266
3267 let mem_dir = tmp.path().join("mem");
3268 std::fs::create_dir_all(&mem_dir).unwrap();
3269 std::fs::write(
3273 mem_dir.join("q.md"),
3274 "---\ntype: question\n---\n# Q\n\n## Answers\n\nTwo answers.\n",
3275 )
3276 .unwrap();
3277
3278 let writer = FilesystemMemWriter::new(mem_dir.clone());
3279 let mount = Mount {
3280 mem: "debate-mem".to_string(),
3281 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3282 storage: MountStorage::Folder { path: mem_dir },
3283 capability: MountCapability::Write,
3284 lifecycle: MountLifecycle::Eager,
3285 cross_linkable: true,
3286 migration_target: None,
3287 };
3288 let mut engine = Engine::from_mounts_with_schemas_dir(
3289 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3290 Some(&schemas_dir),
3291 )
3292 .expect("a violating sealed schema must keep loading");
3293
3294 let id = crate::EntityId::new("debate-mem", "q");
3295 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3296 let mut sections = indexmap::IndexMap::new();
3297 sections.insert("answers".to_string(), "Updated answers body.".to_string());
3298 let outcome = engine
3299 .update_entity(
3300 crate::engine::UpdateEntityArgs {
3301 anchors: Vec::new(),
3302 anchors_unset: Vec::new(),
3303 id: id.clone(),
3304 expected_hash: Some(hash),
3305 sections,
3306 append_sections: indexmap::IndexMap::new(),
3307 patch_sections: indexmap::IndexMap::new(),
3308 metadata: indexmap::IndexMap::new(),
3309 metadata_unset: Vec::new(),
3310 declare_relations: Vec::new(),
3311 dry_run: false,
3312 relations_unset: Vec::new(),
3313 },
3314 crate::vcs::Actor::Cli,
3315 None,
3316 None,
3317 )
3318 .expect("a write against a sealed-violator mem must NOT be refused");
3319 assert!(!outcome.commit_sha.is_empty(), "the write commits");
3320 assert!(
3321 outcome
3322 .warnings
3323 .iter()
3324 .any(|w| w.code() == "SECTION_HEADING_DIVERGENCE"),
3325 "the write-path divergence warning fires where its condition holds: {:?}",
3326 outcome.warnings
3327 );
3328
3329 assert!(
3331 engine.load_warnings().iter().any(|w| matches!(
3332 w,
3333 WarningHint::SchemaHeadingRoundtripViolation { mem, .. } if mem == "debate-mem"
3334 )),
3335 "the health finding persists across writes"
3336 );
3337 let entity = engine.get_entity(&id).unwrap();
3346 assert!(
3347 entity
3348 .sections
3349 .values()
3350 .any(|s| s.contains("Updated answers body.")),
3351 "written content survives the round-trip (in the catch-all): {:?}",
3352 entity.sections
3353 );
3354 }
3355
3356 }