Skip to main content

memstead_base/
workspace_store.rs

1//! Persistence adapter — reads / writes the [`Workspace`] from disk
2//! (or other backing stores) so [`crate::Engine::from_mounts`] can
3//! consume an in-memory mount list without owning the disk format.
4//!
5//! ## Two-layer file adapter (default)
6//!
7//! [`FileWorkspaceStore`] is the default adapter. It splits the
8//! workspace's persisted state across two files under
9//! `<workspace>/.memstead/`:
10//!
11//! - `workspace.toml` — operator-edited. Carries the persistence-
12//!   adapter declaration plus (in later sessions) cross-mem
13//!   permissions, workspace-level policy, plugin hooks. The engine
14//!   never writes to this file.
15//! - `state/mounts.json` — engine-managed. Carries the mount list
16//!   (per-mount mem name, schema pin, capability, lifecycle,
17//!   cross-linkable flag, and the backend-specific storage reference
18//!   — folder path, gitdir+branch pair, or archive path). The
19//!   operator does not edit this file during normal operation.
20//!
21//! The split mirrors the natural authorship: the operator edits rules
22//! that change rarely; the engine writes mount-list entries that
23//! change often (planning mems, ingest scratch mems, …). Sharing
24//! one file would force two authors with different update frequencies
25//! through the same merge surface.
26//!
27//! ## Adapter trait
28//!
29//! [`WorkspaceStoreAdapter`] is the seam. Future adapters (SQLite,
30//! remote, in-memory test fixture) implement it without changing the
31//! engine API.
32//! The adapter is selected at startup via the persistence-adapter
33//! declaration in `workspace.toml` — the file adapter is the only
34//! built-in V1.
35//!
36//! ## Backend instantiation
37//!
38//! The adapter produces a [`Workspace`] (mount list + operator
39//! policy). Turning each [`Mount`]'s [`MountStorage`] into a
40//! `Box<dyn MemBackend>` is a separate concern — handled by
41//! [`instantiate_lean_backend`] for folder + archive variants. The
42//! git-branch backend lives in the `memstead-git-branch` crate behind the
43//! `mem-repo` Cargo feature; consumers in the lean flavour cannot
44//! materialise a `MountStorage::GitBranch` mount and surface
45//! [`InstantiateError::GitBranchRequiresMemRepoFeature`].
46
47use std::path::{Path, PathBuf};
48
49use serde::{Deserialize, Serialize};
50
51use crate::backend::MemBackend;
52use crate::storage::{ArchiveBackend, FilesystemMemWriter, InMemoryBackend};
53use crate::workspace::{
54    McpSection, Mount, MountCapability, MountLifecycle, MountStorage, MutationsSection, Workspace,
55    WorkspaceSettings,
56};
57
58/// The engine-managed workspace store directory under the workspace
59/// root — `<workspace_root>/.memstead/` holds `workspace.toml`,
60/// `state/mounts.json`, and the tier-3 install cache. Distinct from
61/// the per-mem meta directory
62/// ([`memstead_schema::MEM_META_DIR`], re-exported as
63/// `crate::mem::MEM_META_DIR`) and from the literal `".memstead/..."`
64/// member paths inside sealed archives, which are a separate on-disk
65/// format and never use this constant.
66pub const WORKSPACE_STORE_DIR: &str = ".memstead";
67
68/// Errors surfaced by [`WorkspaceStoreAdapter::load`] and
69/// [`WorkspaceStoreAdapter::save_state`]. Backend-specific failures
70/// surface as [`StoreError::Other`] with a string message; structured
71/// per-adapter errors can extend the enum later.
72#[derive(Debug, thiserror::Error)]
73pub enum StoreError {
74    /// Workspace root has no `.memstead/` directory or no recognised
75    /// adapter file inside it. Distinct from `Io` so callers can
76    /// distinguish "needs `memstead init`" from "permissions broke".
77    #[error("workspace store not found at {path} — run `memstead mem-repo init` first")]
78    NotInitialised { path: PathBuf },
79    /// IO failure reading or writing one of the adapter files.
80    #[error(
81        "workspace store io error at {path}: {source} — no memstead command repairs this; \
82         check filesystem permissions and disk state"
83    )]
84    Io {
85        path: PathBuf,
86        #[source]
87        source: std::io::Error,
88    },
89    /// TOML or JSON parse / serialise failure. The wrapped string is
90    /// the underlying serde error; the file is named so operators
91    /// know where to look.
92    #[error(
93        "workspace store parse error at {path}: {message} — no memstead command repairs this; \
94         fix the named file by hand or restore it from version control"
95    )]
96    Parse { path: PathBuf, message: String },
97    /// Format version mismatch — adapter understands a different
98    /// schema version than the file declares.
99    #[error(
100        "workspace store format mismatch at {path}: expected {expected}, found {found} — \
101         no memstead command repairs this; use an engine version whose format matches the file"
102    )]
103    FormatMismatch {
104        path: PathBuf,
105        expected: String,
106        found: String,
107    },
108    /// Pre-rename workspace layout. The unit-noun cut renamed every
109    /// on-disk shape with no dual-read; refusing keeps an old
110    /// workspace from booting empty, half-mounted, or silently
111    /// rewritten. The message names the one-shot migration steps.
112    #[error(
113        "pre-rename workspace layout at {path} (found format {found}): migrate the workspace \
114         state in place — rewrite mounts.json to memstead-mounts-3 (record field `mem`, storage \
115         paths under mem-repo/), workspace.toml to memstead-git-branch-2 (tables `mem_management`, \
116         `cross_mem_links`), rename the gitdir container to mem-repo/, and move the metadata \
117         branch tree to mems/ — then retry"
118    )]
119    LegacyLayout { path: PathBuf, found: String },
120    /// A `projections/` directory holds a pre-v2 config — either a
121    /// version-less (gen-2 four-primitive) projection or a v1 binding of the
122    /// retired three-file store. The loader serves only v2 (one record per
123    /// pipeline); prior generations are migrated once (`memstead projection
124    /// migrate`), never silently served. The message names the one-shot
125    /// migration command; [`StoreError::code`] maps it to the
126    /// `PROJECTION_STORE_LEGACY` token on every surface.
127    #[error(
128        "legacy (pre-v2) projection config at {path}: this workspace predates the single-record \
129         binding format v2 — run `memstead projection migrate` to convert it in place once"
130    )]
131    LegacyProjectionStore { path: PathBuf },
132    /// A binding file declares a `version` the loader does not understand
133    /// (only v2 = `2` is supported; v1 and version-less files surface
134    /// [`Self::LegacyProjectionStore`] instead). Refused, never reinterpreted.
135    #[error(
136        "unsupported binding format version {version} at {path}: this engine understands v2 (version 2)"
137    )]
138    UnknownBindingVersion { path: PathBuf, version: i64 },
139    /// Catch-all for adapter-specific failures. Carries an
140    /// agent-readable message; structured variants extend the enum.
141    #[error("workspace store error: {0}")]
142    Other(String),
143}
144
145impl StoreError {
146    /// Stable, surface-independent error code token, following the
147    /// [`crate::EngineError::code`] convention (UPPER_SNAKE). Boot
148    /// failures route through [`crate::engine::BootError::code`],
149    /// which delegates here — a store-layer failure carries the same
150    /// typed code on CLI stderr, `--json` envelopes, and the MCP
151    /// server's boot diagnostics.
152    pub fn code(&self) -> &'static str {
153        match self {
154            // Same token the CLI's setup layer uses for "no workspace
155            // marker found" — one condition, one code, regardless of
156            // whether the walk or the store load detected it.
157            StoreError::NotInitialised { .. } => "WORKSPACE_NOT_INITIALISED",
158            StoreError::Io { .. } => "WORKSPACE_STORE_IO",
159            StoreError::Parse { .. } => "WORKSPACE_STORE_PARSE",
160            StoreError::FormatMismatch { .. } => "WORKSPACE_STORE_FORMAT_MISMATCH",
161            StoreError::LegacyLayout { .. } => "LEGACY_WORKSPACE_LAYOUT",
162            StoreError::LegacyProjectionStore { .. } => "PROJECTION_STORE_LEGACY",
163            StoreError::UnknownBindingVersion { .. } => "UNKNOWN_BINDING_VERSION",
164            StoreError::Other(_) => "WORKSPACE_STORE_ERROR",
165        }
166    }
167}
168
169/// Adapter trait — the seam between the engine and the persisted
170/// workspace state. Implementations decide *where* the mount list
171/// lives (two files under `.memstead/`, a SQLite database, a remote
172/// service, an in-memory test fixture); the engine consumes the
173/// produced [`Workspace`] uniformly.
174pub trait WorkspaceStoreAdapter: Send + Sync {
175    /// Load the workspace from `workspace_root`. Implementations
176    /// resolve the adapter-specific files relative to this root
177    /// (e.g. the file adapter reads
178    /// `<workspace_root>/.memstead/workspace.toml` +
179    /// `<workspace_root>/.memstead/state/mounts.json`).
180    fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError>;
181
182    /// Persist the engine-managed slice of state (today: the mount
183    /// list). Operator-edited fields stay untouched — adapters that
184    /// share one file with operator content must not overwrite it
185    /// here. The two-layer file adapter writes only
186    /// `state/mounts.json`.
187    fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError>;
188}
189
190/// Two-layer file adapter — the default. Reads
191/// `.memstead/workspace.toml` (operator) +
192/// `.memstead/state/mounts.json` (engine). Constructed without
193/// arguments; everything is keyed off the workspace root passed
194/// per-call.
195#[derive(Debug, Default, Clone, Copy)]
196pub struct FileWorkspaceStore;
197
198impl FileWorkspaceStore {
199    /// Construct the adapter. Stateless; safe to share across
200    /// engines, callers, and tests.
201    pub fn new() -> Self {
202        Self
203    }
204
205    /// Path of the operator-edited file.
206    pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
207        workspace_root
208            .join(WORKSPACE_STORE_DIR)
209            .join("workspace.toml")
210    }
211
212    /// Path of the engine-managed state file.
213    pub fn mounts_json_path(workspace_root: &Path) -> PathBuf {
214        workspace_root
215            .join(WORKSPACE_STORE_DIR)
216            .join("state")
217            .join("mounts.json")
218    }
219}
220
221const WORKSPACE_TOML_FORMAT: &str = "memstead-git-branch-2";
222/// Pre-rename `workspace.toml` format. V1 carried the old unit-noun
223/// policy tables. Recognised only to refuse with
224/// [`StoreError::LegacyLayout`] — no dual-read.
225const WORKSPACE_TOML_FORMAT_LEGACY: &str = "memstead-git-branch-1";
226/// Current `mounts.json` format. V3 is the unit-noun cut: mount
227/// records carry a `"mem"` field and mem-repo path values. Like V2 it
228/// stores `gitdir` / `path` values relative to `workspace_root` when
229/// they live inside the workspace, so checked-in state survives a
230/// clone into a different home dir. Absolute paths are still accepted
231/// on write (and round-trip untouched) when the mount target sits
232/// outside `workspace_root` — e.g., an archive on a shared cache. The
233/// reader resolves relative values against `workspace_root` at load
234/// time.
235const MOUNTS_JSON_FORMAT_V3: &str = "memstead-mounts-3";
236/// Pre-rename `mounts.json` formats (V1: absolute paths; V2: relative
237/// paths; both with the old unit-noun record field). Recognised only
238/// to refuse with [`StoreError::LegacyLayout`] — there is no
239/// dual-read; a one-shot migration rewrites state in place.
240const MOUNTS_JSON_FORMAT_LEGACY: [&str; 2] = ["memstead-mounts-1", "memstead-mounts-2"];
241
242/// Format-only probe for `mounts.json`, parsed before the full
243/// document so format refusals (legacy layout, unknown version)
244/// surface as typed errors rather than record-level parse failures.
245#[derive(Deserialize)]
246struct MountsFormatProbe {
247    format: String,
248}
249
250/// Shared `workspace.toml` format gate: current passes, the
251/// pre-rename V1 refuses as [`StoreError::LegacyLayout`], anything
252/// else as [`StoreError::FormatMismatch`].
253fn check_workspace_toml_format(format: &str, toml_path: &Path) -> Result<(), StoreError> {
254    if format == WORKSPACE_TOML_FORMAT {
255        return Ok(());
256    }
257    if format == WORKSPACE_TOML_FORMAT_LEGACY {
258        return Err(StoreError::LegacyLayout {
259            path: toml_path.to_path_buf(),
260            found: format.to_string(),
261        });
262    }
263    Err(StoreError::FormatMismatch {
264        path: toml_path.to_path_buf(),
265        expected: WORKSPACE_TOML_FORMAT.to_string(),
266        found: format.to_string(),
267    })
268}
269
270/// Resolve a path read from `mounts.json` against the workspace
271/// root. Absolute paths are returned untouched (they sit outside
272/// `workspace_root` by design — typically a shared archive cache);
273/// relative paths are joined against `workspace_root` so the
274/// in-memory `Mount` always carries an absolute path.
275fn absolutize_mount_path(value: PathBuf, workspace_root: &Path) -> PathBuf {
276    if value.is_absolute() {
277        value
278    } else {
279        workspace_root.join(value)
280    }
281}
282
283/// Normalise an absolute mount path for `mounts.json` serialisation.
284/// When the path sits inside `workspace_root`, strip the prefix so
285/// the on-disk form is portable. When it sits outside — an archive
286/// in a global cache, a mem on a separate filesystem — keep the
287/// absolute form; `strip_prefix` failure is the explicit fallback.
288fn relativize_mount_path(value: &Path, workspace_root: &Path) -> PathBuf {
289    match value.strip_prefix(workspace_root) {
290        Ok(rel) => rel.to_path_buf(),
291        Err(_) => value.to_path_buf(),
292    }
293}
294
295/// True when `dir` is a workspace root: it carries
296/// `.memstead/workspace.toml`. The shared recognition primitive for
297/// every workspace walk-up (MCP boot, CLI setup, per-command walkers)
298/// — keep them all on this helper so workspaces resolve uniformly.
299pub fn is_workspace_root(dir: &Path) -> bool {
300    FileWorkspaceStore::workspace_toml_path(dir).is_file()
301}
302
303/// True when the workspace root carries `mem-repo/.git/` — the
304/// multi-mem, git-backed shape. False for the single-mem, history-free
305/// filesystem-mem shape. The `.memstead/workspace.toml` marker is
306/// shape-neutral, so this directory probe is what distinguishes the two.
307///
308/// The shared shape primitive: the CLI's `WorkspaceShape` resolution,
309/// the mem-repo-only refusals, and the MCP boot line all route through
310/// here so no surface can name a shape the others disagree with.
311pub fn is_mem_repo_shaped(workspace_root: &Path) -> bool {
312    workspace_root.join("mem-repo").join(".git").is_dir()
313}
314
315/// The one spelling of a workspace's shape, for any surface that names
316/// it to a human or an agent: `"mem-repo"` or `"filesystem-mem"`. Both
317/// spellings match the vocabulary the refusals use
318/// (`UNSUPPORTED_WORKSPACE_SHAPE`) and the commands that create each
319/// shape (`memstead mem-repo init` / `memstead quickstart`).
320pub fn workspace_shape_label(workspace_root: &Path) -> &'static str {
321    if is_mem_repo_shaped(workspace_root) {
322        "mem-repo"
323    } else {
324        "filesystem-mem"
325    }
326}
327
328impl WorkspaceStoreAdapter for FileWorkspaceStore {
329    fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError> {
330        let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
331        if !memstead_dir.is_dir() {
332            return Err(StoreError::NotInitialised {
333                path: workspace_root.to_path_buf(),
334            });
335        }
336
337        // workspace.toml is required (carries the adapter declaration).
338        let toml_path = Self::workspace_toml_path(workspace_root);
339        let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
340            if e.kind() == std::io::ErrorKind::NotFound {
341                StoreError::NotInitialised {
342                    path: workspace_root.to_path_buf(),
343                }
344            } else {
345                StoreError::Io {
346                    path: toml_path.clone(),
347                    source: e,
348                }
349            }
350        })?;
351        let toml_doc: WorkspaceTomlDoc =
352            toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
353                path: toml_path.clone(),
354                message: e.to_string(),
355            })?;
356        check_workspace_toml_format(&toml_doc.format, &toml_path)?;
357
358        // state/mounts.json is optional — a fresh workspace has the
359        // adapter declaration but no mounts yet. Treat the missing
360        // file as "zero mounts".
361        let mounts_path = Self::mounts_json_path(workspace_root);
362        let mounts: Vec<Mount> = match std::fs::read_to_string(&mounts_path) {
363            Ok(text) => {
364                // Probe the format field before the full parse: a
365                // pre-rename file fails record deserialisation (old
366                // unit-noun field name), and the typed LegacyLayout
367                // refusal must win over that generic parse error.
368                let probe: MountsFormatProbe =
369                    serde_json::from_str(&text).map_err(|e| StoreError::Parse {
370                        path: mounts_path.clone(),
371                        message: e.to_string(),
372                    })?;
373                if MOUNTS_JSON_FORMAT_LEGACY.contains(&probe.format.as_str()) {
374                    return Err(StoreError::LegacyLayout {
375                        path: mounts_path,
376                        found: probe.format,
377                    });
378                }
379                if probe.format != MOUNTS_JSON_FORMAT_V3 {
380                    return Err(StoreError::FormatMismatch {
381                        path: mounts_path,
382                        expected: MOUNTS_JSON_FORMAT_V3.to_string(),
383                        found: probe.format,
384                    });
385                }
386                let doc: MountsJsonDoc =
387                    serde_json::from_str(&text).map_err(|e| StoreError::Parse {
388                        path: mounts_path.clone(),
389                        message: e.to_string(),
390                    })?;
391                doc.mounts
392                    .into_iter()
393                    .map(|w| w.into_mount(workspace_root))
394                    .collect()
395            }
396            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
397            Err(e) => {
398                return Err(StoreError::Io {
399                    path: mounts_path,
400                    source: e,
401                });
402            }
403        };
404
405        warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
406        let settings = build_settings(
407            toml_doc.mem_management,
408            toml_doc.cross_mem_links,
409            toml_doc.mcp,
410            toml_doc.mutations,
411            toml_doc.plugin,
412        )?;
413        Ok(Workspace { mounts, settings })
414    }
415
416    fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError> {
417        let mounts_path = Self::mounts_json_path(workspace_root);
418        if let Some(parent) = mounts_path.parent() {
419            std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
420                path: parent.to_path_buf(),
421                source: e,
422            })?;
423        }
424        let doc = MountsJsonDoc {
425            format: MOUNTS_JSON_FORMAT_V3.to_string(),
426            mounts: workspace
427                .mounts
428                .iter()
429                .map(|m| MountWire::from_mount(m, workspace_root))
430                .collect(),
431        };
432        let text = serde_json::to_string_pretty(&doc).map_err(|e| StoreError::Parse {
433            path: mounts_path.clone(),
434            message: e.to_string(),
435        })?;
436        std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
437            path: mounts_path,
438            source: e,
439        })?;
440        Ok(())
441    }
442}
443
444/// On-disk shape of `workspace.toml`. Operator-edited; engine never
445/// writes. V1 carries the adapter declaration, `[mem_management]`
446/// rule lists, and `[cross_mem_links]` permission policy. Plugin
447/// hooks land additively when consumers need them.
448#[derive(Debug, Serialize, Deserialize)]
449#[serde(deny_unknown_fields)]
450struct WorkspaceTomlDoc {
451    /// Schema version of the TOML file. Must equal
452    /// `memstead-git-branch-1` for V1; mismatch surfaces
453    /// [`StoreError::FormatMismatch`].
454    format: String,
455    /// Persistence-adapter declaration. Carries the adapter `name`
456    /// (default: `"file-two-layer"`); future adapters add their own
457    /// nested config blocks.
458    #[serde(default)]
459    persistence_adapter: PersistenceAdapterDecl,
460    /// `[mem_management]` rule lists. Both arrays default to empty
461    /// — an empty list means "no agent-driven mem create / delete
462    /// allowed" (mirrors full). Operators add `[[mem_management.create]]`
463    /// / `[[mem_management.delete]]` entries to opt in.
464    #[serde(default)]
465    mem_management: MemManagementWire,
466    /// `[cross_mem_links]` raw shape — `<mem> = "*"` (wildcard)
467    /// or `<mem> = ["target", ...]` (allowlist) per key. Parsed
468    /// post-decode via `memstead_schema::workspace_config::CrossLinkValue::parse_toml`
469    /// because the wildcard-or-list shape doesn't fit serde's
470    /// untagged-enum pattern. Empty when the section is absent;
471    /// interpreted as default-deny.
472    #[serde(default)]
473    cross_mem_links: toml::Table,
474    /// **Retired key.** The folder-backend authored-schema location is
475    /// fixed at `<workspace>/.memstead/schemas/`; this key is no longer
476    /// honoured. Kept here only so an older workspace.toml that still
477    /// carries it parses cleanly — `warn_if_legacy_schemas_dir` emits a
478    /// one-line warning and the value is dropped (never threaded into
479    /// `WorkspaceSettings`).
480    #[serde(default)]
481    schemas_dir: Option<std::path::PathBuf>,
482    /// `[mcp]` section — MCP-binary tuning. Absent → defaults
483    /// (`token_budget` falls back to the binary's compile-time
484    /// default, `disabled_tools` is empty).
485    #[serde(default)]
486    mcp: McpSection,
487    /// `[mutations]` section — engine-wide mutation policy. Absent →
488    /// `require_notes = None` (interpreted as `false`).
489    #[serde(default)]
490    mutations: MutationsSection,
491    /// `[plugin.*]` namespace — opaque pass-through map keyed by
492    /// plugin identifier. Values are raw TOML tables; the engine
493    /// never inspects them.
494    #[serde(default)]
495    plugin: std::collections::HashMap<String, toml::Table>,
496}
497
498/// Wire shape for the `[mem_management]` section. Both arrays
499/// default to empty so the section may be omitted from
500/// `workspace.toml` entirely.
501#[derive(Debug, Default, Serialize, Deserialize)]
502struct MemManagementWire {
503    #[serde(default)]
504    create: Vec<CreateRuleWire>,
505    #[serde(default)]
506    delete: Vec<DeleteRuleWire>,
507}
508
509/// Wire shape for one `[[mem_management.create]]` entry. Mirrors
510/// [`crate::workspace::CreateRuleSetting`] with serde defaults so
511/// `schemas` may be omitted (treated as the empty allowlist —
512/// effectively a deny rule, surfaced for parity with full).
513///
514/// `default_cross_links` is decoded as a raw `toml::Value` and lifted
515/// to `CrossLinkValue` post-decode via the same parser as the
516/// top-level `[cross_mem_links]` section, sharing the wildcard-vs-
517/// list-vs-mixed-rejection semantics.
518#[derive(Debug, Serialize, Deserialize)]
519struct CreateRuleWire {
520    pattern: String,
521    #[serde(default)]
522    schemas: Vec<String>,
523    #[serde(default)]
524    default_cross_links: Option<toml::Value>,
525}
526
527/// Wire shape for one `[[mem_management.delete]]` entry. Mirrors
528/// [`crate::workspace::DeleteRuleSetting`].
529#[derive(Debug, Serialize, Deserialize)]
530struct DeleteRuleWire {
531    pattern: String,
532}
533
534/// Parse the operator-edited `.memstead/workspace.toml` at `workspace_root`
535/// into a fresh `WorkspaceSettings`. Exposed so MCP- and CLI-driven
536/// policy-mutation tools (the `workspace_config_edit::{grant,revoke}_*`
537/// family) can refresh the engine's in-memory settings after writing
538/// to disk — closing the stale-cache footgun where the next call
539/// into the engine after a successful policy mutation still saw the
540/// pre-mutation policy.
541///
542/// Reads only `workspace.toml` — the engine-managed `mounts.json` is
543/// untouched. The function pays one file-read; the alternative
544/// (threading projections through the policy-mutation functions
545/// without an engine handle) was rejected for the coupling cost.
546///
547/// Errors mirror [`FileWorkspaceStore::load`]'s subset that touches
548/// `workspace.toml` only: `NotInitialised` (no `.memstead/` dir),
549/// `Io` / `Parse` (file read or TOML parse failure),
550/// `FormatMismatch` (unsupported `format` field).
551pub fn parse_workspace_settings(
552    workspace_root: &Path,
553) -> Result<crate::workspace::WorkspaceSettings, StoreError> {
554    let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
555    if !memstead_dir.is_dir() {
556        return Err(StoreError::NotInitialised {
557            path: workspace_root.to_path_buf(),
558        });
559    }
560    let toml_path = FileWorkspaceStore::workspace_toml_path(workspace_root);
561    let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
562        if e.kind() == std::io::ErrorKind::NotFound {
563            StoreError::NotInitialised {
564                path: workspace_root.to_path_buf(),
565            }
566        } else {
567            StoreError::Io {
568                path: toml_path.clone(),
569                source: e,
570            }
571        }
572    })?;
573    let toml_doc: WorkspaceTomlDoc = toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
574        path: toml_path.clone(),
575        message: e.to_string(),
576    })?;
577    check_workspace_toml_format(&toml_doc.format, &toml_path)?;
578    warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
579    build_settings(
580        toml_doc.mem_management,
581        toml_doc.cross_mem_links,
582        toml_doc.mcp,
583        toml_doc.mutations,
584        toml_doc.plugin,
585    )
586}
587
588/// Build a `WorkspaceSettings` from the raw wire shapes. Folds in
589/// the `[mem_management]` rules and the post-decoded
590/// `[cross_mem_links]` map; surfaces a typed parse error if any
591/// cross-link value violates the wildcard / list / non-empty
592/// invariants.
593fn build_settings(
594    vm: MemManagementWire,
595    cross_mem_links_raw: toml::Table,
596    mcp: McpSection,
597    mutations: MutationsSection,
598    plugin: std::collections::HashMap<String, toml::Table>,
599) -> Result<WorkspaceSettings, StoreError> {
600    let mut create_rules = Vec::with_capacity(vm.create.len());
601    for r in vm.create {
602        let default_cross_links = match r.default_cross_links {
603            None => None,
604            Some(value) => {
605                let location = format!(
606                    "[[mem_management.create]] pattern={}.default_cross_links",
607                    r.pattern
608                );
609                Some(parse_cross_link_value(&location, &value)?)
610            }
611        };
612        create_rules.push(crate::workspace::CreateRuleSetting {
613            pattern: r.pattern,
614            schemas: r.schemas,
615            default_cross_links,
616        });
617    }
618
619    let mut cross_mem_links = std::collections::BTreeMap::new();
620    for (mem, value) in &cross_mem_links_raw {
621        let location = format!("[cross_mem_links].{mem}");
622        let parsed = parse_cross_link_value(&location, value)?;
623        cross_mem_links.insert(mem.clone(), parsed);
624    }
625
626    Ok(WorkspaceSettings {
627        mem_create_rules: create_rules,
628        mem_delete_rules: vm
629            .delete
630            .into_iter()
631            .map(|r| crate::workspace::DeleteRuleSetting { pattern: r.pattern })
632            .collect(),
633        cross_mem_links,
634        mcp,
635        mutations,
636        plugin,
637    })
638}
639
640/// The folder-backend authored-schema location is fixed at
641/// `<workspace>/.memstead/schemas/` — the `schemas_dir` workspace.toml
642/// key is retired (no configurability without demonstrated need). A
643/// workspace.toml that still carries the key gets a one-line warning
644/// naming the fixed location; the key is otherwise ignored, never
645/// honoured. Called from both `workspace.toml` parse entry points.
646fn warn_if_legacy_schemas_dir(schemas_dir: Option<&std::path::Path>) {
647    if let Some(dir) = schemas_dir {
648        tracing::warn!(
649            "`schemas_dir` (= {:?}) in workspace.toml is retired and ignored — \
650             authored schemas are read from the fixed `<workspace>/.memstead/schemas/`. \
651             Remove the key to silence this warning.",
652            dir
653        );
654    }
655}
656
657/// Parse one cross-link value via `memstead_schema::workspace_config::CrossLinkValue::parse_toml`,
658/// lifting the schema-crate's `ConfigError` into a `StoreError::Parse` with
659/// the operator-facing TOML location prefix.
660fn parse_cross_link_value(
661    location: &str,
662    value: &toml::Value,
663) -> Result<memstead_schema::workspace_config::CrossLinkValue, StoreError> {
664    memstead_schema::workspace_config::CrossLinkValue::parse_toml(location, value).map_err(|e| {
665        StoreError::Parse {
666            path: std::path::PathBuf::from("workspace.toml"),
667            message: e.to_string(),
668        }
669    })
670}
671
672/// Persistence-adapter section of `workspace.toml`. Future adapters
673/// nest their config under this section.
674#[derive(Debug, Serialize, Deserialize)]
675struct PersistenceAdapterDecl {
676    name: String,
677}
678
679impl Default for PersistenceAdapterDecl {
680    fn default() -> Self {
681        Self {
682            name: "file-two-layer".to_string(),
683        }
684    }
685}
686
687/// On-disk shape of `state/mounts.json`. Engine-managed; operator
688/// does not edit during normal operation (lifecycle tools rewrite it
689/// after every mount-state mutation).
690#[derive(Debug, Serialize, Deserialize)]
691struct MountsJsonDoc {
692    format: String,
693    mounts: Vec<MountWire>,
694}
695
696/// Wire shape for one mount. Mirrors [`Mount`] with serializable
697/// fields. Schema pin serialises as a plain string (`"default"` or
698/// `"default@1.0.0"`); storage uses an internally-tagged enum
699/// (`type: "folder" | "git-branch" | "archive"`).
700#[derive(Debug, Serialize, Deserialize)]
701struct MountWire {
702    mem: String,
703    /// Optional schema-pin *expectation assertion* (`<name>@<version>`).
704    /// The authoritative pin is the mem's own `MemConfig.schema`;
705    /// this is a workspace-local cross-check. `default` on read keeps
706    /// older `mounts.json` files (which always carried the key) loading
707    /// as `Some`; skip-on-`None` omits the key for assertion-less mounts.
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    schema: Option<String>,
710    /// In-flight migration target (`<name>@<version>`), absent for
711    /// settled mems. `default` on read keeps pre-dual-pin
712    /// `mounts.json` files loading unchanged; skip-on-`None` keeps
713    /// settled mems' entries byte-identical to before.
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    migration_target: Option<String>,
716    storage: MountStorageWire,
717    capability: CapabilityWire,
718    lifecycle: LifecycleWire,
719    cross_linkable: bool,
720}
721
722#[derive(Debug, Serialize, Deserialize)]
723#[serde(tag = "type", rename_all = "kebab-case")]
724enum MountStorageWire {
725    Folder {
726        path: PathBuf,
727    },
728    GitBranch {
729        gitdir: PathBuf,
730        branch: String,
731    },
732    Archive {
733        path: PathBuf,
734    },
735    /// In-memory backend. Carries no fields — it serialises as the
736    /// bare tag `{ "type": "in-memory" }`. Unambiguous against the
737    /// other three variants (each of which carries a `path` or
738    /// `gitdir`/`branch`), so a round-trip never confuses it for one
739    /// of them. Present for wire completeness; ephemeral session
740    /// mems are normally constructed via `Engine::from_mounts`
741    /// rather than persisted to `mounts.json`.
742    InMemory,
743}
744
745#[derive(Debug, Serialize, Deserialize)]
746#[serde(rename_all = "kebab-case")]
747enum CapabilityWire {
748    ReadOnly,
749    Write,
750}
751
752#[derive(Debug, Serialize, Deserialize)]
753#[serde(rename_all = "kebab-case")]
754enum LifecycleWire {
755    Eager,
756    Lazy,
757}
758
759impl MountWire {
760    fn from_mount(m: &Mount, workspace_root: &Path) -> Self {
761        Self {
762            mem: m.mem.clone(),
763            schema: m.schema.as_ref().map(|s| s.to_string()),
764            migration_target: m.migration_target.as_ref().map(|t| t.to_string()),
765            storage: match &m.storage {
766                MountStorage::Folder { path } => MountStorageWire::Folder {
767                    path: relativize_mount_path(path, workspace_root),
768                },
769                MountStorage::GitBranch { gitdir, branch } => MountStorageWire::GitBranch {
770                    gitdir: relativize_mount_path(gitdir, workspace_root),
771                    branch: branch.clone(),
772                },
773                MountStorage::Archive { path } => MountStorageWire::Archive {
774                    path: relativize_mount_path(path, workspace_root),
775                },
776                MountStorage::InMemory => MountStorageWire::InMemory,
777            },
778            capability: match m.capability {
779                MountCapability::ReadOnly => CapabilityWire::ReadOnly,
780                MountCapability::Write => CapabilityWire::Write,
781            },
782            lifecycle: match m.lifecycle {
783                MountLifecycle::Eager => LifecycleWire::Eager,
784                MountLifecycle::Lazy => LifecycleWire::Lazy,
785            },
786            cross_linkable: m.cross_linkable,
787        }
788    }
789
790    fn into_mount(self, workspace_root: &Path) -> Mount {
791        Mount {
792            mem: self.mem,
793            schema: self.schema.map(|s| {
794                s.parse()
795                    .expect("schema pin on disk must be `<name>@<version>`")
796            }),
797            migration_target: self.migration_target.map(|t| {
798                t.parse()
799                    .expect("migration_target on disk must be `<name>@<version>`")
800            }),
801            storage: match self.storage {
802                MountStorageWire::Folder { path } => MountStorage::Folder {
803                    path: absolutize_mount_path(path, workspace_root),
804                },
805                MountStorageWire::GitBranch { gitdir, branch } => MountStorage::GitBranch {
806                    gitdir: absolutize_mount_path(gitdir, workspace_root),
807                    branch,
808                },
809                MountStorageWire::Archive { path } => MountStorage::Archive {
810                    path: absolutize_mount_path(path, workspace_root),
811                },
812                MountStorageWire::InMemory => MountStorage::InMemory,
813            },
814            capability: match self.capability {
815                CapabilityWire::ReadOnly => MountCapability::ReadOnly,
816                CapabilityWire::Write => MountCapability::Write,
817            },
818            lifecycle: match self.lifecycle {
819                LifecycleWire::Eager => MountLifecycle::Eager,
820                LifecycleWire::Lazy => MountLifecycle::Lazy,
821            },
822            cross_linkable: self.cross_linkable,
823        }
824    }
825}
826
827/// Errors surfaced by [`instantiate_lean_backend`].
828#[derive(Debug, thiserror::Error)]
829pub enum InstantiateError {
830    /// Mount declares a `MountStorage::GitBranch` storage variant but
831    /// `memstead-base` alone cannot construct a git-branch backend —
832    /// the implementation lives in the `memstead-git-branch` crate.
833    ///
834    /// The remedy text names the two real, published entry points and
835    /// nothing else: an earlier revision pointed at a `mem-repo` Cargo
836    /// feature that does not exist on the published crates and named
837    /// `instantiate_full_backend` without saying how to wire it —
838    /// a cold-start run burned its longest wall on exactly that
839    /// (protocol 0-8-0, F6). Keep this message in lockstep with the
840    /// actual `memstead-git-branch` public API.
841    #[error(
842        "mem {mem}: git-branch storage needs the memstead-git-branch crate \
843         (`cargo add memstead-git-branch`). Simplest: open the workspace with \
844         `memstead_git_branch::workspace_store::engine_from_workspace_root(root)` \
845         instead of the memstead-base constructor. Alternative, if you build the \
846         Engine yourself: `engine.set_backend_factory(\
847         memstead_git_branch::storage::instantiate_full_backend)` before mounting"
848    )]
849    GitBranchRequiresMemRepoFeature { mem: String },
850}
851
852impl InstantiateError {
853    /// Stable, surface-independent error code token (UPPER_SNAKE, per
854    /// the [`crate::EngineError::code`] convention). Reuses the CLI's
855    /// existing `UNSUPPORTED_WORKSPACE_SHAPE` token: both fire when a
856    /// lean binary meets a git-branch-shaped workspace, and the
857    /// agent's next step is identical.
858    pub fn code(&self) -> &'static str {
859        match self {
860            InstantiateError::GitBranchRequiresMemRepoFeature { .. } => {
861                "UNSUPPORTED_WORKSPACE_SHAPE"
862            }
863        }
864    }
865}
866
867/// Materialise a [`MemBackend`] for `mount` using the lean-flavour
868/// backends (folder + archive). Returns an error for the git-branch
869/// variant — full consumers handle that with a feature-gated
870/// counterpart in `memstead-git-branch`.
871///
872/// Lives in `memstead-base` because both folder and archive backends are
873/// always-on; the function shape (one mount in, one boxed backend
874/// out) stays uniform for both flavours so the engine's
875/// `from_mounts` glue is identical between lean and full.
876pub fn instantiate_lean_backend(mount: &Mount) -> Result<Box<dyn MemBackend>, InstantiateError> {
877    match &mount.storage {
878        MountStorage::Folder { path } => Ok(Box::new(FilesystemMemWriter::new(path.clone()))),
879        MountStorage::Archive { path } => Ok(Box::new(ArchiveBackend::new(path.clone()))),
880        MountStorage::InMemory => Ok(Box::new(InMemoryBackend::new())),
881        MountStorage::GitBranch { .. } => Err(InstantiateError::GitBranchRequiresMemRepoFeature {
882            mem: mount.mem.clone(),
883        }),
884    }
885}
886
887/// On-disk layout the workspace root carries today.
888///
889/// Drives [`Engine::from_workspace_root`](crate::Engine::from_workspace_root):
890/// a workspace either carries the two-layer file adapter shape
891/// (`Layout::New`) or it does not (`Layout::Empty`). Pre-rebuild
892/// layouts are no longer recognised — operators run
893/// `memstead mem-repo init` to bootstrap a fresh workspace.
894#[derive(Debug, Clone, Copy, PartialEq, Eq)]
895pub enum Layout {
896    /// No `.memstead/workspace.toml` present — operator should run
897    /// `memstead mem-repo init` to bootstrap.
898    Empty,
899    /// `.memstead/workspace.toml` present — workspace runs on the
900    /// two-layer file adapter.
901    New,
902}
903
904/// Detect the on-disk layout at `workspace_root`. The returned
905/// [`Layout`] discriminator is total: every workspace falls into
906/// exactly one variant.
907pub fn detect_layout(workspace_root: &Path) -> Layout {
908    if is_workspace_root(workspace_root) {
909        Layout::New
910    } else {
911        Layout::Empty
912    }
913}
914
915/// Synthesize a one-mount [`Workspace`] from a bare *standalone* folder
916/// mem — a directory carrying `.memstead/config.json` but **no**
917/// `.memstead/workspace.toml`. The mem root *is* the workspace root
918/// (the collapsed single-mem form), so the lone mount is a folder
919/// backend pointed at `workspace_root` itself.
920///
921/// Returns `None` when the directory is not a standalone mem — no
922/// readable, schema-pinned `config.json` — so boot callers fall through
923/// to [`crate::BootError::NotInitialised`] exactly as before. This is what
924/// collapses the old separate standalone-mem boot path into the unified
925/// roster+detail experience: a lone mem opens as a workspace with one
926/// mount, no `workspace.toml` required.
927///
928/// The synthesized workspace carries default (empty) settings — a
929/// standalone mem has no `[mem_management]` / `[cross_mem_links]`
930/// policy — and the mount is writable, not cross-linkable (there is no
931/// sibling to link to). The schema pin and name come from the mem's own
932/// `config.json`; an engine-written config omits `name`, so the directory
933/// basename is the fallback identity.
934pub fn standalone_workspace(workspace_root: &Path) -> Option<Workspace> {
935    let config = memstead_schema::config::load_and_validate(workspace_root).ok()?;
936    let schema = config.schema.clone()?;
937    let name = config.name.clone().unwrap_or_else(|| {
938        workspace_root
939            .file_name()
940            .map(|n| n.to_string_lossy().to_string())
941            .unwrap_or_else(|| "mem".to_string())
942    });
943    let mount = Mount {
944        mem: name,
945        schema: Some(schema),
946        storage: MountStorage::Folder {
947            path: workspace_root.to_path_buf(),
948        },
949        capability: MountCapability::Write,
950        lifecycle: MountLifecycle::Eager,
951        cross_linkable: false,
952        migration_target: None,
953    };
954    Some(Workspace {
955        mounts: vec![mount],
956        settings: WorkspaceSettings::default(),
957    })
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963    use memstead_schema::SchemaRef;
964    use std::io::Write as _;
965    use tempfile::TempDir;
966
967    fn pin(s: &str) -> SchemaRef {
968        s.parse().unwrap()
969    }
970
971    fn folder_mount(mem: &str, path: PathBuf) -> Mount {
972        Mount {
973            mem: mem.to_string(),
974            schema: Some(pin("default@1.0.0")),
975            storage: MountStorage::Folder { path },
976            capability: MountCapability::Write,
977            lifecycle: MountLifecycle::Eager,
978            cross_linkable: true,
979            migration_target: None,
980        }
981    }
982
983    fn write_workspace_toml(workspace_root: &Path, body: &str) {
984        let path = FileWorkspaceStore::workspace_toml_path(workspace_root);
985        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
986        std::fs::write(path, body).unwrap();
987    }
988
989    #[test]
990    fn load_returns_not_initialised_when_memstead_dir_absent() {
991        let tmp = TempDir::new().unwrap();
992        let store = FileWorkspaceStore::new();
993        let err = store.load(tmp.path()).unwrap_err();
994        assert!(matches!(err, StoreError::NotInitialised { .. }));
995    }
996
997    /// The [`parse_workspace_settings`] helper reads only
998    /// `.memstead/workspace.toml` and returns a fresh `WorkspaceSettings`.
999    /// MCP-driven policy mutations call this after writing to disk
1000    /// to refresh the engine's in-memory cache without re-loading
1001    /// the engine-managed `mounts.json`.
1002    #[test]
1003    fn parse_workspace_settings_reflects_cross_mem_links_edit() {
1004        let tmp = TempDir::new().unwrap();
1005        write_workspace_toml(
1006            tmp.path(),
1007            r#"
1008format = "memstead-git-branch-2"
1009
1010[persistence_adapter]
1011name = "file-two-layer"
1012
1013[cross_mem_links]
1014team-a = ["team-b"]
1015"#,
1016        );
1017        let settings = super::parse_workspace_settings(tmp.path()).unwrap();
1018        assert!(
1019            settings.cross_mem_links.contains_key("team-a"),
1020            "initial parse must surface the team-a grant; got {:?}",
1021            settings.cross_mem_links
1022        );
1023
1024        // Mutate the file (simulating `workspace_config_edit::revoke_cross_link`).
1025        write_workspace_toml(
1026            tmp.path(),
1027            r#"
1028format = "memstead-git-branch-2"
1029
1030[persistence_adapter]
1031name = "file-two-layer"
1032
1033[cross_mem_links]
1034"#,
1035        );
1036        let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1037        assert!(
1038            refreshed.cross_mem_links.is_empty(),
1039            "refreshed parse must drop the team-a grant; got {:?}",
1040            refreshed.cross_mem_links
1041        );
1042    }
1043
1044    /// `parse_workspace_settings` surfaces
1045    /// `[[mem_management.create]]` / `[[mem_management.delete]]`
1046    /// rules so the engine's allowlist gate sees the post-mutation
1047    /// state immediately.
1048    #[test]
1049    fn parse_workspace_settings_reflects_allowlist_edit() {
1050        let tmp = TempDir::new().unwrap();
1051        write_workspace_toml(
1052            tmp.path(),
1053            r#"
1054format = "memstead-git-branch-2"
1055
1056[persistence_adapter]
1057name = "file-two-layer"
1058"#,
1059        );
1060        let initial = super::parse_workspace_settings(tmp.path()).unwrap();
1061        assert!(initial.mem_create_rules.is_empty());
1062
1063        // Mutate (simulating `workspace_config_edit::add_create_rule`).
1064        write_workspace_toml(
1065            tmp.path(),
1066            r#"
1067format = "memstead-git-branch-2"
1068
1069[persistence_adapter]
1070name = "file-two-layer"
1071
1072[[mem_management.create]]
1073pattern = "test-*"
1074schemas = ["default@1.0.0"]
1075"#,
1076        );
1077        let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1078        assert_eq!(refreshed.mem_create_rules.len(), 1);
1079        assert_eq!(refreshed.mem_create_rules[0].pattern, "test-*");
1080    }
1081
1082    #[test]
1083    fn load_returns_not_initialised_when_workspace_toml_missing() {
1084        let tmp = TempDir::new().unwrap();
1085        std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
1086        let store = FileWorkspaceStore::new();
1087        let err = store.load(tmp.path()).unwrap_err();
1088        assert!(matches!(err, StoreError::NotInitialised { .. }));
1089    }
1090
1091    #[test]
1092    fn load_with_no_mounts_yields_empty_mount_list() {
1093        let tmp = TempDir::new().unwrap();
1094        write_workspace_toml(
1095            tmp.path(),
1096            r#"
1097format = "memstead-git-branch-2"
1098
1099[persistence_adapter]
1100name = "file-two-layer"
1101"#,
1102        );
1103        let store = FileWorkspaceStore::new();
1104        let workspace = store.load(tmp.path()).unwrap();
1105        assert!(workspace.mounts.is_empty());
1106    }
1107
1108    #[test]
1109    fn load_with_no_mem_management_yields_empty_settings() {
1110        // Workspace.toml without a `[mem_management]` section
1111        // produces the default empty settings — mirrors full's
1112        // behaviour where missing rules mean "no agent-driven
1113        // mem create / delete allowed".
1114        let tmp = TempDir::new().unwrap();
1115        write_workspace_toml(
1116            tmp.path(),
1117            r#"
1118format = "memstead-git-branch-2"
1119
1120[persistence_adapter]
1121name = "file-two-layer"
1122"#,
1123        );
1124        let store = FileWorkspaceStore::new();
1125        let workspace = store.load(tmp.path()).unwrap();
1126        assert!(workspace.settings.mem_create_rules.is_empty());
1127        assert!(workspace.settings.mem_delete_rules.is_empty());
1128        assert!(workspace.settings.cross_mem_links.is_empty());
1129    }
1130
1131    #[test]
1132    fn load_picks_up_cross_mem_links_wildcard_and_list() {
1133        // [cross_mem_links] is parsed via CrossLinkValue::parse_toml
1134        // to handle the wildcard ("*") vs allowlist ([...]) shape that
1135        // serde untagged-enum decode can't express. Both shapes round
1136        // through to WorkspaceSettings.cross_mem_links.
1137        use memstead_schema::workspace_config::CrossLinkValue;
1138        let tmp = TempDir::new().unwrap();
1139        write_workspace_toml(
1140            tmp.path(),
1141            r#"
1142format = "memstead-git-branch-2"
1143
1144[persistence_adapter]
1145name = "file-two-layer"
1146
1147[cross_mem_links]
1148specs = "*"
1149engine = ["specs", "macos"]
1150locked = []
1151"#,
1152        );
1153        let store = FileWorkspaceStore::new();
1154        let workspace = store.load(tmp.path()).unwrap();
1155        let cvl = &workspace.settings.cross_mem_links;
1156        assert_eq!(cvl.len(), 3);
1157        assert_eq!(cvl.get("specs"), Some(&CrossLinkValue::Wildcard));
1158        assert_eq!(
1159            cvl.get("engine"),
1160            Some(&CrossLinkValue::List(vec![
1161                "specs".to_string(),
1162                "macos".to_string()
1163            ]))
1164        );
1165        assert_eq!(cvl.get("locked"), Some(&CrossLinkValue::List(vec![])));
1166    }
1167
1168    #[test]
1169    fn load_rejects_cross_mem_links_mixed_wildcard_and_names() {
1170        // The shared parser rejects `["*", "specs"]` — wildcard must
1171        // be the sole entry. The schema-crate's typed error lifts via
1172        // StoreError::Parse so the operator-facing error names the
1173        // exact key.
1174        let tmp = TempDir::new().unwrap();
1175        write_workspace_toml(
1176            tmp.path(),
1177            r#"
1178format = "memstead-git-branch-2"
1179
1180[persistence_adapter]
1181name = "file-two-layer"
1182
1183[cross_mem_links]
1184specs = ["*", "engine"]
1185"#,
1186        );
1187        let store = FileWorkspaceStore::new();
1188        let err = store.load(tmp.path()).unwrap_err();
1189        match err {
1190            StoreError::Parse { message, .. } => {
1191                assert!(message.contains("[cross_mem_links].specs"));
1192                assert!(message.contains("wildcard"));
1193            }
1194            other => panic!("expected StoreError::Parse, got {other:?}"),
1195        }
1196    }
1197
1198    #[test]
1199    fn load_picks_up_default_cross_links_on_create_rule() {
1200        // CreateRule.default_cross_links uses the same CrossLinkValue
1201        // parser. A rule with `default_cross_links = "*"` lifts to
1202        // CreateRuleSetting.default_cross_links = Some(Wildcard).
1203        use memstead_schema::workspace_config::CrossLinkValue;
1204        let tmp = TempDir::new().unwrap();
1205        write_workspace_toml(
1206            tmp.path(),
1207            r#"
1208format = "memstead-git-branch-2"
1209
1210[persistence_adapter]
1211name = "file-two-layer"
1212
1213[[mem_management.create]]
1214pattern = "exec-*"
1215schemas = ["default"]
1216default_cross_links = "*"
1217"#,
1218        );
1219        let store = FileWorkspaceStore::new();
1220        let workspace = store.load(tmp.path()).unwrap();
1221        let rule = &workspace.settings.mem_create_rules[0];
1222        assert_eq!(rule.pattern, "exec-*");
1223        assert_eq!(rule.default_cross_links, Some(CrossLinkValue::Wildcard));
1224    }
1225
1226    #[test]
1227    fn load_picks_up_mem_management_create_and_delete_rules() {
1228        // Operator-edited `[mem_management]` section flows through
1229        // FileWorkspaceStore::load into Workspace.settings; the
1230        // engine layer then propagates via Engine::set_settings.
1231        let tmp = TempDir::new().unwrap();
1232        write_workspace_toml(
1233            tmp.path(),
1234            r#"
1235format = "memstead-git-branch-2"
1236
1237[persistence_adapter]
1238name = "file-two-layer"
1239
1240[[mem_management.create]]
1241pattern = "exec-*"
1242schemas = ["default@1.0.0", "*"]
1243
1244[[mem_management.create]]
1245pattern = "scratch-*"
1246schemas = ["default"]
1247
1248[[mem_management.delete]]
1249pattern = "exec-*"
1250"#,
1251        );
1252        let store = FileWorkspaceStore::new();
1253        let workspace = store.load(tmp.path()).unwrap();
1254        assert_eq!(workspace.settings.mem_create_rules.len(), 2);
1255        assert_eq!(workspace.settings.mem_create_rules[0].pattern, "exec-*");
1256        assert_eq!(
1257            workspace.settings.mem_create_rules[0].schemas,
1258            vec!["default@1.0.0".to_string(), "*".to_string()]
1259        );
1260        assert_eq!(workspace.settings.mem_create_rules[1].pattern, "scratch-*");
1261        assert_eq!(workspace.settings.mem_delete_rules.len(), 1);
1262        assert_eq!(workspace.settings.mem_delete_rules[0].pattern, "exec-*");
1263    }
1264
1265    /// Dual-pin state survives the store round-trip: a mount carrying
1266    /// `migration_target` writes it to `mounts.json` and reads it
1267    /// back; settled mounts' entries stay byte-compatible (the key is
1268    /// skipped when `None`).
1269    #[test]
1270    fn save_state_round_trips_migration_target() {
1271        let tmp = TempDir::new().unwrap();
1272        write_workspace_toml(
1273            tmp.path(),
1274            "\nformat = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1275        );
1276        let store = FileWorkspaceStore::new();
1277        let mut migrating = folder_mount("specs", PathBuf::from("/work/mem"));
1278        migrating.migration_target = Some(pin("mig-b@0.1.0"));
1279        let settled = folder_mount("other", PathBuf::from("/work/other"));
1280        let original = Workspace {
1281            mounts: vec![migrating, settled],
1282            settings: WorkspaceSettings::default(),
1283        };
1284        store.save_state(tmp.path(), &original).unwrap();
1285        let raw =
1286            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1287        assert!(
1288            raw.contains("mig-b@0.1.0"),
1289            "migration_target must persist: {raw}"
1290        );
1291        assert_eq!(
1292            raw.matches("migration_target").count(),
1293            1,
1294            "settled mounts must omit the key entirely: {raw}"
1295        );
1296        let loaded = store.load(tmp.path()).unwrap();
1297        assert_eq!(loaded.mounts[0].migration_target, Some(pin("mig-b@0.1.0")));
1298        assert_eq!(loaded.mounts[1].migration_target, None);
1299    }
1300
1301    #[test]
1302    fn save_state_then_load_round_trips_mount_list() {
1303        let tmp = TempDir::new().unwrap();
1304        write_workspace_toml(
1305            tmp.path(),
1306            r#"
1307format = "memstead-git-branch-2"
1308
1309[persistence_adapter]
1310name = "file-two-layer"
1311"#,
1312        );
1313        let store = FileWorkspaceStore::new();
1314        let original = Workspace {
1315            mounts: vec![
1316                folder_mount("specs", PathBuf::from("/work/mem")),
1317                Mount {
1318                    mem: "engine".to_string(),
1319                    schema: Some(pin("default@1.0.0")),
1320                    storage: MountStorage::GitBranch {
1321                        gitdir: PathBuf::from("/work/mem-repo/.git"),
1322                        branch: "engine".to_string(),
1323                    },
1324                    capability: MountCapability::Write,
1325                    lifecycle: MountLifecycle::Eager,
1326                    cross_linkable: true,
1327                    migration_target: None,
1328                },
1329                Mount {
1330                    mem: "external".to_string(),
1331                    schema: Some(pin("default@1.0.0")),
1332                    storage: MountStorage::Archive {
1333                        path: PathBuf::from("/deps/external.mem"),
1334                    },
1335                    capability: MountCapability::ReadOnly,
1336                    lifecycle: MountLifecycle::Lazy,
1337                    cross_linkable: false,
1338                    migration_target: None,
1339                },
1340            ],
1341            settings: WorkspaceSettings::default(),
1342        };
1343        store.save_state(tmp.path(), &original).unwrap();
1344
1345        // The mounts.json file lives where we expect.
1346        assert!(FileWorkspaceStore::mounts_json_path(tmp.path()).is_file());
1347
1348        let reloaded = store.load(tmp.path()).unwrap();
1349        assert_eq!(reloaded.mounts.len(), original.mounts.len());
1350        for (a, b) in reloaded.mounts.iter().zip(original.mounts.iter()) {
1351            assert_eq!(a.mem, b.mem);
1352            assert_eq!(a.schema, b.schema);
1353            assert_eq!(a.capability, b.capability);
1354            assert_eq!(a.lifecycle, b.lifecycle);
1355            assert_eq!(a.cross_linkable, b.cross_linkable);
1356            assert_eq!(a.storage, b.storage);
1357        }
1358    }
1359
1360    #[test]
1361    fn save_state_round_trips_unset_schema_assertion() {
1362        // `Mount.schema = None` (no expectation assertion) must survive a
1363        // mounts.json round-trip and omit the `schema` key on the wire —
1364        // the authoritative pin lives in the mem's backend config, not
1365        // in the mount record.
1366        let tmp = TempDir::new().unwrap();
1367        write_workspace_toml(
1368            tmp.path(),
1369            r#"
1370format = "memstead-git-branch-2"
1371
1372[persistence_adapter]
1373name = "file-two-layer"
1374"#,
1375        );
1376        let store = FileWorkspaceStore::new();
1377        let original = Workspace {
1378            mounts: vec![Mount {
1379                mem: "foreign".to_string(),
1380                schema: None,
1381                storage: MountStorage::Folder {
1382                    path: tmp.path().join("foreign"),
1383                },
1384                capability: MountCapability::ReadOnly,
1385                lifecycle: MountLifecycle::Eager,
1386                cross_linkable: false,
1387                migration_target: None,
1388            }],
1389            settings: WorkspaceSettings::default(),
1390        };
1391        store.save_state(tmp.path(), &original).unwrap();
1392
1393        // The wire form omits the `schema` key entirely (skip-on-None).
1394        let raw =
1395            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1396        assert!(
1397            !raw.contains("\"schema\""),
1398            "unset schema assertion must omit the key on the wire; got:\n{raw}"
1399        );
1400
1401        // And it reloads as `None`.
1402        let reloaded = store.load(tmp.path()).unwrap();
1403        assert_eq!(reloaded.mounts.len(), 1);
1404        assert_eq!(reloaded.mounts[0].schema, None);
1405    }
1406
1407    #[test]
1408    fn save_state_does_not_touch_workspace_toml() {
1409        let tmp = TempDir::new().unwrap();
1410        let original_body = r#"
1411format = "memstead-git-branch-2"
1412
1413[persistence_adapter]
1414name = "file-two-layer"
1415"#;
1416        write_workspace_toml(tmp.path(), original_body);
1417        let store = FileWorkspaceStore::new();
1418        let workspace = Workspace::default();
1419        store.save_state(tmp.path(), &workspace).unwrap();
1420        // Operator's TOML untouched.
1421        let toml_after =
1422            std::fs::read_to_string(FileWorkspaceStore::workspace_toml_path(tmp.path())).unwrap();
1423        assert_eq!(toml_after, original_body);
1424    }
1425
1426    #[test]
1427    fn save_state_writes_paths_relative_to_workspace_root() {
1428        let tmp = TempDir::new().unwrap();
1429        write_workspace_toml(
1430            tmp.path(),
1431            r#"
1432format = "memstead-git-branch-2"
1433
1434[persistence_adapter]
1435name = "file-two-layer"
1436"#,
1437        );
1438        let store = FileWorkspaceStore::new();
1439        let workspace = Workspace {
1440            mounts: vec![
1441                Mount {
1442                    mem: "engine".to_string(),
1443                    schema: Some(pin("default@1.0.0")),
1444                    storage: MountStorage::GitBranch {
1445                        gitdir: tmp.path().join("mem-repo").join(".git"),
1446                        branch: "engine".to_string(),
1447                    },
1448                    capability: MountCapability::Write,
1449                    lifecycle: MountLifecycle::Eager,
1450                    cross_linkable: true,
1451                    migration_target: None,
1452                },
1453                Mount {
1454                    mem: "external".to_string(),
1455                    schema: Some(pin("default@1.0.0")),
1456                    storage: MountStorage::Archive {
1457                        path: PathBuf::from("/global/cache/external.mem"),
1458                    },
1459                    capability: MountCapability::ReadOnly,
1460                    lifecycle: MountLifecycle::Lazy,
1461                    cross_linkable: false,
1462                    migration_target: None,
1463                },
1464            ],
1465            settings: WorkspaceSettings::default(),
1466        };
1467        store.save_state(tmp.path(), &workspace).unwrap();
1468
1469        let on_disk =
1470            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1471        // Format bumped to V2.
1472        assert!(on_disk.contains("\"memstead-mounts-3\""));
1473        // In-workspace path stored relative — no absolute prefix bake-in.
1474        assert!(
1475            on_disk.contains("\"mem-repo/.git\""),
1476            "expected relative gitdir, got: {on_disk}"
1477        );
1478        assert!(
1479            !on_disk.contains(tmp.path().to_str().unwrap()),
1480            "in-workspace path should not include the absolute tmp prefix: {on_disk}"
1481        );
1482        // Out-of-workspace path kept absolute (fallback for shared caches / external archives).
1483        assert!(on_disk.contains("\"/global/cache/external.mem\""));
1484
1485        // Re-load reconstructs absolute paths.
1486        let reloaded = store.load(tmp.path()).unwrap();
1487        match &reloaded.mounts[0].storage {
1488            MountStorage::GitBranch { gitdir, .. } => {
1489                assert_eq!(gitdir, &tmp.path().join("mem-repo").join(".git"));
1490            }
1491            other => panic!("expected GitBranch storage, got {other:?}"),
1492        }
1493        match &reloaded.mounts[1].storage {
1494            MountStorage::Archive { path } => {
1495                assert_eq!(path, &PathBuf::from("/global/cache/external.mem"));
1496            }
1497            other => panic!("expected Archive storage, got {other:?}"),
1498        }
1499    }
1500
1501    #[test]
1502    fn load_absolute_inside_root_path_then_save_rewrites_relative() {
1503        let tmp = TempDir::new().unwrap();
1504        write_workspace_toml(
1505            tmp.path(),
1506            r#"
1507format = "memstead-git-branch-2"
1508
1509[persistence_adapter]
1510name = "file-two-layer"
1511"#,
1512        );
1513        // Hand-write a mounts.json carrying an absolute gitdir that
1514        // lives inside this workspace_root — simulates the "committed
1515        // by another operator's home dir" failure mode that motivated
1516        // relative serialisation.
1517        let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1518        std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1519        let abs_gitdir = tmp.path().join("mem-repo").join(".git");
1520        let mounts_body = format!(
1521            r#"{{
1522  "format": "memstead-mounts-3",
1523  "mounts": [
1524    {{
1525      "mem": "engine",
1526      "schema": "default@1.0.0",
1527      "storage": {{
1528        "type": "git-branch",
1529        "gitdir": "{}",
1530        "branch": "engine"
1531      }},
1532      "capability": "write",
1533      "lifecycle": "eager",
1534      "cross_linkable": true
1535    }}
1536  ]
1537}}"#,
1538            abs_gitdir.to_str().unwrap()
1539        );
1540        std::fs::write(&mounts_path, &mounts_body).unwrap();
1541
1542        let store = FileWorkspaceStore::new();
1543        // The reader accepts the file; the absolute path round-trips
1544        // untouched (it's already absolute).
1545        let workspace = store.load(tmp.path()).unwrap();
1546        match &workspace.mounts[0].storage {
1547            MountStorage::GitBranch { gitdir, .. } => assert_eq!(gitdir, &abs_gitdir),
1548            other => panic!("expected GitBranch storage, got {other:?}"),
1549        }
1550
1551        // Saving the same workspace rewrites the file with a relative
1552        // path — self-healing, no explicit command needed.
1553        store.save_state(tmp.path(), &workspace).unwrap();
1554        let on_disk = std::fs::read_to_string(&mounts_path).unwrap();
1555        assert!(on_disk.contains("\"memstead-mounts-3\""));
1556        assert!(on_disk.contains("\"mem-repo/.git\""));
1557        assert!(!on_disk.contains(tmp.path().to_str().unwrap()));
1558    }
1559
1560    /// Item 03 round-trip: writing a `MountStorage::GitBranch` mount
1561    /// whose `branch` already carries the canonical `refs/heads/<leaf>`
1562    /// form serialises that exact string into `mounts.json` (no
1563    /// rewrite, no truncation), and reading the file back produces a
1564    /// `Mount` whose in-memory `branch` equals the input verbatim. The
1565    /// write path is the one source of truth for the on-disk shape — a
1566    /// regression that re-introduces short-form writes would surface
1567    /// here as a mismatch against `refs/heads/demo/engine`.
1568    #[test]
1569    fn save_state_preserves_refs_heads_branch_form() {
1570        let tmp = TempDir::new().unwrap();
1571        write_workspace_toml(
1572            tmp.path(),
1573            r#"
1574format = "memstead-git-branch-2"
1575
1576[persistence_adapter]
1577name = "file-two-layer"
1578"#,
1579        );
1580        let store = FileWorkspaceStore::new();
1581        let original = Workspace {
1582            mounts: vec![Mount {
1583                mem: "engine".to_string(),
1584                schema: Some(pin("default@1.0.0")),
1585                storage: MountStorage::GitBranch {
1586                    gitdir: tmp.path().join("mem-repo").join(".git"),
1587                    branch: "refs/heads/demo/engine".to_string(),
1588                },
1589                capability: MountCapability::Write,
1590                lifecycle: MountLifecycle::Eager,
1591                cross_linkable: true,
1592                migration_target: None,
1593            }],
1594            settings: WorkspaceSettings::default(),
1595        };
1596        store.save_state(tmp.path(), &original).unwrap();
1597
1598        let on_disk =
1599            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1600        assert!(
1601            on_disk.contains("\"branch\": \"refs/heads/demo/engine\""),
1602            "expected fully-qualified ref on disk, got: {on_disk}"
1603        );
1604
1605        let reloaded = store.load(tmp.path()).unwrap();
1606        match &reloaded.mounts[0].storage {
1607            MountStorage::GitBranch { branch, .. } => {
1608                assert_eq!(branch, "refs/heads/demo/engine");
1609            }
1610            other => panic!("expected GitBranch storage, got {other:?}"),
1611        }
1612    }
1613
1614    /// Item 03 reader tolerance: a hand-written legacy `mounts.json`
1615    /// whose `branch` field carries the short-form leaf (no
1616    /// `refs/heads/` prefix) loads without error, and the in-memory
1617    /// `Mount` carries the input string intact. The reader does not
1618    /// silently normalise — backend factories are responsible for
1619    /// fully-qualifying short forms at instantiation time. This pin
1620    /// guards against an over-eager normaliser landing on the read
1621    /// path and masking out the legacy shape that older committed
1622    /// `mounts.json` files used to carry.
1623    #[test]
1624    fn load_preserves_short_form_branch_without_rewrite() {
1625        let tmp = TempDir::new().unwrap();
1626        write_workspace_toml(
1627            tmp.path(),
1628            r#"
1629format = "memstead-git-branch-2"
1630
1631[persistence_adapter]
1632name = "file-two-layer"
1633"#,
1634        );
1635        let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1636        std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1637        std::fs::write(
1638            &mounts_path,
1639            r#"{
1640  "format": "memstead-mounts-3",
1641  "mounts": [
1642    {
1643      "mem": "engine",
1644      "schema": "default@1.0.0",
1645      "storage": {
1646        "type": "git-branch",
1647        "gitdir": "mem-repo/.git",
1648        "branch": "demo/engine"
1649      },
1650      "capability": "write",
1651      "lifecycle": "eager",
1652      "cross_linkable": true
1653    }
1654  ]
1655}"#,
1656        )
1657        .unwrap();
1658
1659        let store = FileWorkspaceStore::new();
1660        let workspace = store.load(tmp.path()).unwrap();
1661        match &workspace.mounts[0].storage {
1662            MountStorage::GitBranch { branch, .. } => {
1663                assert_eq!(
1664                    branch, "demo/engine",
1665                    "reader must not silently rewrite short-form branch"
1666                );
1667            }
1668            other => panic!("expected GitBranch storage, got {other:?}"),
1669        }
1670    }
1671
1672    #[test]
1673    fn load_rejects_format_version_mismatch_on_toml() {
1674        let tmp = TempDir::new().unwrap();
1675        write_workspace_toml(
1676            tmp.path(),
1677            r#"
1678format = "memstead-git-branch-99"
1679
1680[persistence_adapter]
1681name = "file-two-layer"
1682"#,
1683        );
1684        let store = FileWorkspaceStore::new();
1685        let err = store.load(tmp.path()).unwrap_err();
1686        match err {
1687            StoreError::FormatMismatch {
1688                expected, found, ..
1689            } => {
1690                assert_eq!(expected, "memstead-git-branch-2");
1691                assert_eq!(found, "memstead-git-branch-99");
1692            }
1693            other => panic!("expected FormatMismatch, got {other:?}"),
1694        }
1695    }
1696
1697    #[test]
1698    fn load_rejects_format_version_mismatch_on_mounts_json() {
1699        let tmp = TempDir::new().unwrap();
1700        write_workspace_toml(
1701            tmp.path(),
1702            r#"
1703format = "memstead-git-branch-2"
1704
1705[persistence_adapter]
1706name = "file-two-layer"
1707"#,
1708        );
1709        let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1710        std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1711        std::fs::write(
1712            &mounts_path,
1713            r#"{ "format": "memstead-mounts-99", "mounts": [] }"#,
1714        )
1715        .unwrap();
1716        let store = FileWorkspaceStore::new();
1717        let err = store.load(tmp.path()).unwrap_err();
1718        assert!(matches!(err, StoreError::FormatMismatch { .. }));
1719    }
1720
1721    /// A pre-rename workspace.toml (format V1) refuses with the typed
1722    /// LegacyLayout error — it must not boot empty or half-parsed.
1723    #[test]
1724    fn load_refuses_pre_rename_toml_as_legacy_layout() {
1725        let tmp = TempDir::new().unwrap();
1726        write_workspace_toml(
1727            tmp.path(),
1728            r#"
1729format = "memstead-git-branch-1"
1730
1731[persistence_adapter]
1732name = "file-two-layer"
1733"#,
1734        );
1735        let store = FileWorkspaceStore::new();
1736        let err = store.load(tmp.path()).unwrap_err();
1737        match err {
1738            StoreError::LegacyLayout { found, .. } => {
1739                assert_eq!(found, "memstead-git-branch-1");
1740            }
1741            other => panic!("expected LegacyLayout, got {other:?}"),
1742        }
1743    }
1744
1745    /// Pre-rename mounts.json formats refuse with LegacyLayout even
1746    /// though their records no longer deserialise (old unit-noun
1747    /// field name) — the format probe must win over the record-level
1748    /// parse error so the agent sees the migration hint, not a serde
1749    /// message. The fixture's record deliberately lacks the `mem`
1750    /// field to prove the full parse is never reached.
1751    #[test]
1752    fn load_refuses_pre_rename_mounts_json_as_legacy_layout() {
1753        for legacy in ["memstead-mounts-1", "memstead-mounts-2"] {
1754            let tmp = TempDir::new().unwrap();
1755            write_workspace_toml(
1756                tmp.path(),
1757                r#"
1758format = "memstead-git-branch-2"
1759
1760[persistence_adapter]
1761name = "file-two-layer"
1762"#,
1763            );
1764            let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1765            std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1766            std::fs::write(
1767                &mounts_path,
1768                format!(
1769                    r#"{{ "format": "{legacy}", "mounts": [{{ "unit": "notes", "storage": {{ "type": "folder", "path": "notes" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#
1770                ),
1771            )
1772            .unwrap();
1773            let store = FileWorkspaceStore::new();
1774            let err = store.load(tmp.path()).unwrap_err();
1775            match err {
1776                StoreError::LegacyLayout { found, .. } => assert_eq!(found, legacy),
1777                other => panic!("expected LegacyLayout for {legacy}, got {other:?}"),
1778            }
1779        }
1780    }
1781
1782    #[test]
1783    fn load_rejects_invalid_toml() {
1784        let tmp = TempDir::new().unwrap();
1785        write_workspace_toml(tmp.path(), "this is not = valid = toml");
1786        let store = FileWorkspaceStore::new();
1787        let err = store.load(tmp.path()).unwrap_err();
1788        assert!(matches!(err, StoreError::Parse { .. }));
1789    }
1790
1791    #[test]
1792    fn load_rejects_unknown_top_level_key() {
1793        // The workspace config's contract (and its shipped example's
1794        // claim) is that typos never pass silently — at the top level,
1795        // not just inside [mcp]/[mutations].
1796        let tmp = TempDir::new().unwrap();
1797        write_workspace_toml(
1798            tmp.path(),
1799            "format = \"memstead-git-branch-2\"\nnonexistent_key = true\n",
1800        );
1801        let store = FileWorkspaceStore::new();
1802        let err = store.load(tmp.path()).unwrap_err();
1803        match err {
1804            StoreError::Parse { message, .. } => {
1805                assert!(
1806                    message.contains("nonexistent_key"),
1807                    "refusal must name the unknown key: {message}"
1808                );
1809            }
1810            other => panic!("expected Parse error, got {other:?}"),
1811        }
1812    }
1813
1814    #[test]
1815    fn instantiate_lean_backend_handles_folder_archive_and_in_memory() {
1816        let tmp = TempDir::new().unwrap();
1817        let folder = folder_mount("local", tmp.path().to_path_buf());
1818        let archive_path = tmp.path().join("ext.mem");
1819        // Make a minimal valid zip so the archive backend can open it.
1820        let f = std::fs::File::create(&archive_path).unwrap();
1821        let mut w = zip::ZipWriter::new(f);
1822        w.start_file("a.md", zip::write::SimpleFileOptions::default())
1823            .unwrap();
1824        w.write_all(b"# a").unwrap();
1825        w.finish().unwrap();
1826        let archive = Mount {
1827            mem: "external".to_string(),
1828            schema: Some(pin("default@1.0.0")),
1829            storage: MountStorage::Archive { path: archive_path },
1830            capability: MountCapability::ReadOnly,
1831            lifecycle: MountLifecycle::Lazy,
1832            cross_linkable: false,
1833            migration_target: None,
1834        };
1835        let in_memory = Mount {
1836            mem: "session".to_string(),
1837            schema: Some(pin("default@1.0.0")),
1838            storage: MountStorage::InMemory,
1839            capability: MountCapability::Write,
1840            lifecycle: MountLifecycle::Eager,
1841            cross_linkable: true,
1842            migration_target: None,
1843        };
1844
1845        let _: Box<dyn MemBackend> = instantiate_lean_backend(&folder).unwrap();
1846        let _: Box<dyn MemBackend> = instantiate_lean_backend(&archive).unwrap();
1847        // The in-memory variant is a lean backend — no feature gate,
1848        // no path, materialises directly.
1849        let _: Box<dyn MemBackend> = instantiate_lean_backend(&in_memory).unwrap();
1850    }
1851
1852    /// AC3 (plan 01): the in-memory storage variant round-trips through
1853    /// `mounts.json` and its wire shape is unambiguous — it serialises
1854    /// as the bare `{"type":"in-memory"}` tag and never parses as one
1855    /// of the path-carrying variants, nor they as it.
1856    #[test]
1857    fn save_state_round_trips_in_memory_variant_unambiguously() {
1858        let tmp = TempDir::new().unwrap();
1859        write_workspace_toml(
1860            tmp.path(),
1861            r#"
1862format = "memstead-git-branch-2"
1863
1864[persistence_adapter]
1865name = "file-two-layer"
1866"#,
1867        );
1868        let store = FileWorkspaceStore::new();
1869        let original = Workspace {
1870            mounts: vec![
1871                folder_mount("local", PathBuf::from("/work/mem")),
1872                Mount {
1873                    mem: "session".to_string(),
1874                    schema: Some(pin("default@1.0.0")),
1875                    storage: MountStorage::InMemory,
1876                    capability: MountCapability::Write,
1877                    lifecycle: MountLifecycle::Eager,
1878                    cross_linkable: true,
1879                    migration_target: None,
1880                },
1881            ],
1882            settings: WorkspaceSettings::default(),
1883        };
1884        store.save_state(tmp.path(), &original).unwrap();
1885
1886        // On the wire it is the bare tag — no `path`, no `gitdir`.
1887        let raw =
1888            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1889        assert!(raw.contains("\"type\": \"in-memory\""), "got: {raw}");
1890
1891        let reloaded = store.load(tmp.path()).unwrap();
1892        assert_eq!(reloaded.mounts.len(), 2);
1893        // The in-memory mount round-trips back to exactly InMemory —
1894        // not silently reinterpreted as a folder/archive/git variant.
1895        let session = reloaded
1896            .mounts
1897            .iter()
1898            .find(|m| m.mem == "session")
1899            .expect("session mount survives reload");
1900        assert_eq!(session.storage, MountStorage::InMemory);
1901        // And the sibling folder mount is untouched — the two wire
1902        // shapes do not bleed into each other.
1903        let local = reloaded.mounts.iter().find(|m| m.mem == "local").unwrap();
1904        assert!(matches!(local.storage, MountStorage::Folder { .. }));
1905    }
1906
1907    #[test]
1908    fn instantiate_lean_backend_rejects_git_branch_with_typed_error() {
1909        let mount = Mount {
1910            mem: "engine".to_string(),
1911            schema: Some(pin("default@1.0.0")),
1912            storage: MountStorage::GitBranch {
1913                gitdir: PathBuf::from("/some/path/.git"),
1914                branch: "engine".to_string(),
1915            },
1916            capability: MountCapability::Write,
1917            lifecycle: MountLifecycle::Eager,
1918            cross_linkable: true,
1919            migration_target: None,
1920        };
1921        // `unwrap_err()` requires Box<dyn MemBackend> to be Debug;
1922        // matching on the Result keeps the test gix-free of that
1923        // bound while still asserting the typed error.
1924        match instantiate_lean_backend(&mount) {
1925            Err(InstantiateError::GitBranchRequiresMemRepoFeature { mem }) => {
1926                assert_eq!(mem, "engine");
1927            }
1928            Ok(_) => panic!("expected GitBranchRequiresMemRepoFeature, got Ok"),
1929        }
1930    }
1931
1932    #[test]
1933    fn detect_layout_returns_empty_for_unrecognised_workspace() {
1934        let tmp = TempDir::new().unwrap();
1935        assert_eq!(detect_layout(tmp.path()), Layout::Empty);
1936    }
1937    #[test]
1938    fn detect_layout_returns_new_when_workspace_toml_present() {
1939        let tmp = TempDir::new().unwrap();
1940        write_workspace_toml(
1941            tmp.path(),
1942            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1943        );
1944        assert_eq!(detect_layout(tmp.path()), Layout::New);
1945    }
1946}