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);
{
let gitdir = workspace_root.join("mem-repo").join(".git");
let gitdir = gitdir.canonicalize().unwrap_or(gitdir);
engine.set_unmounted_storage_prober(Box::new(move |mem: &str| {
if !gitdir.is_dir() {
return None;
}
let branch_path =
crate::mem_repo_config::resolve_full_path_at_gitdir(&gitdir, mem).ok()??;
let backend = crate::storage::git_tree::GitTreeMemWriter::new(
gitdir.clone(),
format!("refs/heads/{branch_path}"),
);
let schema = memstead_base::MemBackend::read_mem_config(&backend)
.ok()
.flatten()
.and_then(|bytes| {
serde_json::from_slice::<memstead_schema::config::MemConfig>(&bytes).ok()
})
.and_then(|cfg| cfg.schema);
Some(memstead_base::engine::UnmountedMemStorage {
backend: Box::new(backend),
schema,
})
}));
}
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 relate_into_unmounted_mem_verifies_against_branch_tree() {
use memstead_base::engine::RelateEntityArgs;
use memstead_base::ops::WarningHint;
use memstead_base::storage::MemWriter;
use memstead_base::vcs::{Actor, ClientId, CommitContext};
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
std::fs::write(
root.join(".memstead").join("workspace.toml"),
concat!(
"format = \"memstead-git-branch-2\"\n\n",
"[persistence_adapter]\nname = \"file-two-layer\"\n\n",
"[cross_mem_links]\nsrc = [\"far\", \"nowhere\"]\n",
),
)
.unwrap();
let gitdir = root.join("mem-repo").join(".git");
std::fs::create_dir_all(&gitdir).unwrap();
gix::init_bare(&gitdir).unwrap();
let gitdir = std::fs::canonicalize(&gitdir).unwrap();
let src_dir = root.join("src-mem");
std::fs::create_dir_all(src_dir.join(".memstead")).unwrap();
std::fs::write(
src_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0"}"#,
)
.unwrap();
std::fs::write(
src_dir.join("alpha.md"),
"---
type: spec
---
# Alpha
## Identity
Body.
",
)
.unwrap();
std::fs::write(
root.join(".memstead").join("state").join("mounts.json"),
format!(
r#"{{"format":"memstead-mounts-3","mounts":[{{"mem":"src","schema":"default@1.0.0","storage":{{"type":"folder","path":{}}},"capability":"write","lifecycle":"eager","cross_linkable":true}}]}}"#,
serde_json::to_string(&src_dir).unwrap()
),
)
.unwrap();
let far = crate::storage::git_tree::GitTreeMemWriter::new(
gitdir.clone(),
"refs/heads/far".to_string(),
);
MemWriter::write_entity(
&far,
std::path::Path::new("topic.md"),
b"---
type: spec
---
# Topic
## Identity
Body.
",
)
.unwrap();
MemWriter::commit(
&far,
"seed far",
&CommitContext {
actor: Actor::Cli,
client: Some(ClientId {
name: "test".to_string(),
version: "0".to_string(),
}),
tool: Some("test"),
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
},
)
.unwrap();
let mut engine = engine_from_workspace_root(root).unwrap();
let relate = |engine: &mut Engine, mem: &str, to: &str| {
engine.relate_entity(
RelateEntityArgs {
source: memstead_base::EntityId::new("src", "alpha"),
expected_hash: None,
rel_type: "SUPPORTS".to_string(),
target: memstead_base::EntityId::new(mem, to),
remove: false,
description: None,
dry_run: false,
},
Actor::Cli,
None,
None,
)
};
let outcome =
relate(&mut engine, "far", "topic").expect("verified unmounted target admits");
assert!(
!outcome.warnings.iter().any(|w| matches!(
w,
WarningHint::AutoStubCreated { .. }
| WarningHint::CrossMemTargetMemUncreated { .. }
)),
"a branch-verified target is neither an auto-stub case nor an uncreated mem: {:?}",
outcome.warnings
);
let stub = engine
.store()
.get(&memstead_base::EntityId::new("far", "topic"))
.expect("until-load stub present");
assert_eq!(
stub.stub_kind,
Some(memstead_base::entity::StubKind::LoadTime)
);
assert!(
engine.mount("far").is_none(),
"verification never adds a mount — the twenty-mems case pays a tree lookup, not a workspace-shape change"
);
let outcome = relate(&mut engine, "far", "missing").expect("absent target auto-stubs");
assert!(
outcome
.warnings
.iter()
.any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
"absent target keeps the auto-stub warning: {:?}",
outcome.warnings
);
let outcome =
relate(&mut engine, "nowhere", "thing").expect("undiscoverable mem auto-stubs");
assert!(
outcome
.warnings
.iter()
.any(|w| matches!(w, WarningHint::AutoStubCreated { .. }))
&& outcome
.warnings
.iter()
.any(|w| matches!(w, WarningHint::CrossMemTargetMemUncreated { .. })),
"no discoverable storage keeps today's stub + warnings: {:?}",
outcome.warnings
);
}
#[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<_>>()
);
}
}