use std::cell::OnceCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use memstead_schema::Schema;
use crate::backend::MemBackend;
use crate::engine_fallback_type;
use crate::entity::loader::parse_entries;
use crate::entity::source::{SourceEntry, SourceReadError};
use crate::entity::store_builder::push_entities_into_store;
use crate::mem::{MemOrigin, MemRouterSnapshot};
use crate::ops::WarningHint;
use crate::store::Store;
use crate::workspace::{Mount, MountCapability, MountStorage, WorkspaceSettings};
use super::{BootError, Engine, EngineError, MountedBackend};
impl Engine {
pub fn from_mounts(mounts: Vec<(Mount, Box<dyn MemBackend>)>) -> Result<Self, EngineError> {
Self::from_mounts_inner(mounts, Vec::new())
}
pub fn from_mounts_with_schemas_dir(
mounts: Vec<(Mount, Box<dyn MemBackend>)>,
schemas_dir: Option<&Path>,
) -> Result<Self, EngineError> {
let extra_schemas = load_workspace_schemas(schemas_dir)?;
Self::from_mounts_inner(mounts, extra_schemas)
}
pub fn from_mounts_with_schemas_dir_and_extra(
mounts: Vec<(Mount, Box<dyn MemBackend>)>,
schemas_dir: Option<&Path>,
mut extra: Vec<Arc<memstead_schema::Schema>>,
) -> Result<Self, EngineError> {
let mut local = load_workspace_schemas(schemas_dir)?;
local.append(&mut extra);
Self::from_mounts_inner(mounts, local)
}
pub(crate) fn from_mounts_inner(
mounts: Vec<(Mount, Box<dyn MemBackend>)>,
extra_schemas: Vec<Arc<memstead_schema::Schema>>,
) -> Result<Self, EngineError> {
let mut seen: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(mounts.len());
let mut mounted: Vec<MountedBackend> = Vec::with_capacity(mounts.len());
for (mount, backend) in mounts {
if !seen.insert(mount.mem.clone()) {
return Err(EngineError::DuplicateMem(mount.mem));
}
let last_known_head = backend.current_head().ok().flatten();
let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
memstead_schema::config::parse_mem_config(&value).ok()
});
let archive_provenance =
backend
.read_archive_provenance()
.ok()
.flatten()
.and_then(|bytes| {
memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok()
});
mounted.push(MountedBackend {
mount,
backend,
last_known_head,
mem_config,
archive_provenance,
});
}
let builtin_schemas_only = memstead_schema::builtins::load_builtin_schemas()
.map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
let workspace_schemas = extra_schemas.clone();
let mut catalogue: Vec<Arc<memstead_schema::Schema>> =
Vec::with_capacity(extra_schemas.len() + builtin_schemas_only.len());
catalogue.extend(extra_schemas);
catalogue.extend(builtin_schemas_only.clone());
let builtin_schemas = catalogue;
let mut store = Store::new();
let mut load_errors: Vec<(PathBuf, String)> = Vec::new();
let mut schemas: HashMap<String, Arc<Schema>> = HashMap::with_capacity(mounted.len());
let fallback = engine_fallback_type();
let mem_names: Vec<String> = mounted.iter().map(|m| m.mount.mem.clone()).collect();
let known_suffixes: Vec<String> = mem_names
.iter()
.map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
.collect();
let mut load_warnings: Vec<WarningHint> = Vec::new();
for m in &mounted {
let config_pin = m.mem_config.as_ref().and_then(|c| c.schema.as_ref());
let mount_pin = m.mount.schema.as_ref();
if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
&& cfg != mp
{
load_warnings.push(WarningHint::SchemaPinMismatch {
mem: m.mount.mem.clone(),
config_pin: cfg.as_display(),
mount_pin: mp.as_display(),
});
}
let settled_pin = config_pin.or(mount_pin);
let effective_pin = m
.mount
.migration_target
.as_ref()
.or(settled_pin)
.ok_or_else(|| EngineError::MemConfigIncomplete {
mem: m.mount.mem.clone(),
missing_fields: vec!["schema".to_string()],
})?;
let schema = SchemaResolver::new(&builtin_schemas)
.resolve(effective_pin)
.map_err(|sources| EngineError::SchemaNotFound {
mem: m.mount.mem.clone(),
pin: effective_pin.as_display(),
sources,
})?;
schemas.insert(m.mount.mem.clone(), schema.clone());
let (entries, read_errors) = collect_source_entries(m.backend.as_ref())?;
let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
push_entities_into_store(
&mut store,
load_result.entities,
fallback.as_ref(),
Some(crate::entity::store_builder::LoadCollector {
warnings: &mut load_warnings,
known_suffixes: &known_suffixes,
mem_names: &mem_names,
}),
);
load_errors.extend(load_result.errors);
}
let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
mounted
.iter()
.map(|m| (m.mount.mem.clone(), m.mount.capability))
.collect();
crate::entity::store_builder::validate_loaded_relations(
&mut store,
&schemas,
&mount_caps,
&mut load_warnings,
);
crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
let mem_router = build_mem_router_from_mounts(&mounted);
Ok(Self {
mounts: mounted,
store,
schemas,
workspace_schemas,
builtin_schemas: builtin_schemas_only,
load_errors,
community_memo: OnceCell::new(),
#[cfg(not(target_arch = "wasm32"))]
search_indexes_memo: OnceCell::new(),
settings: WorkspaceSettings::default(),
create_rule_set_memo: OnceCell::new(),
workspace_root: None,
load_warnings,
pipeline_configs: crate::pipeline_store::PipelineConfigs::default(),
mem_router: Arc::new(mem_router),
backend_factory: crate::workspace_store::instantiate_lean_backend,
git_branch_ops: None,
event_subscribers: Arc::new(std::sync::Mutex::new(
crate::engine::events::SubscriberRegistry::new(),
)),
pending_mem_changed: Vec::new(),
})
}
pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
use crate::workspace_store::{
FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
instantiate_lean_backend,
};
let workspace = match detect_layout(workspace_root) {
Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
Some(ws) => ws,
None => {
return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
}
},
Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
};
let settings = workspace.settings.clone();
let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
Vec::with_capacity(workspace.mounts.len());
for mount in workspace.mounts {
let backend = instantiate_lean_backend(&mount)?;
mounts.push((mount, backend));
}
use crate::schema_source::SchemaSource as _;
let local = crate::schema_source::FolderSchemaSource::for_workspace(workspace_root)
.read_schemas()
.map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
let mut engine = Engine::from_mounts_inner(mounts, local)?;
engine.set_settings(settings);
engine.workspace_root = Some(workspace_root.to_path_buf());
engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
workspace_root,
)?);
let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
Ok(engine)
}
}
fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
let mut router = MemRouterSnapshot::new();
for m in mounts {
match m.mount.capability {
MountCapability::Write => {
let dir: Option<PathBuf> = match &m.mount.storage {
MountStorage::Folder { path } => Some(path.clone()),
MountStorage::GitBranch { .. } => None,
MountStorage::Archive { .. } => None,
MountStorage::InMemory => None,
};
router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
}
MountCapability::ReadOnly => match &m.mount.storage {
MountStorage::Archive { path } => {
router.add_read_only(m.mount.mem.clone(), path.clone());
}
MountStorage::Folder { path } => {
router.add_read_only(m.mount.mem.clone(), path.clone());
}
MountStorage::GitBranch { gitdir, .. } => {
router.add_read_only(m.mount.mem.clone(), gitdir.clone());
}
MountStorage::InMemory => {}
},
}
}
router
}
pub fn resolve_builtin_schema_pin_pub(
pin: &memstead_schema::SchemaRef,
catalogue: &[Arc<memstead_schema::Schema>],
) -> Option<Arc<memstead_schema::Schema>> {
resolve_builtin_schema_pin(pin, catalogue)
}
pub struct SchemaResolver<'a> {
catalogue: &'a [Arc<memstead_schema::Schema>],
}
impl<'a> SchemaResolver<'a> {
pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
Self { catalogue }
}
pub fn resolve(
&self,
pin: &memstead_schema::SchemaRef,
) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
{
resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
&pin.name,
&pin.version,
self.catalogue,
)
})
}
}
pub(crate) fn load_workspace_schemas(
schemas_dir: Option<&Path>,
) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
let Some(dir) = schemas_dir else {
return Ok(Vec::new());
};
if !dir.is_dir() {
return Ok(Vec::new());
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return Ok(Vec::new()),
};
let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if !path.join("schema.yaml").is_file() {
continue;
}
let schema = memstead_schema::load_schema_from_dir(&path)
.map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
schemas.push(Arc::new(schema));
}
Ok(schemas)
}
pub(super) fn resolve_builtin_schema_pin(
pin: &memstead_schema::SchemaRef,
catalogue: &[Arc<memstead_schema::Schema>],
) -> Option<Arc<memstead_schema::Schema>> {
catalogue
.iter()
.find(|s| {
let id = s.id();
id.0 == pin.name && id.1 == pin.version
})
.cloned()
}
pub(super) fn collect_source_entries(
backend: &dyn MemBackend,
) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
let paths = backend.list_entities()?;
let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
let mut errors: Vec<SourceReadError> = Vec::new();
for path in paths {
match backend.read_entity(&path) {
Ok(Some(bytes)) => match String::from_utf8(bytes) {
Ok(content) => entries.push(SourceEntry {
relative_path: path.to_string_lossy().into_owned(),
source_path: path.clone(),
content,
}),
Err(e) => errors.push(SourceReadError {
source_path: path,
error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
}),
},
Ok(None) => {
}
Err(e) => errors.push(SourceReadError {
source_path: path,
error: std::io::Error::other(e.to_string()),
}),
}
}
Ok((entries, errors))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use memstead_schema::SchemaRef;
use tempfile::TempDir;
use crate::backend::MemBackend;
use crate::engine::test_helpers::*;
use crate::engine::{Engine, EngineError};
use crate::ops::WarningHint;
use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
use crate::vcs::CommitContext;
use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
#[test]
fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
let resolver = super::SchemaResolver::new(&catalogue);
let ok: SchemaRef = "default@1.0.0".parse().unwrap();
assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
let sources = resolver.resolve(&miss).unwrap_err();
let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
assert_eq!(labels, ["local_storage", "builtin", "remote"]);
assert!(sources.iter().all(|s| !s.pinned_version_match));
}
#[test]
fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
let engine = Engine::from_mounts(Vec::new()).unwrap();
assert!(engine.mem_names().is_empty());
match engine.list_entities("missing") {
Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
other => panic!("expected UnknownMem, got {other:?}"),
}
}
#[test]
fn duplicate_mem_names_rejected_at_construction() {
let tmp = TempDir::new().unwrap();
let writer1: Box<dyn MemBackend> =
Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
let writer2: Box<dyn MemBackend> =
Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
let err = Engine::from_mounts(vec![
(folder_mount("specs", tmp.path().to_path_buf()), writer1),
(folder_mount("specs", tmp.path().to_path_buf()), writer2),
])
.unwrap_err();
assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
}
#[test]
fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
let body =
"---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
std::fs::write(mem_dir.join("dup.md"), body).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let warnings = engine.load_warnings();
assert!(
warnings
.iter()
.any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
"load_warnings must surface DuplicateSectionHeading: {warnings:?}",
);
}
#[test]
fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
std::fs::write(mem_dir.join("target.md"), target).unwrap();
std::fs::write(mem_dir.join("source.md"), source).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let source_id = crate::entity::EntityId::new("specs", "source");
let target_id = crate::entity::EntityId::new("specs", "target");
let source_entity = engine.get_entity(&source_id).expect("source loaded");
assert!(
source_entity.relationships.is_empty(),
"MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
source_entity.relationships,
);
let outgoing: Vec<_> = engine
.store()
.outgoing(&source_id)
.iter()
.filter(|e| e.rel_type == "MADE_UP_TYPE")
.collect();
assert!(
outgoing.is_empty(),
"MADE_UP_TYPE edge must be dropped from the store"
);
let parsed_invalid: Vec<_> = engine
.load_warnings()
.iter()
.filter_map(|w| match w {
WarningHint::ParsedRelationInvalid {
entity_id,
rel_type,
target,
reason,
origin,
recovery,
} => Some((
entity_id.clone(),
rel_type.clone(),
target.clone(),
reason.clone(),
origin.clone(),
recovery.clone(),
)),
_ => None,
})
.collect();
assert_eq!(
parsed_invalid.len(),
1,
"expected one warning, got {parsed_invalid:?}"
);
assert_eq!(parsed_invalid[0].0, source_id);
assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
assert_eq!(parsed_invalid[0].2, target_id);
assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
assert_eq!(parsed_invalid[0].4, "writable");
let recovery = parsed_invalid[0]
.5
.as_ref()
.expect("writable-origin warning must carry recovery");
assert_eq!(
recovery.kind,
crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
);
assert_eq!(recovery.source_id, parsed_invalid[0].0);
assert_eq!(recovery.target_id, parsed_invalid[0].2);
assert_eq!(recovery.rel_type, parsed_invalid[0].1);
}
#[test]
fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let alpha_id = crate::entity::EntityId::new("specs", "alpha");
let beta_id = crate::entity::EntityId::new("specs", "beta");
assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
let surviving: Vec<_> = engine
.store()
.all_entities()
.flat_map(|e| {
engine
.store()
.outgoing(&e.id)
.iter()
.filter(|edge| edge.rel_type == "PART_OF")
.map(|edge| (e.id.clone(), edge.target.clone()))
.collect::<Vec<_>>()
})
.collect();
assert_eq!(
surviving.len(),
1,
"exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
);
let cycle_drops: Vec<_> = engine
.load_warnings()
.iter()
.filter_map(|w| match w {
WarningHint::ParsedRelationInvalid {
entity_id,
rel_type,
target,
reason,
..
} if reason == "cycle" => {
Some((entity_id.clone(), rel_type.clone(), target.clone()))
}
_ => None,
})
.collect();
assert_eq!(
cycle_drops.len(),
1,
"exactly one cycle warning must fire, got {cycle_drops:?}",
);
let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
assert_eq!(dropped_rel_type, "PART_OF");
let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
assert!(
is_alpha_to_beta || is_beta_to_alpha,
"dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
);
assert_ne!(
(&surviving[0].0, &surviving[0].1),
(dropped_from, dropped_to),
"surviving edge must differ from the dropped one",
);
}
#[test]
fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
use crate::engine::test_helpers::write_schema_files_with_default_type;
let tmp = TempDir::new().unwrap();
let schemas_dir = tmp.path().join("schemas");
std::fs::create_dir_all(&schemas_dir).unwrap();
let manifest = r#"name: shape-test
version: 0.1.0
description: shape-constraint schema
when_to_use: tests
types:
- doc
- actor
relationships:
mode: strict
definitions:
- name: OWNS
description: actor owns doc
default_weight: 1.0
source_types: [actor]
target_types: [doc]
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
write_schema_files_with_default_type(
&schemas_dir,
"shape-test",
manifest,
&["doc", "actor"],
);
let mem_dir = tmp.path().join("mem");
std::fs::create_dir_all(&mem_dir).unwrap();
let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
std::fs::write(mem_dir.join("target.md"), target).unwrap();
std::fs::write(mem_dir.join("source.md"), source).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
let mount = Mount {
mem: "specs".to_string(),
schema: Some(pin),
storage: MountStorage::Folder { path: mem_dir },
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let engine = Engine::from_mounts_with_schemas_dir(
vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
Some(&schemas_dir),
)
.unwrap();
let source_id = crate::entity::EntityId::new("specs", "source");
let target_id = crate::entity::EntityId::new("specs", "target");
let shape_drops: Vec<_> = engine
.load_warnings()
.iter()
.filter_map(|w| match w {
WarningHint::ParsedRelationInvalid {
entity_id,
rel_type,
target,
reason,
origin,
recovery,
} if reason == "shape" => Some((
entity_id.clone(),
rel_type.clone(),
target.clone(),
origin.clone(),
recovery.clone(),
)),
_ => None,
})
.collect();
assert_eq!(
shape_drops.len(),
1,
"expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
engine.load_warnings(),
);
let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
shape_drops.into_iter().next().unwrap();
assert_eq!(drop_from, source_id);
assert_eq!(drop_type, "OWNS");
assert_eq!(drop_to, target_id);
assert_eq!(drop_origin, "writable");
let recovery = drop_recovery.expect("writable origin must carry recovery");
assert_eq!(
recovery.kind,
crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
);
assert_eq!(recovery.source_id, source_id);
assert_eq!(recovery.target_id, target_id);
assert_eq!(recovery.rel_type, "OWNS");
}
#[test]
fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
let tmp = TempDir::new().unwrap();
let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
let archive_path = build_archive(
tmp.path(),
"ext",
&[
("target.md", target.as_bytes()),
("source.md", source.as_bytes()),
],
);
let engine = Engine::from_mounts(vec![(
archive_mount("external", archive_path.clone()),
Box::new(ArchiveBackend::new(archive_path)),
)])
.unwrap();
let invalid: Vec<_> = engine
.load_warnings()
.iter()
.filter_map(|w| match w {
WarningHint::ParsedRelationInvalid {
rel_type,
reason,
origin,
recovery,
..
} => Some((
rel_type.clone(),
reason.clone(),
origin.clone(),
recovery.clone(),
)),
_ => None,
})
.collect();
assert_eq!(
invalid.len(),
1,
"expected one parse-time drop on the readonly mount, got {invalid:?}",
);
assert_eq!(invalid[0].0, "MADE_UP_TYPE");
assert_eq!(invalid[0].1, "unknown_rel_type");
assert_eq!(invalid[0].2, "readonly");
assert!(
invalid[0].3.is_none(),
"readonly-origin warning must omit the recovery hint, got {:?}",
invalid[0].3,
);
}
#[test]
fn load_on_init_populates_store_from_folder_mount() {
let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
let writer = FilesystemMemWriter::new(mem_dir.clone());
<FilesystemMemWriter as MemWriter>::write_entity(
&writer,
Path::new("hello.md"),
body.as_bytes(),
)
.unwrap();
<FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
.unwrap();
let engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
assert_eq!(engine.store().len(), 1, "expected one entity in the store");
let id = crate::EntityId::new("specs", "hello");
let entity = engine.get_entity(&id).expect("entity must be present");
assert_eq!(entity.title, "Hello");
assert_eq!(entity.entity_type, "spec");
assert!(engine.load_errors().is_empty());
assert_eq!(engine.schemas().len(), 1);
assert!(engine.schemas().contains_key("specs"));
}
#[test]
fn load_on_init_populates_store_from_archive_mount() {
let body =
"---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
let tmp = TempDir::new().unwrap();
let archive_path =
build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
let engine = Engine::from_mounts(vec![(
archive_mount("external", archive_path.clone()),
Box::new(ArchiveBackend::new(archive_path)),
)])
.unwrap();
let id = crate::EntityId::new("external", "from-archive");
let entity = engine.get_entity(&id).expect("entity must be present");
assert_eq!(entity.title, "From Archive");
assert!(engine.load_errors().is_empty());
}
#[test]
fn load_on_init_populates_store_from_heterogeneous_mounts() {
let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
let tmp = TempDir::new().unwrap();
let folder_dir = tmp.path().join("folder-mem");
std::fs::create_dir_all(&folder_dir).unwrap();
let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
<FilesystemMemWriter as MemWriter>::write_entity(
&folder_writer,
Path::new("local.md"),
folder_body.as_bytes(),
)
.unwrap();
<FilesystemMemWriter as MemWriter>::commit(
&folder_writer,
"seed",
&CommitContext::internal(),
)
.unwrap();
let archive_path = build_archive(
tmp.path(),
"external",
&[("external.md", archive_body.as_bytes())],
);
let engine = Engine::from_mounts(vec![
(
folder_mount("local", folder_dir),
Box::new(folder_writer) as Box<dyn MemBackend>,
),
(
archive_mount("external", archive_path.clone()),
Box::new(ArchiveBackend::new(archive_path)),
),
])
.unwrap();
assert_eq!(engine.store().len(), 2);
assert!(
engine
.get_entity(&crate::EntityId::new("local", "local"))
.is_some()
);
assert!(
engine
.get_entity(&crate::EntityId::new("external", "external"))
.is_some()
);
}
#[test]
fn load_on_init_collects_per_file_parse_errors_without_failing() {
let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
let writer = FilesystemMemWriter::new(mem_dir.clone());
<FilesystemMemWriter as MemWriter>::write_entity(
&writer,
Path::new("good.md"),
good.as_bytes(),
)
.unwrap();
<FilesystemMemWriter as MemWriter>::write_entity(
&writer,
Path::new("bad.md"),
bad.as_bytes(),
)
.unwrap();
<FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
.unwrap();
let engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
assert!(
engine
.get_entity(&crate::EntityId::new("specs", "good"))
.is_some(),
"good.md must parse and reach the store"
);
let bad_known_to_engine = engine
.get_entity(&crate::EntityId::new("specs", "bad"))
.is_some()
|| !engine.load_errors().is_empty();
assert!(
bad_known_to_engine,
"bad.md must either parse or surface in load_errors"
);
}
#[test]
fn empty_mount_list_yields_empty_store() {
let engine = Engine::from_mounts(Vec::new()).unwrap();
assert!(engine.store().is_empty());
assert!(engine.schemas().is_empty());
assert!(engine.load_errors().is_empty());
}
#[test]
fn from_workspace_root_errors_for_empty_layout() {
let tmp = TempDir::new().unwrap();
let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
match err {
crate::BootError::NotInitialised(p) => {
assert_eq!(p, tmp.path());
}
other => panic!("expected NotInitialised, got {other:?}"),
}
}
#[test]
fn from_workspace_root_loads_new_two_layer_layout() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("mem");
std::fs::create_dir_all(&mem_dir).unwrap();
std::fs::write(
mem_dir.join("hello.md"),
"---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
)
.unwrap();
let memstead = tmp.path().join(".memstead");
std::fs::create_dir_all(&memstead).unwrap();
std::fs::write(
memstead.join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
use crate::workspace_store::WorkspaceStoreAdapter;
let store = crate::FileWorkspaceStore::new();
store
.save_state(
tmp.path(),
&crate::workspace::Workspace {
mounts: vec![folder_mount("specs", mem_dir)],
settings: crate::workspace::WorkspaceSettings::default(),
},
)
.unwrap();
let engine = Engine::from_workspace_root(tmp.path()).unwrap();
assert_eq!(engine.mem_names(), vec!["specs"]);
let entity = engine
.get_entity(&crate::EntityId::new("specs", "hello"))
.expect("seeded entity must load through from_workspace_root");
assert_eq!(entity.title, "Hello");
}
#[test]
fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
use crate::pipeline::{
Facet, Ingest, IngestMode, IngestTrigger, Medium, MediumType, Projection,
};
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("mem");
std::fs::create_dir_all(&mem_dir).unwrap();
let memstead = tmp.path().join(".memstead");
std::fs::create_dir_all(&memstead).unwrap();
std::fs::write(
memstead.join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
use crate::workspace_store::WorkspaceStoreAdapter;
crate::FileWorkspaceStore::new()
.save_state(
tmp.path(),
&crate::workspace::Workspace {
mounts: vec![folder_mount("specs", mem_dir)],
settings: crate::workspace::WorkspaceSettings::default(),
},
)
.unwrap();
crate::pipeline_store::write_medium(
tmp.path(),
"specs",
"src",
&Medium {
name: "src".to_string(),
medium_type: MediumType::Codebase,
pointer: "..".to_string(),
},
)
.unwrap();
crate::pipeline_store::write_facet(
tmp.path(),
"specs",
"view",
&Facet {
name: "view".to_string(),
medium: "src".to_string(),
scope: Vec::new(),
engagement: None,
preparation: None,
},
)
.unwrap();
crate::pipeline_store::write_projection(
tmp.path(),
"specs",
"graph",
&Projection {
intent: None,
source_facets: vec!["view".to_string()],
reference_mems: Vec::new(),
destination_mem: "specs".to_string(),
},
)
.unwrap();
crate::pipeline_store::write_ingest(
tmp.path(),
"specs-graph",
&Ingest {
projection: "specs/graph".to_string(),
mode: IngestMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 10,
deny_paths: Vec::new(),
},
)
.unwrap();
let engine = Engine::from_workspace_root(tmp.path()).unwrap();
let pc = engine.pipeline_configs();
assert_eq!(pc.mediums.len(), 1, "one medium enumerated");
assert_eq!(pc.mediums[0].config.medium_type, MediumType::Codebase);
assert_eq!(pc.facets.len(), 1, "one facet enumerated");
assert_eq!(pc.facets[0].config.medium, "src");
assert_eq!(pc.projections.len(), 1, "one projection enumerated");
assert_eq!(pc.projections[0].config.destination_mem, "specs");
assert_eq!(pc.ingests.len(), 1, "one ingest enumerated");
assert_eq!(pc.ingests[0].name, "specs-graph");
assert_eq!(pc.ingests[0].config.mode, IngestMode::Discovery);
}
#[test]
fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
use crate::pipeline::{Facet, Medium, MediumType};
use crate::workspace_store::WorkspaceStoreAdapter;
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("mem");
std::fs::create_dir_all(&mem_dir).unwrap();
let memstead = tmp.path().join(".memstead");
std::fs::create_dir_all(&memstead).unwrap();
std::fs::write(
memstead.join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
crate::FileWorkspaceStore::new()
.save_state(
tmp.path(),
&crate::workspace::Workspace {
mounts: vec![folder_mount("specs", mem_dir)],
settings: crate::workspace::WorkspaceSettings::default(),
},
)
.unwrap();
let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
assert!(engine.pipeline_configs().mediums.is_empty());
engine
.add_medium(
"specs",
"src",
&Medium {
name: "src".to_string(),
medium_type: MediumType::Codebase,
pointer: "..".to_string(),
},
)
.unwrap();
assert_eq!(engine.pipeline_configs().mediums.len(), 1);
assert_eq!(engine.pipeline_configs().mediums[0].name, "src");
engine
.add_facet(
"specs",
"view",
&Facet {
name: "view".to_string(),
medium: "src".to_string(),
scope: Vec::new(),
engagement: None,
preparation: None,
},
)
.unwrap();
let err = engine.delete_medium("specs", "src").unwrap_err();
assert!(
matches!(
err,
crate::pipeline_edit::PipelineEditError::Referenced { .. }
),
"got {err:?}"
);
assert_eq!(
engine.pipeline_configs().mediums.len(),
1,
"refused delete left the medium in place"
);
engine
.add_medium_json(
"specs",
"docs",
r#"{"name":"docs","type":"filesystem","pointer":"./docs"}"#,
)
.unwrap();
assert!(
engine
.pipeline_configs()
.mediums
.iter()
.any(|m| m.name == "docs" && m.config.medium_type == MediumType::Filesystem),
"add_medium_json should land a filesystem medium"
);
let err = engine
.add_medium_json("specs", "bad", "{ not json")
.unwrap_err();
assert!(
matches!(
err,
crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
),
"got {err:?}"
);
let json = engine.pipeline_configs_json();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let mediums = parsed["mediums"].as_array().unwrap();
assert_eq!(mediums.len(), 2, "src + docs");
assert!(
mediums
.iter()
.any(|m| m["name"] == "docs" && m["config"]["type"] == "filesystem"),
"pipeline_configs_json should carry the docs medium: {json}"
);
}
#[test]
fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
use crate::engine::test_helpers::write_schema_files_with_default_type;
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("mem");
std::fs::create_dir_all(&mem_dir).unwrap();
let authored_dir = tmp.path().join(".memstead").join("schemas");
let manifest = r#"name: authored
version: 0.1.0
description: an authored-in-workspace test schema
when_to_use: tests
types:
- doc
relationships:
mode: strict
definitions:
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
let memstead = tmp.path().join(".memstead");
std::fs::create_dir_all(&memstead).unwrap();
std::fs::write(
memstead.join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
let mount = Mount {
mem: "specs".to_string(),
schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
storage: MountStorage::Folder { path: mem_dir },
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
use crate::workspace_store::WorkspaceStoreAdapter;
crate::FileWorkspaceStore::new()
.save_state(
tmp.path(),
&crate::workspace::Workspace {
mounts: vec![mount],
settings: crate::workspace::WorkspaceSettings::default(),
},
)
.unwrap();
let engine = Engine::from_workspace_root(tmp.path())
.expect("authored schema at .memstead/schemas/ must resolve at boot");
assert_eq!(engine.mem_names(), vec!["specs"]);
}
#[test]
fn from_workspace_root_propagates_mem_management_settings() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("mem");
std::fs::create_dir_all(&mem_dir).unwrap();
let memstead = tmp.path().join(".memstead");
std::fs::create_dir_all(&memstead).unwrap();
std::fs::write(
memstead.join("workspace.toml"),
r#"format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[[mem_management.create]]
pattern = "exec-*"
schemas = ["default@1.0.0"]
[[mem_management.delete]]
pattern = "exec-*"
"#,
)
.unwrap();
use crate::workspace_store::WorkspaceStoreAdapter;
let store = crate::FileWorkspaceStore::new();
store
.save_state(
tmp.path(),
&crate::workspace::Workspace {
mounts: vec![folder_mount("specs", mem_dir)],
settings: crate::workspace::WorkspaceSettings::default(),
},
)
.unwrap();
let engine = Engine::from_workspace_root(tmp.path()).unwrap();
let s = engine.settings();
assert_eq!(s.mem_create_rules.len(), 1);
assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
assert_eq!(
s.mem_create_rules[0].schemas,
vec!["default@1.0.0".to_string()]
);
assert_eq!(s.mem_delete_rules.len(), 1);
assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
}
#[test]
fn from_mounts_rejects_unknown_schema_pin_with_typed_error() {
let tmp = TempDir::new().unwrap();
let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
let mount = Mount {
mem: "specs".to_string(),
schema: Some(SchemaRef::new(
"totally-not-a-schema",
semver::Version::new(1, 0, 0),
)),
storage: MountStorage::Folder {
path: tmp.path().to_path_buf(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let err = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
.unwrap_err();
match err {
EngineError::SchemaNotFound { mem, pin, sources } => {
assert_eq!(mem, "specs");
assert_eq!(pin, "totally-not-a-schema@1.0.0");
let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
assert_eq!(labels, ["local_storage", "builtin", "remote"]);
assert!(sources.iter().all(|s| !s.pinned_version_match));
assert_eq!(
sources
.iter()
.find(|s| s.source == "remote")
.unwrap()
.status,
Some("not_configured"),
);
}
other => panic!("expected SchemaNotFound, got {other:?}"),
}
}
#[test]
fn mem_config_schema_is_authoritative_over_mount_pin() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"schema":"software@0.1.0"}"#,
)
.unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mount = Mount {
mem: "specs".to_string(),
schema: Some(SchemaRef::new(
"totally-not-a-schema",
semver::Version::new(9, 9, 9),
)),
storage: MountStorage::Folder { path: mem_dir },
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let engine = Engine::from_mounts(vec![(
mount,
Box::new(writer) as Box<dyn MemBackend>,
)])
.expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
let mismatch = engine
.load_warnings()
.iter()
.find_map(|w| match w {
WarningHint::SchemaPinMismatch {
mem,
config_pin,
mount_pin,
} => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
_ => None,
})
.expect("SchemaPinMismatch warning must surface naming both pins");
assert_eq!(mismatch.0, "specs");
assert_eq!(mismatch.1, "software@0.1.0");
assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
}
#[test]
fn from_workspace_root_rejects_git_branch_mount_with_typed_error() {
let tmp = TempDir::new().unwrap();
let memstead = tmp.path().join(".memstead");
std::fs::create_dir_all(&memstead).unwrap();
std::fs::write(
memstead.join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
let state_dir = memstead.join("state");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(
state_dir.join("mounts.json"),
r#"{
"format": "memstead-mounts-3",
"mounts": [
{
"mem": "specs",
"schema": "default@1.0.0",
"storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
"capability": "write",
"lifecycle": "eager",
"cross_linkable": true
}
]
}"#,
)
.unwrap();
let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
match err {
crate::BootError::Instantiate(
crate::workspace_store::InstantiateError::GitBranchRequiresMemRepoFeature { mem },
) => {
assert_eq!(mem, "specs");
}
other => panic!("expected Instantiate(GitBranchRequiresMemRepoFeature), got {other:?}"),
}
}
#[test]
fn from_workspace_root_roots_standalone_folder_mem() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(
root.join(".memstead").join("config.json"),
r#"{"schema":"default@1.0.0"}"#,
)
.unwrap();
std::fs::write(
root.join("hello.md"),
"---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
)
.unwrap();
let engine = Engine::from_workspace_root(root)
.expect("a bare folder mem must root as a one-mount workspace");
assert_eq!(engine.stats().mem_count, 1, "exactly one mount");
assert!(
engine.stats().entity_count >= 1,
"the standalone mem's entity must load"
);
}
#[test]
fn from_workspace_root_still_rejects_truly_empty_dir() {
let tmp = TempDir::new().unwrap();
let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
assert!(
matches!(err, crate::BootError::NotInitialised(_)),
"got {err:?}"
);
}
}