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