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}