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