Skip to main content

memstead_base/
workspace.rs

1//! Workspace concept — first-class in `memstead-base` after the
2//! workspace-store rebuild.
3//!
4//! A [`Workspace`] is the operator-curated collection of mounts;
5//! each [`Mount`] attaches one mem to the workspace via a storage
6//! backend (folder / git-branch / archive). One mount = one mem:
7//! five mems living on five branches in one git-repo materialise as
8//! five mounts; the engine pools the gitdir handle internally for the
9//! shared backend rather than collapsing the conceptual mount.
10//!
11//! This module ships the data shapes only. The persistence adapter
12//! that materialises a `Workspace` from `.memstead/workspace.toml` +
13//! `.memstead/state/mounts.json` lands separately as the file-adapter
14//! sessions move forward; tests and in-memory builders construct
15//! `Workspace` directly without going through any adapter.
16//!
17//! Distinct from [`crate::mem::MemRouterSnapshot`], which is the
18//! engine's *runtime* snapshot of writable / visible mems. The
19//! engine derives a `MemRouterSnapshot` from a `Workspace` at boot;
20//! the two coexist while the rebuild is in flight.
21
22use std::collections::{BTreeMap, HashMap};
23use std::path::PathBuf;
24
25use memstead_schema::SchemaRef;
26use memstead_schema::workspace_config::CrossLinkValue;
27use serde::{Deserialize, Serialize};
28
29/// A single mem attachment in a [`Workspace`]. One mount = one
30/// mem. The schema pin is on the mount because per-mem schema
31/// resolution is fixed in code (local-storage → built-in → registry,
32/// with the storage layer owning where "local" lives — see the
33/// glossary's *Schema* entry); the mount carries which schema this
34/// mem expects, the backend resolves where the YAMLs come from.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Mount {
37    /// Operator-facing mem name within this workspace.
38    pub mem: String,
39    /// Optional *expectation assertion* about this mem's schema pin
40    /// (exact `<name>@<version>`). The authoritative pin is the mem's
41    /// own `MemConfig.schema` on its storage backend; boot/load
42    /// resolve from there. This field is a workspace-local cross-check —
43    /// useful for foreign or read-only mounts. `None` means "assert
44    /// nothing, trust the backend config". When `Some` and mismatching
45    /// the config pin, loading surfaces a `SchemaPinMismatch` finding
46    /// naming both values; neither is silently preferred. Resolution
47    /// falls back to this value only when the backend config carries no
48    /// pin.
49    pub schema: Option<SchemaRef>,
50    /// Backend-specific reference to the mem's content.
51    pub storage: MountStorage,
52    /// Read-only or writable attachment.
53    pub capability: MountCapability,
54    /// Eager (open the backend at engine start) or lazy (entity load
55    /// deferred to first read) — see [`MountLifecycle`] for the full
56    /// contract. Behavioural since flywheel W7/01; opt-in per mount.
57    pub lifecycle: MountLifecycle,
58    /// Whether other mounts in the same workspace may form
59    /// cross-mem edges into this mount. Workspace-level cross-mem
60    /// permission policy can override.
61    pub cross_linkable: bool,
62    /// In-flight schema migration target. `Some(target)` puts the
63    /// mem in dual-pin state: writes validate against `target`
64    /// (the engine's effective validation schema), reads stay
65    /// permissive, and `schema` remains the settled pin until every
66    /// entity is integral against the target — then the atomic
67    /// switch sets `schema = target` and clears this field in one
68    /// workspace-store write. Persisted so a long migration is
69    /// resumable across engine restarts.
70    pub migration_target: Option<SchemaRef>,
71}
72
73impl Mount {
74    /// Hierarchical organisational path for this mount, or `None` for
75    /// flat layout / non-hierarchical storage. Mirrors the
76    /// `MemCreateParams.path` create-side input — at delete time the
77    /// lifecycle candidate composes as `<mem_path>/<name>` (or
78    /// `<name>` alone when `None`) to match the create-side rule.
79    ///
80    /// Derivation: `MountStorage::GitBranch` carries the path in its
81    /// `branch` field. Tolerates both fully-qualified
82    /// `refs/heads/<mem_path>/<mem>` (the shape `create_mem`
83    /// produces) and bare `<mem_path>/<mem>` (the shape
84    /// `mounts.json` operator-edited entries carry) — full's
85    /// `instantiate_full_backend` already normalises both forms. Strip
86    /// the optional `refs/heads/` prefix and the trailing `<mem>`
87    /// leaf. `Folder` / `Archive` carry no hierarchical path on the
88    /// storage variant — runtime callers that know the create-time
89    /// `path` plumb it directly into the router via
90    /// `Engine::register_writable_mem`.
91    pub fn mem_path(&self) -> Option<String> {
92        match &self.storage {
93            MountStorage::GitBranch { branch, .. } => {
94                let leaf = branch
95                    .strip_prefix("refs/heads/")
96                    .unwrap_or(branch.as_str());
97                let after_leaf = leaf.strip_suffix(&self.mem)?;
98                let trimmed = after_leaf.trim_end_matches('/');
99                if trimmed.is_empty() {
100                    None
101                } else {
102                    Some(trimmed.to_string())
103                }
104            }
105            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
106                None
107            }
108        }
109    }
110}
111
112/// Branch name of the mem-repo's unified engine ref — schemas plus
113/// per-mem configs in one tree. Not a mem: it has no mount, and the
114/// transport verbs that walk every ref (`push --all`) carry it by
115/// name.
116pub const MEMSTEAD_REF_BRANCH: &str = "__MEMSTEAD";
117
118/// A mount's declared branch as a fully-qualified local ref. The
119/// `branch` field tolerates both `refs/heads/<path>` (used verbatim,
120/// any `refs/` value is) and bare `<path>` (prefixed) — the same
121/// normalisation `instantiate_full_backend` applies. Every operation
122/// that needs the mem's local ref derives it from the declared branch
123/// through this function; nothing reconstructs a ref from the mem
124/// name.
125pub fn branch_full_ref(branch: &str) -> String {
126    if branch.starts_with("refs/") {
127        branch.to_string()
128    } else {
129        format!("refs/heads/{branch}")
130    }
131}
132
133/// A mount's declared branch as the short name remote-tracking refs
134/// use (`refs/remotes/<remote>/<short>`): the `refs/heads/` prefix
135/// stripped when present, the value verbatim otherwise.
136pub fn branch_short_name(branch: &str) -> &str {
137    branch.strip_prefix("refs/heads/").unwrap_or(branch)
138}
139
140/// Storage reference for a [`Mount`]. One variant per
141/// [`crate::backend::MemBackend`] implementation. New backends add
142/// a variant; the file-adapter learns to round-trip it.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum MountStorage {
145    /// Folder backend — mem lives as a directory tree on disk.
146    /// The mem root may be the workspace root itself (collapsed
147    /// single-mem form: `.memstead/config.json` at root, no `mems/`
148    /// subfolder) or a sibling mem subfolder.
149    Folder {
150        /// Absolute path to the mem root directory.
151        path: PathBuf,
152    },
153    /// Git-branch backend — mem lives as a branch in a mem-repo
154    /// gitdir. Multi-repo workspaces are supported by varying
155    /// `gitdir` across mounts (see the *Storage backend* glossary
156    /// entry's *per-mount git-repo* block for the trade-offs).
157    GitBranch {
158        /// Absolute path to the gitdir
159        /// (typically `<workspace>/mem-repo/.git`).
160        gitdir: PathBuf,
161        /// Branch name within the gitdir holding the mem content.
162        branch: String,
163    },
164    /// Archive backend — mem lives inside a sealed `.mem` zip archive.
165    /// Always read-only; mounts of this storage carry
166    /// [`MountCapability::ReadOnly`].
167    Archive {
168        /// Absolute path to the sealed archive file.
169        path: PathBuf,
170    },
171    /// In-memory backend — mem lives entirely in RAM, with no
172    /// filesystem path and no git. Created empty, dropped with the
173    /// engine, leaving no on-disk residue. Serves ephemeral
174    /// per-session playground mems. Carries no fields: there is
175    /// nothing to locate on disk, and the backend holds all state
176    /// itself (see [`crate::storage::InMemoryBackend`]).
177    InMemory,
178}
179
180impl MountStorage {
181    /// Stable kebab-case backend identifier surfaced in error envelopes
182    /// (e.g. `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND`'s `active_backend`
183    /// detail) and in the on-disk `mounts.json` serialisation. The
184    /// kebab-case form matches the `MountStorageWire` `#[serde(tag,
185    /// rename_all = "kebab-case")]` tag.
186    pub fn backend_id(&self) -> &'static str {
187        match self {
188            MountStorage::Folder { .. } => "folder",
189            MountStorage::GitBranch { .. } => "git-branch",
190            MountStorage::Archive { .. } => "archive",
191            MountStorage::InMemory => "in-memory",
192        }
193    }
194
195    /// Whether writes (and, for read-only backends, the loaded content)
196    /// survive process restart / session-TTL eviction. `Folder`,
197    /// `GitBranch`, and `Archive` all live on disk and persist; only
198    /// `InMemory` is volatile — its state is dropped with the engine,
199    /// so a `write_id` it returns denotes nothing durable. This is the
200    /// fact the durability marker projects: derived from the storage
201    /// *kind*, not from `current_head()` (which is `None` for both
202    /// `Folder` and `InMemory` and so cannot tell them apart).
203    /// How the durability answer for this storage was arrived at.
204    ///
205    /// [`Self::is_durable`] answers from the storage KIND, which is a real
206    /// answer to a narrow question (does a write survive process restart)
207    /// and is routinely read as a broader one (is the write recorded
208    /// somewhere it could be recovered from). Callers cannot tell the two
209    /// apart from a bare boolean, so the basis travels with it (04/04,
210    /// criterion 7). The marker itself is unchanged and stays.
211    pub fn durability_basis(&self, head: Option<&str>) -> DurabilityBasis {
212        match self {
213            // A real commit object, named by a backend that HAS commits. The
214            // storage kind gates this on purpose: a folder backend's
215            // `current_head()` is its change ledger's last timestamp, not a
216            // commit, so keying only on "a head exists" reported `established`
217            // for every folder mem that had ever been written — strictly
218            // stronger than the mount-kind answer this field was added to
219            // qualify, which is the misreading it exists to prevent (04/04,
220            // criteria 6 and 7, found by the plan's grade).
221            MountStorage::GitBranch { .. } if head.is_some_and(|h| !h.is_empty()) => {
222                DurabilityBasis::Established
223            }
224            _ => DurabilityBasis::InferredFromMountKind,
225        }
226    }
227
228    pub fn is_durable(&self) -> bool {
229        match self {
230            MountStorage::Folder { .. }
231            | MountStorage::GitBranch { .. }
232            | MountStorage::Archive { .. } => true,
233            MountStorage::InMemory => false,
234        }
235    }
236}
237
238/// Whether a durability answer was established or inferred.
239///
240/// The distinction exists because the engine's answer is derived from the
241/// mount kind, which is honest about surviving a restart and says nothing
242/// about the write having reached version control. A folder mem is the case
243/// that matters: its writes land on disk, so `durable` is true, and whether
244/// anything could recover them is a question the engine cannot answer at all.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
246#[serde(rename_all = "kebab-case")]
247pub enum DurabilityBasis {
248    /// The backend named a real commit for this write, so it is recorded.
249    Established,
250    /// Read off the storage kind. True for surviving a restart; silent on
251    /// whether the write is recorded anywhere it could be recovered from.
252    InferredFromMountKind,
253}
254
255impl DurabilityBasis {
256    pub fn as_wire(&self) -> &'static str {
257        match self {
258            DurabilityBasis::Established => "established",
259            DurabilityBasis::InferredFromMountKind => "inferred-from-mount-kind",
260        }
261    }
262}
263
264/// What the workspace may do with a mount.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum MountCapability {
267    /// Mutations rejected — the engine surfaces a typed read-only
268    /// error before reaching the backend.
269    ReadOnly,
270    /// Full read + write.
271    Write,
272}
273
274/// When the mount's backend initialises.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum MountLifecycle {
277    /// Open the backend at engine start.
278    Eager,
279    /// Defer the ENTITY load until the first operation that needs the
280    /// mem. The metadata half (config, provenance, schema pin) still
281    /// resolves at boot, so the mem is on the roster with its pin —
282    /// present, never silently absent — and a broken pin quarantines at
283    /// boot exactly as an eager mount's would. The first read triggers
284    /// the load through the per-operation funnel
285    /// (`Engine::ensure_mems_loaded`, called by `reload_if_stale`),
286    /// running the same validation gauntlet an eager boot runs; a load
287    /// failure quarantines at that moment with the same typed
288    /// reporting. Opt-in per mount; nothing sets it by default.
289    Lazy,
290}
291
292/// Operator-curated workspace — the in-memory shape the engine
293/// receives.
294///
295/// The two-layer file adapter produces a `Workspace` by reading
296/// `.memstead/workspace.toml` (operator-edited rules) and
297/// `.memstead/state/mounts.json` (engine-managed mount list). Tests and
298/// in-memory builders construct `Workspace` directly.
299///
300/// V1 carries the mount list and operator policy. Plugin hooks and
301/// pipeline-config handles attach as additive fields — no breaking
302/// changes expected.
303#[derive(Debug, Clone, Default)]
304pub struct Workspace {
305    pub mounts: Vec<Mount>,
306    /// Workspace-level operator policy (mem create/delete rules,
307    /// cross-mem link permissions). Defaults to empty for tests
308    /// and in-memory builders; the file adapter
309    /// populates from `.memstead/workspace.toml`'s `[mem_management]`
310    /// and `[cross_mem_links]` sections. The unified engine reads
311    /// this via [`crate::Engine::settings`] after
312    /// [`crate::Engine::from_workspace_root`] threads it through
313    /// [`crate::Engine::set_settings`].
314    pub settings: WorkspaceSettings,
315}
316
317impl Workspace {
318    /// Empty workspace — zero mounts, default settings. Useful for
319    /// tests; production workspaces always carry at least one mount
320    /// (the engine rejects an empty `Workspace` at boot).
321    pub fn empty() -> Self {
322        Self {
323            mounts: Vec::new(),
324            settings: WorkspaceSettings::default(),
325        }
326    }
327}
328
329/// Workspace-level operator policy carried alongside the mount list.
330///
331/// Data carriers only — the matcher compilation lives in
332/// `crate::mem_management::CreateRuleSet`. The engine carries the
333/// raw settings so MCP handlers can surface them under `memstead_health
334/// { include_config: true }` and `memstead_overview`'s
335/// lifecycle-namespaces section.
336///
337/// `Default::default()` is a totally-empty policy: zero create rules,
338/// zero delete rules, no cross-mem link policy. The unified engine
339/// uses this as the bootstrap value at construction time; consumers
340/// that load a real policy call [`crate::Engine::set_settings`].
341#[derive(Debug, Clone, Default)]
342pub struct WorkspaceSettings {
343    /// Raw `[[mem_management.create]]` rules in declaration order.
344    /// Each entry carries a gitignore-style `pattern` matched against
345    /// the candidate mem path, an `schemas[]` allowlist, and an
346    /// optional `default_cross_links` synthesised cross-link
347    /// permission. Empty list means "no agent-driven mem creation
348    /// allowed" — `memstead_mem_create` rejects every candidate.
349    pub mem_create_rules: Vec<CreateRuleSetting>,
350    /// Raw `[[mem_management.delete]]` rules. Same first-match
351    /// semantics as [`Self::mem_create_rules`], minus the schema
352    /// dimension. Empty list means "no agent-driven mem deletion
353    /// allowed".
354    pub mem_delete_rules: Vec<DeleteRuleSetting>,
355    /// `[cross_mem_links]` policy — workspace-level cross-mem
356    /// edge permissions keyed by source mem. Empty map means
357    /// default-deny: every cross-mem edge fails until at least one
358    /// matching entry exists or a create-rule synthesised one.
359    pub cross_mem_links: BTreeMap<String, CrossLinkValue>,
360    /// `[mcp]` section — MCP-binary tuning knobs that operators set
361    /// per-workspace. The MCP binary reads this off
362    /// `Engine::settings()` at boot to size the response chunker
363    /// (`token_budget`) and filter the advertised tool surface
364    /// (`disabled_tools`). Defaulted when the section is absent.
365    pub mcp: McpSection,
366    /// `[mutations]` section — engine-wide mutation policy. The
367    /// `require_notes` field surfaces a `WarningHint::NoteMissing` on
368    /// mutation calls that omit a `note`. Default-zeroed when absent.
369    pub mutations: MutationsSection,
370    /// `[plugin.*]` namespace — opaque pass-through map keyed by
371    /// plugin identifier (`claude_code`, …). Values are raw
372    /// TOML tables the engine never inspects; named plugins read
373    /// their own sub-table via `memstead_health { include_config: true }`.
374    pub plugin: HashMap<String, toml::Table>,
375}
376
377/// `[mcp]` section — settings the MCP binary reads at boot. Carried
378/// on `WorkspaceSettings` so the MCP server sources its tuning from
379/// `Engine::settings()` instead of a parallel TOML parse.
380#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
381#[serde(deny_unknown_fields)]
382pub struct McpSection {
383    /// Per-response chunking budget in tokens. `None` → caller falls
384    /// back to the compile-time `DEFAULT_TOKEN_BUDGET`.
385    pub token_budget: Option<usize>,
386    /// Blocklist of tool names. Entries matching a compiled-in tool
387    /// are hidden from `tools/list` and rejected with `TOOL_DISABLED`
388    /// on direct invocation. Unknown entries log a warning and drop
389    /// from the effective set. Empty / absent → every compiled-in
390    /// tool is advertised.
391    pub disabled_tools: Option<Vec<String>>,
392}
393
394/// `[mutations]` section — engine-wide mutation policy. Carried on
395/// `WorkspaceSettings` so plugins can read the configured posture via
396/// `memstead_health { include_config: true }` without a round-trip.
397#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
398#[serde(deny_unknown_fields)]
399pub struct MutationsSection {
400    /// When `true`, a mutation call without a `note` field emits a
401    /// `WarningHint { code: "note_missing" }`. The mutation still
402    /// succeeds — provenance is best-effort.
403    pub require_notes: Option<bool>,
404}
405
406/// One `[[mem_management.create]]` rule. Carries a glob `pattern`
407/// matched against the candidate mem path, the `schemas` allowlist
408/// (each entry an exact `name@x.y.z` pin or the literal `"*"` for
409/// any-schema), and an optional `default_cross_links` value applied
410/// to every mem the rule matches.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct CreateRuleSetting {
413    pub pattern: String,
414    pub schemas: Vec<String>,
415    pub default_cross_links: Option<CrossLinkValue>,
416}
417
418/// One `[[mem_management.delete]]` rule. Carries only a `pattern`;
419/// delete has no schema dimension.
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct DeleteRuleSetting {
422    pub pattern: String,
423}
424
425/// The literal `"*"` schema-allowlist entry that admits any pinned
426/// schema. Consumed by the create-rule allowlist parser.
427pub const SCHEMA_WILDCARD: &str = "*";
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn pin(name: &str) -> SchemaRef {
434        SchemaRef::new(name, semver::Version::new(1, 0, 0))
435    }
436
437    #[test]
438    fn empty_workspace_has_no_mounts() {
439        let ws = Workspace::empty();
440        assert!(ws.mounts.is_empty());
441    }
442
443    #[test]
444    fn durability_follows_storage_kind() {
445        // On-disk backends persist; only in-memory is volatile. This is
446        // the fact the durability marker projects across overview / health
447        // / mutation responses.
448        let folder = MountStorage::Folder {
449            path: PathBuf::from("/work/mem"),
450        };
451        let git = MountStorage::GitBranch {
452            gitdir: PathBuf::from("/work/mem-repo/.git"),
453            branch: "specs".into(),
454        };
455        let archive = MountStorage::Archive {
456            path: PathBuf::from("/work/curated.mem"),
457        };
458        let in_memory = MountStorage::InMemory;
459
460        assert!(folder.is_durable());
461        assert!(git.is_durable());
462        assert!(archive.is_durable());
463        assert!(!in_memory.is_durable());
464
465        // The backend_id kebab string the marker rides alongside.
466        assert_eq!(folder.backend_id(), "folder");
467        assert_eq!(git.backend_id(), "git-branch");
468        assert_eq!(archive.backend_id(), "archive");
469        assert_eq!(in_memory.backend_id(), "in-memory");
470    }
471
472    #[test]
473    fn mount_can_describe_folder_storage() {
474        let m = Mount {
475            mem: "specs".into(),
476            schema: Some(pin("default")),
477            storage: MountStorage::Folder {
478                path: PathBuf::from("/work/mem"),
479            },
480            capability: MountCapability::Write,
481            lifecycle: MountLifecycle::Eager,
482            cross_linkable: true,
483            migration_target: None,
484        };
485        assert_eq!(m.mem, "specs");
486        assert!(matches!(m.storage, MountStorage::Folder { .. }));
487    }
488
489    #[test]
490    fn mount_can_describe_git_branch_storage() {
491        let m = Mount {
492            mem: "engine".into(),
493            schema: Some(pin("default")),
494            storage: MountStorage::GitBranch {
495                gitdir: PathBuf::from("/work/mem-repo/.git"),
496                branch: "engine".into(),
497            },
498            capability: MountCapability::Write,
499            lifecycle: MountLifecycle::Eager,
500            cross_linkable: true,
501            migration_target: None,
502        };
503        assert!(matches!(m.storage, MountStorage::GitBranch { .. }));
504    }
505
506    /// `Mount::mem_path()` derives the hierarchical path component
507    /// the delete-side lifecycle composer needs. Tolerates both bare
508    /// `<path>/<mem>` (operator-edited mounts.json) and
509    /// fully-qualified `refs/heads/<path>/<mem>` (runtime-created
510    /// mems). Folder / Archive variants always return `None`.
511    #[test]
512    fn mem_path_extracts_hierarchical_prefix_from_git_branch() {
513        // Bare hierarchical (mounts.json shape).
514        let m = Mount {
515            mem: "engine".into(),
516            schema: Some(pin("default")),
517            storage: MountStorage::GitBranch {
518                gitdir: PathBuf::from("/work/mem-repo/.git"),
519                branch: "memstead/engine".into(),
520            },
521            capability: MountCapability::Write,
522            lifecycle: MountLifecycle::Eager,
523            cross_linkable: true,
524            migration_target: None,
525        };
526        assert_eq!(m.mem_path(), Some("memstead".to_string()));
527
528        // Fully-qualified hierarchical (create_mem shape).
529        let m = Mount {
530            mem: "plan-foo".into(),
531            schema: Some(pin("default")),
532            storage: MountStorage::GitBranch {
533                gitdir: PathBuf::from("/work/mem-repo/.git"),
534                branch: "refs/heads/planning/plan-foo".into(),
535            },
536            capability: MountCapability::Write,
537            lifecycle: MountLifecycle::Eager,
538            cross_linkable: true,
539            migration_target: None,
540        };
541        assert_eq!(m.mem_path(), Some("planning".to_string()));
542
543        // Multi-segment hierarchical prefix.
544        let m = Mount {
545            mem: "leaf".into(),
546            schema: Some(pin("default")),
547            storage: MountStorage::GitBranch {
548                gitdir: PathBuf::from("/work/mem-repo/.git"),
549                branch: "refs/heads/a/b/c/leaf".into(),
550            },
551            capability: MountCapability::Write,
552            lifecycle: MountLifecycle::Eager,
553            cross_linkable: true,
554            migration_target: None,
555        };
556        assert_eq!(m.mem_path(), Some("a/b/c".to_string()));
557
558        // Flat layout (bare leaf, no prefix).
559        let m = Mount {
560            mem: "engine".into(),
561            schema: Some(pin("default")),
562            storage: MountStorage::GitBranch {
563                gitdir: PathBuf::from("/work/mem-repo/.git"),
564                branch: "engine".into(),
565            },
566            capability: MountCapability::Write,
567            lifecycle: MountLifecycle::Eager,
568            cross_linkable: true,
569            migration_target: None,
570        };
571        assert_eq!(m.mem_path(), None);
572
573        // Flat layout (fully-qualified, no prefix beyond refs/heads/).
574        let m = Mount {
575            mem: "engine".into(),
576            schema: Some(pin("default")),
577            storage: MountStorage::GitBranch {
578                gitdir: PathBuf::from("/work/mem-repo/.git"),
579                branch: "refs/heads/engine".into(),
580            },
581            capability: MountCapability::Write,
582            lifecycle: MountLifecycle::Eager,
583            cross_linkable: true,
584            migration_target: None,
585        };
586        assert_eq!(m.mem_path(), None);
587
588        // Folder backend has no hierarchical concept.
589        let m = Mount {
590            mem: "engine".into(),
591            schema: Some(pin("default")),
592            storage: MountStorage::Folder {
593                path: PathBuf::from("/work/mem"),
594            },
595            capability: MountCapability::Write,
596            lifecycle: MountLifecycle::Eager,
597            cross_linkable: true,
598            migration_target: None,
599        };
600        assert_eq!(m.mem_path(), None);
601    }
602
603    #[test]
604    fn mount_can_describe_archive_storage() {
605        let m = Mount {
606            mem: "external".into(),
607            schema: Some(pin("default")),
608            storage: MountStorage::Archive {
609                path: PathBuf::from("/deps/external.mem"),
610            },
611            capability: MountCapability::ReadOnly,
612            lifecycle: MountLifecycle::Lazy,
613            cross_linkable: false,
614            migration_target: None,
615        };
616        assert!(matches!(m.storage, MountStorage::Archive { .. }));
617        assert_eq!(m.capability, MountCapability::ReadOnly);
618    }
619
620    #[test]
621    fn workspace_with_heterogeneous_mounts() {
622        let ws = Workspace {
623            mounts: vec![
624                Mount {
625                    mem: "engine".into(),
626                    schema: Some(pin("default")),
627                    storage: MountStorage::GitBranch {
628                        gitdir: PathBuf::from("/work/mem-repo/.git"),
629                        branch: "engine".into(),
630                    },
631                    capability: MountCapability::Write,
632                    lifecycle: MountLifecycle::Eager,
633                    cross_linkable: true,
634                    migration_target: None,
635                },
636                Mount {
637                    mem: "macos".into(),
638                    schema: Some(pin("default")),
639                    storage: MountStorage::GitBranch {
640                        gitdir: PathBuf::from("/work/mem-repo/.git"),
641                        branch: "macos".into(),
642                    },
643                    capability: MountCapability::Write,
644                    lifecycle: MountLifecycle::Eager,
645                    cross_linkable: true,
646                    migration_target: None,
647                },
648                Mount {
649                    mem: "external".into(),
650                    schema: Some(pin("default")),
651                    storage: MountStorage::Archive {
652                        path: PathBuf::from("/deps/external.mem"),
653                    },
654                    capability: MountCapability::ReadOnly,
655                    lifecycle: MountLifecycle::Lazy,
656                    cross_linkable: false,
657                    migration_target: None,
658                },
659            ],
660            settings: WorkspaceSettings::default(),
661        };
662        assert_eq!(ws.mounts.len(), 3);
663        // Two mounts share a gitdir — the engine will pool the handle
664        // internally; the conceptual mount stays per-mem.
665        let shared_gitdir_mounts = ws
666            .mounts
667            .iter()
668            .filter(|m| matches!(&m.storage, MountStorage::GitBranch { gitdir, .. } if gitdir == std::path::Path::new("/work/mem-repo/.git")))
669            .count();
670        assert_eq!(shared_gitdir_mounts, 2);
671    }
672}