use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::backend::MemBackend;
use crate::storage::{ArchiveBackend, FilesystemMemWriter, InMemoryBackend};
use crate::workspace::{
McpSection, Mount, MountCapability, MountLifecycle, MountStorage, MutationsSection, Workspace,
WorkspaceSettings,
};
pub const WORKSPACE_STORE_DIR: &str = ".memstead";
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("workspace store not found at {path} — run `memstead mem-repo init` first")]
NotInitialised { path: PathBuf },
#[error(
"workspace store io error at {path}: {source} — no memstead command repairs this; \
check filesystem permissions and disk state"
)]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"workspace store parse error at {path}: {message} — no memstead command repairs this; \
fix the named file by hand or restore it from version control"
)]
Parse { path: PathBuf, message: String },
#[error(
"workspace store format mismatch at {path}: expected {expected}, found {found} — \
no memstead command repairs this; use an engine version whose format matches the file"
)]
FormatMismatch {
path: PathBuf,
expected: String,
found: String,
},
#[error(
"pre-rename workspace layout at {path} (found format {found}): migrate the workspace \
state in place — rewrite mounts.json to memstead-mounts-3 (record field `mem`, storage \
paths under mem-repo/), workspace.toml to memstead-git-branch-2 (tables `mem_management`, \
`cross_mem_links`), rename the gitdir container to mem-repo/, and move the metadata \
branch tree to mems/ — then retry"
)]
LegacyLayout { path: PathBuf, found: String },
#[error(
"legacy (pre-v2) projection config at {path}: this workspace predates the single-record \
binding format v2 — run `memstead projection migrate` to convert it in place once"
)]
LegacyProjectionStore { path: PathBuf },
#[error(
"unsupported binding format version {version} at {path}: this engine understands v2 (version 2)"
)]
UnknownBindingVersion { path: PathBuf, version: i64 },
#[error("workspace store error: {0}")]
Other(String),
}
impl StoreError {
pub fn code(&self) -> &'static str {
match self {
StoreError::NotInitialised { .. } => "WORKSPACE_NOT_INITIALISED",
StoreError::Io { .. } => "WORKSPACE_STORE_IO",
StoreError::Parse { .. } => "WORKSPACE_STORE_PARSE",
StoreError::FormatMismatch { .. } => "WORKSPACE_STORE_FORMAT_MISMATCH",
StoreError::LegacyLayout { .. } => "LEGACY_WORKSPACE_LAYOUT",
StoreError::LegacyProjectionStore { .. } => "PROJECTION_STORE_LEGACY",
StoreError::UnknownBindingVersion { .. } => "UNKNOWN_BINDING_VERSION",
StoreError::Other(_) => "WORKSPACE_STORE_ERROR",
}
}
}
pub trait WorkspaceStoreAdapter: Send + Sync {
fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError>;
fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct FileWorkspaceStore;
impl FileWorkspaceStore {
pub fn new() -> Self {
Self
}
pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
workspace_root
.join(WORKSPACE_STORE_DIR)
.join("workspace.toml")
}
pub fn mounts_json_path(workspace_root: &Path) -> PathBuf {
workspace_root
.join(WORKSPACE_STORE_DIR)
.join("state")
.join("mounts.json")
}
}
const WORKSPACE_TOML_FORMAT: &str = "memstead-git-branch-2";
const WORKSPACE_TOML_FORMAT_LEGACY: &str = "memstead-git-branch-1";
const MOUNTS_JSON_FORMAT_V3: &str = "memstead-mounts-3";
const MOUNTS_JSON_FORMAT_LEGACY: [&str; 2] = ["memstead-mounts-1", "memstead-mounts-2"];
#[derive(Deserialize)]
struct MountsFormatProbe {
format: String,
}
fn check_workspace_toml_format(format: &str, toml_path: &Path) -> Result<(), StoreError> {
if format == WORKSPACE_TOML_FORMAT {
return Ok(());
}
if format == WORKSPACE_TOML_FORMAT_LEGACY {
return Err(StoreError::LegacyLayout {
path: toml_path.to_path_buf(),
found: format.to_string(),
});
}
Err(StoreError::FormatMismatch {
path: toml_path.to_path_buf(),
expected: WORKSPACE_TOML_FORMAT.to_string(),
found: format.to_string(),
})
}
fn absolutize_mount_path(value: PathBuf, workspace_root: &Path) -> PathBuf {
if value.is_absolute() {
value
} else {
workspace_root.join(value)
}
}
fn relativize_mount_path(value: &Path, workspace_root: &Path) -> PathBuf {
match value.strip_prefix(workspace_root) {
Ok(rel) => rel.to_path_buf(),
Err(_) => value.to_path_buf(),
}
}
pub fn is_workspace_root(dir: &Path) -> bool {
FileWorkspaceStore::workspace_toml_path(dir).is_file()
}
impl WorkspaceStoreAdapter for FileWorkspaceStore {
fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError> {
let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
if !memstead_dir.is_dir() {
return Err(StoreError::NotInitialised {
path: workspace_root.to_path_buf(),
});
}
let toml_path = Self::workspace_toml_path(workspace_root);
let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
StoreError::NotInitialised {
path: workspace_root.to_path_buf(),
}
} else {
StoreError::Io {
path: toml_path.clone(),
source: e,
}
}
})?;
let toml_doc: WorkspaceTomlDoc =
toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
path: toml_path.clone(),
message: e.to_string(),
})?;
check_workspace_toml_format(&toml_doc.format, &toml_path)?;
let mounts_path = Self::mounts_json_path(workspace_root);
let mounts: Vec<Mount> = match std::fs::read_to_string(&mounts_path) {
Ok(text) => {
let probe: MountsFormatProbe =
serde_json::from_str(&text).map_err(|e| StoreError::Parse {
path: mounts_path.clone(),
message: e.to_string(),
})?;
if MOUNTS_JSON_FORMAT_LEGACY.contains(&probe.format.as_str()) {
return Err(StoreError::LegacyLayout {
path: mounts_path,
found: probe.format,
});
}
if probe.format != MOUNTS_JSON_FORMAT_V3 {
return Err(StoreError::FormatMismatch {
path: mounts_path,
expected: MOUNTS_JSON_FORMAT_V3.to_string(),
found: probe.format,
});
}
let doc: MountsJsonDoc =
serde_json::from_str(&text).map_err(|e| StoreError::Parse {
path: mounts_path.clone(),
message: e.to_string(),
})?;
doc.mounts
.into_iter()
.map(|w| w.into_mount(workspace_root))
.collect()
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
return Err(StoreError::Io {
path: mounts_path,
source: e,
});
}
};
warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
let settings = build_settings(
toml_doc.mem_management,
toml_doc.cross_mem_links,
toml_doc.mcp,
toml_doc.mutations,
toml_doc.plugin,
)?;
Ok(Workspace { mounts, settings })
}
fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError> {
let mounts_path = Self::mounts_json_path(workspace_root);
if let Some(parent) = mounts_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let doc = MountsJsonDoc {
format: MOUNTS_JSON_FORMAT_V3.to_string(),
mounts: workspace
.mounts
.iter()
.map(|m| MountWire::from_mount(m, workspace_root))
.collect(),
};
let text = serde_json::to_string_pretty(&doc).map_err(|e| StoreError::Parse {
path: mounts_path.clone(),
message: e.to_string(),
})?;
std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
path: mounts_path,
source: e,
})?;
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceTomlDoc {
format: String,
#[serde(default)]
persistence_adapter: PersistenceAdapterDecl,
#[serde(default)]
mem_management: MemManagementWire,
#[serde(default)]
cross_mem_links: toml::Table,
#[serde(default)]
schemas_dir: Option<std::path::PathBuf>,
#[serde(default)]
mcp: McpSection,
#[serde(default)]
mutations: MutationsSection,
#[serde(default)]
plugin: std::collections::HashMap<String, toml::Table>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct MemManagementWire {
#[serde(default)]
create: Vec<CreateRuleWire>,
#[serde(default)]
delete: Vec<DeleteRuleWire>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CreateRuleWire {
pattern: String,
#[serde(default)]
schemas: Vec<String>,
#[serde(default)]
default_cross_links: Option<toml::Value>,
}
#[derive(Debug, Serialize, Deserialize)]
struct DeleteRuleWire {
pattern: String,
}
pub fn parse_workspace_settings(
workspace_root: &Path,
) -> Result<crate::workspace::WorkspaceSettings, StoreError> {
let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
if !memstead_dir.is_dir() {
return Err(StoreError::NotInitialised {
path: workspace_root.to_path_buf(),
});
}
let toml_path = FileWorkspaceStore::workspace_toml_path(workspace_root);
let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
StoreError::NotInitialised {
path: workspace_root.to_path_buf(),
}
} else {
StoreError::Io {
path: toml_path.clone(),
source: e,
}
}
})?;
let toml_doc: WorkspaceTomlDoc = toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
path: toml_path.clone(),
message: e.to_string(),
})?;
check_workspace_toml_format(&toml_doc.format, &toml_path)?;
warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
build_settings(
toml_doc.mem_management,
toml_doc.cross_mem_links,
toml_doc.mcp,
toml_doc.mutations,
toml_doc.plugin,
)
}
fn build_settings(
vm: MemManagementWire,
cross_mem_links_raw: toml::Table,
mcp: McpSection,
mutations: MutationsSection,
plugin: std::collections::HashMap<String, toml::Table>,
) -> Result<WorkspaceSettings, StoreError> {
let mut create_rules = Vec::with_capacity(vm.create.len());
for r in vm.create {
let default_cross_links = match r.default_cross_links {
None => None,
Some(value) => {
let location = format!(
"[[mem_management.create]] pattern={}.default_cross_links",
r.pattern
);
Some(parse_cross_link_value(&location, &value)?)
}
};
create_rules.push(crate::workspace::CreateRuleSetting {
pattern: r.pattern,
schemas: r.schemas,
default_cross_links,
});
}
let mut cross_mem_links = std::collections::BTreeMap::new();
for (mem, value) in &cross_mem_links_raw {
let location = format!("[cross_mem_links].{mem}");
let parsed = parse_cross_link_value(&location, value)?;
cross_mem_links.insert(mem.clone(), parsed);
}
Ok(WorkspaceSettings {
mem_create_rules: create_rules,
mem_delete_rules: vm
.delete
.into_iter()
.map(|r| crate::workspace::DeleteRuleSetting { pattern: r.pattern })
.collect(),
cross_mem_links,
mcp,
mutations,
plugin,
})
}
fn warn_if_legacy_schemas_dir(schemas_dir: Option<&std::path::Path>) {
if let Some(dir) = schemas_dir {
tracing::warn!(
"`schemas_dir` (= {:?}) in workspace.toml is retired and ignored — \
authored schemas are read from the fixed `<workspace>/.memstead/schemas/`. \
Remove the key to silence this warning.",
dir
);
}
}
fn parse_cross_link_value(
location: &str,
value: &toml::Value,
) -> Result<memstead_schema::workspace_config::CrossLinkValue, StoreError> {
memstead_schema::workspace_config::CrossLinkValue::parse_toml(location, value).map_err(|e| {
StoreError::Parse {
path: std::path::PathBuf::from("workspace.toml"),
message: e.to_string(),
}
})
}
#[derive(Debug, Serialize, Deserialize)]
struct PersistenceAdapterDecl {
name: String,
}
impl Default for PersistenceAdapterDecl {
fn default() -> Self {
Self {
name: "file-two-layer".to_string(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct MountsJsonDoc {
format: String,
mounts: Vec<MountWire>,
}
#[derive(Debug, Serialize, Deserialize)]
struct MountWire {
mem: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
schema: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
migration_target: Option<String>,
storage: MountStorageWire,
capability: CapabilityWire,
lifecycle: LifecycleWire,
cross_linkable: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum MountStorageWire {
Folder {
path: PathBuf,
},
GitBranch {
gitdir: PathBuf,
branch: String,
},
Archive {
path: PathBuf,
},
InMemory,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum CapabilityWire {
ReadOnly,
Write,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum LifecycleWire {
Eager,
Lazy,
}
impl MountWire {
fn from_mount(m: &Mount, workspace_root: &Path) -> Self {
Self {
mem: m.mem.clone(),
schema: m.schema.as_ref().map(|s| s.to_string()),
migration_target: m.migration_target.as_ref().map(|t| t.to_string()),
storage: match &m.storage {
MountStorage::Folder { path } => MountStorageWire::Folder {
path: relativize_mount_path(path, workspace_root),
},
MountStorage::GitBranch { gitdir, branch } => MountStorageWire::GitBranch {
gitdir: relativize_mount_path(gitdir, workspace_root),
branch: branch.clone(),
},
MountStorage::Archive { path } => MountStorageWire::Archive {
path: relativize_mount_path(path, workspace_root),
},
MountStorage::InMemory => MountStorageWire::InMemory,
},
capability: match m.capability {
MountCapability::ReadOnly => CapabilityWire::ReadOnly,
MountCapability::Write => CapabilityWire::Write,
},
lifecycle: match m.lifecycle {
MountLifecycle::Eager => LifecycleWire::Eager,
MountLifecycle::Lazy => LifecycleWire::Lazy,
},
cross_linkable: m.cross_linkable,
}
}
fn into_mount(self, workspace_root: &Path) -> Mount {
Mount {
mem: self.mem,
schema: self.schema.map(|s| {
s.parse()
.expect("schema pin on disk must be `<name>@<version>`")
}),
migration_target: self.migration_target.map(|t| {
t.parse()
.expect("migration_target on disk must be `<name>@<version>`")
}),
storage: match self.storage {
MountStorageWire::Folder { path } => MountStorage::Folder {
path: absolutize_mount_path(path, workspace_root),
},
MountStorageWire::GitBranch { gitdir, branch } => MountStorage::GitBranch {
gitdir: absolutize_mount_path(gitdir, workspace_root),
branch,
},
MountStorageWire::Archive { path } => MountStorage::Archive {
path: absolutize_mount_path(path, workspace_root),
},
MountStorageWire::InMemory => MountStorage::InMemory,
},
capability: match self.capability {
CapabilityWire::ReadOnly => MountCapability::ReadOnly,
CapabilityWire::Write => MountCapability::Write,
},
lifecycle: match self.lifecycle {
LifecycleWire::Eager => MountLifecycle::Eager,
LifecycleWire::Lazy => MountLifecycle::Lazy,
},
cross_linkable: self.cross_linkable,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum InstantiateError {
#[error(
"mem {mem}: git-branch backend requires the `mem-repo` feature; \
use `instantiate_full_backend` from memstead-git-branch, or rebuild with --features mem-repo"
)]
GitBranchRequiresMemRepoFeature { mem: String },
}
impl InstantiateError {
pub fn code(&self) -> &'static str {
match self {
InstantiateError::GitBranchRequiresMemRepoFeature { .. } => {
"UNSUPPORTED_WORKSPACE_SHAPE"
}
}
}
}
pub fn instantiate_lean_backend(mount: &Mount) -> Result<Box<dyn MemBackend>, InstantiateError> {
match &mount.storage {
MountStorage::Folder { path } => Ok(Box::new(FilesystemMemWriter::new(path.clone()))),
MountStorage::Archive { path } => Ok(Box::new(ArchiveBackend::new(path.clone()))),
MountStorage::InMemory => Ok(Box::new(InMemoryBackend::new())),
MountStorage::GitBranch { .. } => Err(InstantiateError::GitBranchRequiresMemRepoFeature {
mem: mount.mem.clone(),
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layout {
Empty,
New,
}
pub fn detect_layout(workspace_root: &Path) -> Layout {
if is_workspace_root(workspace_root) {
Layout::New
} else {
Layout::Empty
}
}
pub fn standalone_workspace(workspace_root: &Path) -> Option<Workspace> {
let config = memstead_schema::config::load_and_validate(workspace_root).ok()?;
let schema = config.schema.clone()?;
let name = config.name.clone().unwrap_or_else(|| {
workspace_root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "mem".to_string())
});
let mount = Mount {
mem: name,
schema: Some(schema),
storage: MountStorage::Folder {
path: workspace_root.to_path_buf(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
};
Some(Workspace {
mounts: vec![mount],
settings: WorkspaceSettings::default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use memstead_schema::SchemaRef;
use std::io::Write as _;
use tempfile::TempDir;
fn pin(s: &str) -> SchemaRef {
s.parse().unwrap()
}
fn folder_mount(mem: &str, path: PathBuf) -> Mount {
Mount {
mem: mem.to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::Folder { path },
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
}
}
fn write_workspace_toml(workspace_root: &Path, body: &str) {
let path = FileWorkspaceStore::workspace_toml_path(workspace_root);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}
#[test]
fn load_returns_not_initialised_when_memstead_dir_absent() {
let tmp = TempDir::new().unwrap();
let store = FileWorkspaceStore::new();
let err = store.load(tmp.path()).unwrap_err();
assert!(matches!(err, StoreError::NotInitialised { .. }));
}
#[test]
fn parse_workspace_settings_reflects_cross_mem_links_edit() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[cross_mem_links]
team-a = ["team-b"]
"#,
);
let settings = super::parse_workspace_settings(tmp.path()).unwrap();
assert!(
settings.cross_mem_links.contains_key("team-a"),
"initial parse must surface the team-a grant; got {:?}",
settings.cross_mem_links
);
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[cross_mem_links]
"#,
);
let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
assert!(
refreshed.cross_mem_links.is_empty(),
"refreshed parse must drop the team-a grant; got {:?}",
refreshed.cross_mem_links
);
}
#[test]
fn parse_workspace_settings_reflects_allowlist_edit() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let initial = super::parse_workspace_settings(tmp.path()).unwrap();
assert!(initial.mem_create_rules.is_empty());
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[[mem_management.create]]
pattern = "test-*"
schemas = ["default@1.0.0"]
"#,
);
let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
assert_eq!(refreshed.mem_create_rules.len(), 1);
assert_eq!(refreshed.mem_create_rules[0].pattern, "test-*");
}
#[test]
fn load_returns_not_initialised_when_workspace_toml_missing() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
let store = FileWorkspaceStore::new();
let err = store.load(tmp.path()).unwrap_err();
assert!(matches!(err, StoreError::NotInitialised { .. }));
}
#[test]
fn load_with_no_mounts_yields_empty_mount_list() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let store = FileWorkspaceStore::new();
let workspace = store.load(tmp.path()).unwrap();
assert!(workspace.mounts.is_empty());
}
#[test]
fn load_with_no_mem_management_yields_empty_settings() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let store = FileWorkspaceStore::new();
let workspace = store.load(tmp.path()).unwrap();
assert!(workspace.settings.mem_create_rules.is_empty());
assert!(workspace.settings.mem_delete_rules.is_empty());
assert!(workspace.settings.cross_mem_links.is_empty());
}
#[test]
fn load_picks_up_cross_mem_links_wildcard_and_list() {
use memstead_schema::workspace_config::CrossLinkValue;
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[cross_mem_links]
specs = "*"
engine = ["specs", "macos"]
locked = []
"#,
);
let store = FileWorkspaceStore::new();
let workspace = store.load(tmp.path()).unwrap();
let cvl = &workspace.settings.cross_mem_links;
assert_eq!(cvl.len(), 3);
assert_eq!(cvl.get("specs"), Some(&CrossLinkValue::Wildcard));
assert_eq!(
cvl.get("engine"),
Some(&CrossLinkValue::List(vec![
"specs".to_string(),
"macos".to_string()
]))
);
assert_eq!(cvl.get("locked"), Some(&CrossLinkValue::List(vec![])));
}
#[test]
fn load_rejects_cross_mem_links_mixed_wildcard_and_names() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[cross_mem_links]
specs = ["*", "engine"]
"#,
);
let store = FileWorkspaceStore::new();
let err = store.load(tmp.path()).unwrap_err();
match err {
StoreError::Parse { message, .. } => {
assert!(message.contains("[cross_mem_links].specs"));
assert!(message.contains("wildcard"));
}
other => panic!("expected StoreError::Parse, got {other:?}"),
}
}
#[test]
fn load_picks_up_default_cross_links_on_create_rule() {
use memstead_schema::workspace_config::CrossLinkValue;
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[[mem_management.create]]
pattern = "exec-*"
schemas = ["default"]
default_cross_links = "*"
"#,
);
let store = FileWorkspaceStore::new();
let workspace = store.load(tmp.path()).unwrap();
let rule = &workspace.settings.mem_create_rules[0];
assert_eq!(rule.pattern, "exec-*");
assert_eq!(rule.default_cross_links, Some(CrossLinkValue::Wildcard));
}
#[test]
fn load_picks_up_mem_management_create_and_delete_rules() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[[mem_management.create]]
pattern = "exec-*"
schemas = ["default@1.0.0", "*"]
[[mem_management.create]]
pattern = "scratch-*"
schemas = ["default"]
[[mem_management.delete]]
pattern = "exec-*"
"#,
);
let store = FileWorkspaceStore::new();
let workspace = store.load(tmp.path()).unwrap();
assert_eq!(workspace.settings.mem_create_rules.len(), 2);
assert_eq!(workspace.settings.mem_create_rules[0].pattern, "exec-*");
assert_eq!(
workspace.settings.mem_create_rules[0].schemas,
vec!["default@1.0.0".to_string(), "*".to_string()]
);
assert_eq!(workspace.settings.mem_create_rules[1].pattern, "scratch-*");
assert_eq!(workspace.settings.mem_delete_rules.len(), 1);
assert_eq!(workspace.settings.mem_delete_rules[0].pattern, "exec-*");
}
#[test]
fn save_state_round_trips_migration_target() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
"\nformat = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
);
let store = FileWorkspaceStore::new();
let mut migrating = folder_mount("specs", PathBuf::from("/work/mem"));
migrating.migration_target = Some(pin("mig-b@0.1.0"));
let settled = folder_mount("other", PathBuf::from("/work/other"));
let original = Workspace {
mounts: vec![migrating, settled],
settings: WorkspaceSettings::default(),
};
store.save_state(tmp.path(), &original).unwrap();
let raw =
std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
assert!(
raw.contains("mig-b@0.1.0"),
"migration_target must persist: {raw}"
);
assert_eq!(
raw.matches("migration_target").count(),
1,
"settled mounts must omit the key entirely: {raw}"
);
let loaded = store.load(tmp.path()).unwrap();
assert_eq!(loaded.mounts[0].migration_target, Some(pin("mig-b@0.1.0")));
assert_eq!(loaded.mounts[1].migration_target, None);
}
#[test]
fn save_state_then_load_round_trips_mount_list() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let store = FileWorkspaceStore::new();
let original = Workspace {
mounts: vec![
folder_mount("specs", PathBuf::from("/work/mem")),
Mount {
mem: "engine".to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::GitBranch {
gitdir: PathBuf::from("/work/mem-repo/.git"),
branch: "engine".to_string(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
},
Mount {
mem: "external".to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::Archive {
path: PathBuf::from("/deps/external.mem"),
},
capability: MountCapability::ReadOnly,
lifecycle: MountLifecycle::Lazy,
cross_linkable: false,
migration_target: None,
},
],
settings: WorkspaceSettings::default(),
};
store.save_state(tmp.path(), &original).unwrap();
assert!(FileWorkspaceStore::mounts_json_path(tmp.path()).is_file());
let reloaded = store.load(tmp.path()).unwrap();
assert_eq!(reloaded.mounts.len(), original.mounts.len());
for (a, b) in reloaded.mounts.iter().zip(original.mounts.iter()) {
assert_eq!(a.mem, b.mem);
assert_eq!(a.schema, b.schema);
assert_eq!(a.capability, b.capability);
assert_eq!(a.lifecycle, b.lifecycle);
assert_eq!(a.cross_linkable, b.cross_linkable);
assert_eq!(a.storage, b.storage);
}
}
#[test]
fn save_state_round_trips_unset_schema_assertion() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let store = FileWorkspaceStore::new();
let original = Workspace {
mounts: vec![Mount {
mem: "foreign".to_string(),
schema: None,
storage: MountStorage::Folder {
path: tmp.path().join("foreign"),
},
capability: MountCapability::ReadOnly,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
};
store.save_state(tmp.path(), &original).unwrap();
let raw =
std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
assert!(
!raw.contains("\"schema\""),
"unset schema assertion must omit the key on the wire; got:\n{raw}"
);
let reloaded = store.load(tmp.path()).unwrap();
assert_eq!(reloaded.mounts.len(), 1);
assert_eq!(reloaded.mounts[0].schema, None);
}
#[test]
fn save_state_does_not_touch_workspace_toml() {
let tmp = TempDir::new().unwrap();
let original_body = r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#;
write_workspace_toml(tmp.path(), original_body);
let store = FileWorkspaceStore::new();
let workspace = Workspace::default();
store.save_state(tmp.path(), &workspace).unwrap();
let toml_after =
std::fs::read_to_string(FileWorkspaceStore::workspace_toml_path(tmp.path())).unwrap();
assert_eq!(toml_after, original_body);
}
#[test]
fn save_state_writes_paths_relative_to_workspace_root() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let store = FileWorkspaceStore::new();
let workspace = Workspace {
mounts: vec![
Mount {
mem: "engine".to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::GitBranch {
gitdir: tmp.path().join("mem-repo").join(".git"),
branch: "engine".to_string(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
},
Mount {
mem: "external".to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::Archive {
path: PathBuf::from("/global/cache/external.mem"),
},
capability: MountCapability::ReadOnly,
lifecycle: MountLifecycle::Lazy,
cross_linkable: false,
migration_target: None,
},
],
settings: WorkspaceSettings::default(),
};
store.save_state(tmp.path(), &workspace).unwrap();
let on_disk =
std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
assert!(on_disk.contains("\"memstead-mounts-3\""));
assert!(
on_disk.contains("\"mem-repo/.git\""),
"expected relative gitdir, got: {on_disk}"
);
assert!(
!on_disk.contains(tmp.path().to_str().unwrap()),
"in-workspace path should not include the absolute tmp prefix: {on_disk}"
);
assert!(on_disk.contains("\"/global/cache/external.mem\""));
let reloaded = store.load(tmp.path()).unwrap();
match &reloaded.mounts[0].storage {
MountStorage::GitBranch { gitdir, .. } => {
assert_eq!(gitdir, &tmp.path().join("mem-repo").join(".git"));
}
other => panic!("expected GitBranch storage, got {other:?}"),
}
match &reloaded.mounts[1].storage {
MountStorage::Archive { path } => {
assert_eq!(path, &PathBuf::from("/global/cache/external.mem"));
}
other => panic!("expected Archive storage, got {other:?}"),
}
}
#[test]
fn load_absolute_inside_root_path_then_save_rewrites_relative() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
let abs_gitdir = tmp.path().join("mem-repo").join(".git");
let mounts_body = format!(
r#"{{
"format": "memstead-mounts-3",
"mounts": [
{{
"mem": "engine",
"schema": "default@1.0.0",
"storage": {{
"type": "git-branch",
"gitdir": "{}",
"branch": "engine"
}},
"capability": "write",
"lifecycle": "eager",
"cross_linkable": true
}}
]
}}"#,
abs_gitdir.to_str().unwrap()
);
std::fs::write(&mounts_path, &mounts_body).unwrap();
let store = FileWorkspaceStore::new();
let workspace = store.load(tmp.path()).unwrap();
match &workspace.mounts[0].storage {
MountStorage::GitBranch { gitdir, .. } => assert_eq!(gitdir, &abs_gitdir),
other => panic!("expected GitBranch storage, got {other:?}"),
}
store.save_state(tmp.path(), &workspace).unwrap();
let on_disk = std::fs::read_to_string(&mounts_path).unwrap();
assert!(on_disk.contains("\"memstead-mounts-3\""));
assert!(on_disk.contains("\"mem-repo/.git\""));
assert!(!on_disk.contains(tmp.path().to_str().unwrap()));
}
#[test]
fn save_state_preserves_refs_heads_branch_form() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let store = FileWorkspaceStore::new();
let original = Workspace {
mounts: vec![Mount {
mem: "engine".to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::GitBranch {
gitdir: tmp.path().join("mem-repo").join(".git"),
branch: "refs/heads/demo/engine".to_string(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
};
store.save_state(tmp.path(), &original).unwrap();
let on_disk =
std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
assert!(
on_disk.contains("\"branch\": \"refs/heads/demo/engine\""),
"expected fully-qualified ref on disk, got: {on_disk}"
);
let reloaded = store.load(tmp.path()).unwrap();
match &reloaded.mounts[0].storage {
MountStorage::GitBranch { branch, .. } => {
assert_eq!(branch, "refs/heads/demo/engine");
}
other => panic!("expected GitBranch storage, got {other:?}"),
}
}
#[test]
fn load_preserves_short_form_branch_without_rewrite() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
std::fs::write(
&mounts_path,
r#"{
"format": "memstead-mounts-3",
"mounts": [
{
"mem": "engine",
"schema": "default@1.0.0",
"storage": {
"type": "git-branch",
"gitdir": "mem-repo/.git",
"branch": "demo/engine"
},
"capability": "write",
"lifecycle": "eager",
"cross_linkable": true
}
]
}"#,
)
.unwrap();
let store = FileWorkspaceStore::new();
let workspace = store.load(tmp.path()).unwrap();
match &workspace.mounts[0].storage {
MountStorage::GitBranch { branch, .. } => {
assert_eq!(
branch, "demo/engine",
"reader must not silently rewrite short-form branch"
);
}
other => panic!("expected GitBranch storage, got {other:?}"),
}
}
#[test]
fn load_rejects_format_version_mismatch_on_toml() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-99"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let store = FileWorkspaceStore::new();
let err = store.load(tmp.path()).unwrap_err();
match err {
StoreError::FormatMismatch {
expected, found, ..
} => {
assert_eq!(expected, "memstead-git-branch-2");
assert_eq!(found, "memstead-git-branch-99");
}
other => panic!("expected FormatMismatch, got {other:?}"),
}
}
#[test]
fn load_rejects_format_version_mismatch_on_mounts_json() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
std::fs::write(
&mounts_path,
r#"{ "format": "memstead-mounts-99", "mounts": [] }"#,
)
.unwrap();
let store = FileWorkspaceStore::new();
let err = store.load(tmp.path()).unwrap_err();
assert!(matches!(err, StoreError::FormatMismatch { .. }));
}
#[test]
fn load_refuses_pre_rename_toml_as_legacy_layout() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-1"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let store = FileWorkspaceStore::new();
let err = store.load(tmp.path()).unwrap_err();
match err {
StoreError::LegacyLayout { found, .. } => {
assert_eq!(found, "memstead-git-branch-1");
}
other => panic!("expected LegacyLayout, got {other:?}"),
}
}
#[test]
fn load_refuses_pre_rename_mounts_json_as_legacy_layout() {
for legacy in ["memstead-mounts-1", "memstead-mounts-2"] {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
std::fs::write(
&mounts_path,
format!(
r#"{{ "format": "{legacy}", "mounts": [{{ "unit": "notes", "storage": {{ "type": "folder", "path": "notes" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#
),
)
.unwrap();
let store = FileWorkspaceStore::new();
let err = store.load(tmp.path()).unwrap_err();
match err {
StoreError::LegacyLayout { found, .. } => assert_eq!(found, legacy),
other => panic!("expected LegacyLayout for {legacy}, got {other:?}"),
}
}
}
#[test]
fn load_rejects_invalid_toml() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(tmp.path(), "this is not = valid = toml");
let store = FileWorkspaceStore::new();
let err = store.load(tmp.path()).unwrap_err();
assert!(matches!(err, StoreError::Parse { .. }));
}
#[test]
fn load_rejects_unknown_top_level_key() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
"format = \"memstead-git-branch-2\"\nnonexistent_key = true\n",
);
let store = FileWorkspaceStore::new();
let err = store.load(tmp.path()).unwrap_err();
match err {
StoreError::Parse { message, .. } => {
assert!(
message.contains("nonexistent_key"),
"refusal must name the unknown key: {message}"
);
}
other => panic!("expected Parse error, got {other:?}"),
}
}
#[test]
fn instantiate_lean_backend_handles_folder_archive_and_in_memory() {
let tmp = TempDir::new().unwrap();
let folder = folder_mount("local", tmp.path().to_path_buf());
let archive_path = tmp.path().join("ext.mem");
let f = std::fs::File::create(&archive_path).unwrap();
let mut w = zip::ZipWriter::new(f);
w.start_file("a.md", zip::write::SimpleFileOptions::default())
.unwrap();
w.write_all(b"# a").unwrap();
w.finish().unwrap();
let archive = Mount {
mem: "external".to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::Archive { path: archive_path },
capability: MountCapability::ReadOnly,
lifecycle: MountLifecycle::Lazy,
cross_linkable: false,
migration_target: None,
};
let in_memory = Mount {
mem: "session".to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::InMemory,
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let _: Box<dyn MemBackend> = instantiate_lean_backend(&folder).unwrap();
let _: Box<dyn MemBackend> = instantiate_lean_backend(&archive).unwrap();
let _: Box<dyn MemBackend> = instantiate_lean_backend(&in_memory).unwrap();
}
#[test]
fn save_state_round_trips_in_memory_variant_unambiguously() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
"#,
);
let store = FileWorkspaceStore::new();
let original = Workspace {
mounts: vec![
folder_mount("local", PathBuf::from("/work/mem")),
Mount {
mem: "session".to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::InMemory,
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
},
],
settings: WorkspaceSettings::default(),
};
store.save_state(tmp.path(), &original).unwrap();
let raw =
std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
assert!(raw.contains("\"type\": \"in-memory\""), "got: {raw}");
let reloaded = store.load(tmp.path()).unwrap();
assert_eq!(reloaded.mounts.len(), 2);
let session = reloaded
.mounts
.iter()
.find(|m| m.mem == "session")
.expect("session mount survives reload");
assert_eq!(session.storage, MountStorage::InMemory);
let local = reloaded.mounts.iter().find(|m| m.mem == "local").unwrap();
assert!(matches!(local.storage, MountStorage::Folder { .. }));
}
#[test]
fn instantiate_lean_backend_rejects_git_branch_with_typed_error() {
let mount = Mount {
mem: "engine".to_string(),
schema: Some(pin("default@1.0.0")),
storage: MountStorage::GitBranch {
gitdir: PathBuf::from("/some/path/.git"),
branch: "engine".to_string(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
match instantiate_lean_backend(&mount) {
Err(InstantiateError::GitBranchRequiresMemRepoFeature { mem }) => {
assert_eq!(mem, "engine");
}
Ok(_) => panic!("expected GitBranchRequiresMemRepoFeature, got Ok"),
}
}
#[test]
fn detect_layout_returns_empty_for_unrecognised_workspace() {
let tmp = TempDir::new().unwrap();
assert_eq!(detect_layout(tmp.path()), Layout::Empty);
}
#[test]
fn detect_layout_returns_new_when_workspace_toml_present() {
let tmp = TempDir::new().unwrap();
write_workspace_toml(
tmp.path(),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
);
assert_eq!(detect_layout(tmp.path()), Layout::New);
}
}