use std::path::Path;
use memstead_base::{
BootError, Engine, FileWorkspaceStore, MemBackend, Mount, WorkspaceStoreAdapter, detect_layout,
};
type MountedBackend = (Mount, Box<dyn MemBackend>);
struct LegacyReadMemMigration {
mounts: Vec<MountedBackend>,
migrated_mems: Vec<String>,
from_host_mems: Vec<String>,
}
fn migrate_legacy_read_mems(
writable_mounts: &[MountedBackend],
writable_names: &std::collections::HashSet<String>,
already_mounted: &std::collections::HashSet<String>,
) -> Result<LegacyReadMemMigration, BootError> {
let cache_dir = crate::mem_cache::mem_cache_dir();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut extras: Vec<MountedBackend> = Vec::new();
let mut migrated_mems: Vec<String> = Vec::new();
let mut from_host_mems: Vec<String> = Vec::new();
for (host_mount, backend) in writable_mounts {
let bytes = match backend.read_mem_config() {
Ok(Some(b)) => b,
_ => continue,
};
let value: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => continue,
};
let mut config = match memstead_schema::config::parse_mem_config(&value) {
Ok(c) => c,
Err(_) => continue,
};
if config.read_mems.is_empty() {
continue;
}
let mut retained: std::collections::BTreeMap<String, memstead_schema::config::ReadMemSpec> =
Default::default();
let mut host_migrated_any = false;
for (mem_name, spec) in &config.read_mems {
if writable_names.contains(mem_name) {
host_migrated_any = true;
continue;
}
if already_mounted.contains(mem_name) || !seen.insert(mem_name.clone()) {
host_migrated_any = true;
if !migrated_mems.contains(mem_name) {
migrated_mems.push(mem_name.clone());
}
continue;
}
let stem = match spec.cache_key.as_deref() {
Some(key) => format!("{mem_name}-{key}"),
None => mem_name.clone(),
};
let archive_path = std::iter::once(memstead_schema::ARCHIVE_EXTENSION)
.map(|ext| cache_dir.join(format!("{stem}.{ext}")))
.find(|p| p.is_file());
let Some(archive_path) = archive_path else {
retained.insert(mem_name.clone(), spec.clone());
continue;
};
let archive_schema = crate::mem_cache::read_published_config(&archive_path)
.map(|cfg| cfg.schema)
.unwrap_or_else(|_| {
memstead_schema::SchemaRef::new("default", semver::Version::new(1, 0, 0))
});
let read_mount = Mount {
mem: mem_name.clone(),
schema: Some(archive_schema),
storage: memstead_base::MountStorage::Archive {
path: archive_path.clone(),
},
capability: memstead_base::MountCapability::ReadOnly,
lifecycle: memstead_base::MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
};
let ro_backend: Box<dyn MemBackend> =
Box::new(memstead_base::storage::ArchiveBackend::new(archive_path));
extras.push((read_mount, ro_backend));
migrated_mems.push(mem_name.clone());
host_migrated_any = true;
}
if host_migrated_any {
config.read_mems = retained;
if let Ok(mut out) = serde_json::to_vec_pretty(&config) {
out.push(b'\n');
if let Err(e) = backend.write_mem_config(&out) {
tracing::warn!(
mem = %host_mount.mem,
error = %e,
"readMems migration: mounts were created but the legacy \
key could not be removed from the host config — the \
migration warning will repeat next boot"
);
}
}
from_host_mems.push(host_mount.mem.clone());
}
}
Ok(LegacyReadMemMigration {
mounts: extras,
migrated_mems,
from_host_mems,
})
}
pub(crate) fn load_workspace_description(
workspace_root: &Path,
) -> Result<memstead_base::Workspace, BootError> {
match detect_layout(workspace_root) {
memstead_base::Layout::Empty => match memstead_base::standalone_workspace(workspace_root) {
Some(ws) => Ok(ws),
None => Err(BootError::NotInitialised(workspace_root.to_path_buf())),
},
memstead_base::Layout::New => Ok(FileWorkspaceStore::new().load(workspace_root)?),
}
}
pub fn engine_from_workspace_root(workspace_root: &Path) -> Result<Engine, BootError> {
let workspace = load_workspace_description(workspace_root)?;
let settings = workspace.settings.clone();
let writable_names: std::collections::HashSet<String> = workspace
.mounts
.iter()
.filter(|m| m.capability == memstead_base::MountCapability::Write)
.map(|m| m.mem.clone())
.collect();
let all_mounted_names: std::collections::HashSet<String> =
workspace.mounts.iter().map(|m| m.mem.clone()).collect();
let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> = Vec::with_capacity(workspace.mounts.len());
let mut instantiate_quarantine: Vec<memstead_base::engine::QuarantinedMem> = Vec::new();
for mount in workspace.mounts {
match crate::storage::instantiate_full_backend(&mount) {
Ok(backend) => mounts.push((mount, backend)),
Err(e) => instantiate_quarantine.push(memstead_base::engine::QuarantinedMem {
reason_code: e.code().to_string(),
reason_message: e.to_string(),
mount,
}),
}
}
let migration = migrate_legacy_read_mems(&mounts, &writable_names, &all_mounted_names)?;
let migration_happened = !migration.migrated_mems.is_empty();
if !migration.mounts.is_empty() {
mounts.extend(migration.mounts);
let persisted = memstead_base::Workspace {
mounts: mounts.iter().map(|(m, _)| m.clone()).collect(),
settings: settings.clone(),
};
use memstead_base::workspace_store::WorkspaceStoreAdapter as _;
if let Err(e) = FileWorkspaceStore::new().save_state(workspace_root, &persisted) {
tracing::warn!(
error = %e,
"readMems migration: mount-state persistence failed — the \
migrated mounts serve this boot but the next boot repeats \
the migration"
);
}
}
use memstead_base::schema_source::SchemaSource as _;
let ref_schemas =
match crate::mem_repo_schemas::GitBranchSchemaSource::for_workspace(workspace_root)
.read_schemas()
{
Ok(schemas) => schemas,
Err(e) => {
tracing::warn!(
"could not read schemas from `__MEMSTEAD:schemas/` ref at {}: {e}; \
resolving against built-ins only",
workspace_root.display()
);
Vec::new()
}
};
let fixed_schemas_dir = workspace_root.join(".memstead").join("schemas");
let mut engine = Engine::from_mounts_with_schemas_dir_and_extra(
mounts,
Some(fixed_schemas_dir.as_path()),
ref_schemas,
)
.map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
engine.extend_quarantine(instantiate_quarantine);
engine.set_settings(settings);
engine.set_workspace_root(workspace_root.to_path_buf());
engine.set_backend_factory(crate::storage::instantiate_full_backend);
engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
engine.set_pipeline_configs(memstead_base::load_pipeline_configs(workspace_root)?);
if migration_happened {
engine.push_load_warning(memstead_base::ops::WarningHint::ReadMemsMigratedToMounts {
mems: migration.migrated_mems,
from_host_mems: migration.from_host_mems,
});
}
let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
Ok(engine)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn engine_from_workspace_root_errors_for_empty_layout() {
let tmp = TempDir::new().unwrap();
let err = engine_from_workspace_root(tmp.path()).unwrap_err();
assert!(matches!(err, BootError::NotInitialised(_)));
}
#[test]
fn engine_from_workspace_root_overlays_ref_schemas() {
use memstead_base::schema_source::SchemaSource as _;
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("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
let gitdir = root.join("mem-repo").join(".git");
std::fs::create_dir_all(&gitdir).unwrap();
gix::init_bare(&gitdir).unwrap();
let manifest = br#"name: refsrc
version: 0.1.0
description: A ref-installed (non-built-in) 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
"#;
let doc = br#"name: doc
description: t
when_to_use: here
sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
write_rules: []
metadata_fields: []
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: _default
no_self_loop_relationships: []
updatable_fields:
- title
- body
health_required_fields:
- body
staleness_threshold_days: 90
write_rules: []
"#;
crate::mem_repo_schemas::GitBranchSchemaSource::for_workspace(root)
.write_schema(
"refsrc",
"0.1.0",
&[
("schema.yaml".to_string(), manifest.to_vec()),
("types/doc.yaml".to_string(), doc.to_vec()),
],
)
.unwrap();
let engine = engine_from_workspace_root(root).unwrap();
assert!(
engine
.workspace_schemas()
.iter()
.any(|s| s.manifest.name == "refsrc"),
"ref-installed schema must overlay into the catalogue: {:?}",
engine
.workspace_schemas()
.iter()
.map(|s| s.manifest.name.clone())
.collect::<Vec<_>>()
);
}
}