Skip to main content

memstead_cli/
setup.rs

1//! Engine setup from global CLI flags. Produces an `Engine`
2//! synchronously (no tokio) for the CLI to call into directly.
3//!
4//! Post-rebuild there is one workspace marker: `.memstead/workspace.toml`
5//! at the workspace root. The `mem-repo` Cargo feature decides
6//! which engine factory consumes it — full routes through
7//! [`memstead_git_branch::workspace_store::engine_from_workspace_root`]
8//! (git-branch backends plus folder + archive), lean routes through
9//! [`memstead_base::Engine::from_workspace_root`] (folder + archive
10//! only).
11//!
12//! [`CliEngine`] wraps either flavour; subcommands match-dispatch on
13//! it. The `WorkspaceShape` variant is retained so the lean build
14//! can still surface an actionable "this is the lean binary, your
15//! workspace has git-branch mounts" error when the operator points a
16//! lean binary at a full workspace — the shape tag is derived from
17//! `mem-repo/.git` co-existing with the marker rather than the
18//! marker itself.
19
20use std::path::{Path, PathBuf};
21
22#[cfg(feature = "mem-repo")]
23use anyhow::Context;
24
25use memstead_base::Engine as BaseEngine;
26use memstead_base::vcs::ClientId;
27#[cfg(feature = "mem-repo")]
28use memstead_base::vcs::{Actor, CommitContext};
29#[cfg(feature = "mem-repo")]
30use memstead_git_branch::workspace_store::engine_from_workspace_root;
31
32use crate::CliError;
33use crate::output::ExitKind;
34
35/// Structured-code constant for the missing-workspace exit envelope.
36/// Surfaced on both `--json` output (under the `code` key in
37/// `details`) and as the `Display` body of the underlying `CliError`.
38/// Scripts and agents branch on this stable token; the human prose
39/// (which mentions the recovery command) is the message and can be
40/// adjusted without breaking the contract.
41pub const WORKSPACE_NOT_INITIALISED_CODE: &str = "WORKSPACE_NOT_INITIALISED";
42
43/// Recovery command suggested when no `.memstead/workspace.toml` is
44/// reachable from cwd. `memstead mem-repo init` in the full build (this
45/// binary speaks mem-repo); `memstead init` in the lean build. The
46/// structured `hint.recovery_command` field carries this token
47/// verbatim so an agent can re-exec it.
48#[cfg(feature = "mem-repo")]
49pub const WORKSPACE_RECOVERY_COMMAND: &str = "memstead mem-repo init";
50#[cfg(not(feature = "mem-repo"))]
51pub const WORKSPACE_RECOVERY_COMMAND: &str = "memstead init";
52
53/// Build the typed `WORKSPACE_NOT_INITIALISED` exit envelope. Goes
54/// through `CliError` so the top-level `main` downcast lifts the
55/// `code` + `hint` fields into the JSON output.
56pub fn workspace_not_initialised_error(message: &str) -> CliError {
57    CliError {
58        kind: ExitKind::Generic,
59        code: WORKSPACE_NOT_INITIALISED_CODE,
60        message: message.to_string(),
61        details: Some(serde_json::json!({
62            "hint": { "recovery_command": WORKSPACE_RECOVERY_COMMAND },
63        })),
64    }
65}
66
67/// Lift a [`memstead_base::BootError`] into the typed CLI envelope.
68/// The boot seam previously flattened these through `anyhow`, so the
69/// `main` downcast missed them and every boot failure surfaced as
70/// `code: INTERNAL` with no next step (plenum 2026-08-06/07, expertise
71/// 2026-08-07). The typed material lives on
72/// [`memstead_base::BootError::code`]; this function only wraps it in
73/// the CLI's exit shape. The message is
74/// [`memstead_base::BootError::surface_message`] verbatim — identical
75/// on the MCP server's boot diagnostics for the same broken workspace.
76pub fn boot_error_to_cli(workspace_root: &Path, e: memstead_base::BootError) -> CliError {
77    let details = e.details();
78    let details = match &details {
79        serde_json::Value::Object(map) if map.is_empty() => None,
80        _ => Some(details),
81    };
82    CliError {
83        kind: ExitKind::Generic,
84        code: e.code(),
85        message: e.surface_message(workspace_root),
86        details,
87    }
88}
89
90/// Global CLI state: shared flags + a lazily-initialized `Engine`.
91pub struct CliContext {
92    pub json: bool,
93    /// User asked for quiet stderr (`--quiet`). The CLI runs the
94    /// engine in-process and never installs a `tracing_subscriber`,
95    /// so the flag is informational.
96    pub quiet: bool,
97    /// The invocation-level declared role (`--role`, agent-trust
98    /// plan 13), already validated at parse time. Stamped onto every
99    /// engine this context constructs so mutations record it.
100    pub role: memstead_base::vcs::Role,
101}
102
103/// Workspace flavour resolved from cwd. Subcommands dispatch on this
104/// to pick the right engine accessor.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum WorkspaceShape {
107    /// Mem-repo workspace — multi-mem, git-backed.
108    /// The `.memstead/workspace.toml` root also carries `mem-repo/.git/`.
109    MemRepo,
110    /// Filesystem-mem workspace — single-mem, history-free.
111    /// The `.memstead/workspace.toml` root has no `mem-repo/.git/`.
112    Filesystem,
113}
114
115/// Engine instance + the workspace flavour it serves. Subcommands
116/// match on the variant to call the right engine API; the read-side
117/// store accessor (`engine.store()`) lives on both flavours so simple
118/// read commands can share most of their bodies.
119///
120/// The `MemRepo` variant is only present under the `mem-repo`
121/// feature. In the lean build (`--no-default-features`) the enum
122/// collapses to a single `Filesystem` arm — every subcommand's
123/// dispatch elides the missing arm via `cfg`.
124pub enum CliEngine {
125    #[cfg(feature = "mem-repo")]
126    MemRepo(BaseEngine),
127    /// Filesystem-mem flavour, served by the unified [`memstead_base::Engine`].
128    Filesystem(BaseEngine),
129}
130
131impl CliEngine {
132    /// The unified base engine behind whichever flavour booted. Both
133    /// variants wrap [`BaseEngine`]; commands that treat the flavours
134    /// identically destructure here instead of carrying a per-site
135    /// match (which, in the lean build's single-variant enum, is the
136    /// `infallible_destructuring_match` shape the isolated lean clippy
137    /// leg flags).
138    pub fn base(&self) -> &BaseEngine {
139        #[cfg(feature = "mem-repo")]
140        {
141            match self {
142                CliEngine::MemRepo(e) => e,
143                CliEngine::Filesystem(e) => e,
144            }
145        }
146        #[cfg(not(feature = "mem-repo"))]
147        {
148            let CliEngine::Filesystem(e) = self;
149            e
150        }
151    }
152
153    /// Mutable twin of [`Self::base`].
154    pub fn base_mut(&mut self) -> &mut BaseEngine {
155        #[cfg(feature = "mem-repo")]
156        {
157            match self {
158                CliEngine::MemRepo(e) => e,
159                CliEngine::Filesystem(e) => e,
160            }
161        }
162        #[cfg(not(feature = "mem-repo"))]
163        {
164            let CliEngine::Filesystem(e) = self;
165            e
166        }
167    }
168
169    /// Owning twin of [`Self::base`].
170    pub fn into_base(self) -> BaseEngine {
171        #[cfg(feature = "mem-repo")]
172        {
173            match self {
174                CliEngine::MemRepo(e) => e,
175                CliEngine::Filesystem(e) => e,
176            }
177        }
178        #[cfg(not(feature = "mem-repo"))]
179        {
180            let CliEngine::Filesystem(e) = self;
181            e
182        }
183    }
184}
185
186impl CliContext {
187    /// Resolve the workspace flavour by walking up from cwd. Returns
188    /// `None` when no `.memstead/workspace.toml` is found in any ancestor.
189    ///
190    /// Post-rebuild the marker is shape-neutral — the same
191    /// `.memstead/workspace.toml` carries both folder-only workspaces and
192    /// mem-repo workspaces. The flavour tag comes from whether the
193    /// workspace root also carries `mem-repo/.git/` (mem-repo
194    /// flavour) or not (folder-only flavour). The lean CLI uses this
195    /// distinction to surface "this is the lean binary" when the
196    /// operator points it at a workspace with git-branch mounts.
197    pub fn workspace_shape(&self) -> Option<(WorkspaceShape, PathBuf)> {
198        let cwd = std::env::current_dir().ok()?;
199        let root = find_workspace_root(&cwd)?;
200        let shape = if root.join("mem-repo").join(".git").is_dir() {
201            WorkspaceShape::MemRepo
202        } else {
203            WorkspaceShape::Filesystem
204        };
205        Some((shape, root))
206    }
207
208    /// Build a [`CliEngine`] from the current cwd. The workspace
209    /// marker `.memstead/workspace.toml` resolves either flavour; the
210    /// presence of `mem-repo/.git/` switches the engine factory.
211    ///
212    /// On the lean build (`--no-default-features`) the mem-repo
213    /// branch surfaces a clear "not built into this binary" error so
214    /// a user pointing the lean build at a mem-repo workspace
215    /// gets an actionable signal rather than a confusing "no
216    /// workspace" bail.
217    pub fn cli_engine(&self) -> anyhow::Result<CliEngine> {
218        match self.workspace_shape() {
219            Some((_, root)) => self.cli_engine_at(&root),
220            None => Err(workspace_not_initialised_error(
221                "No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead init` for a folder-mount workspace, or `memstead mem-repo init` for a mem-repo workspace).",
222            )
223            .into()),
224        }
225    }
226
227    /// Build a [`CliEngine`] rooted at an explicit workspace directory,
228    /// skipping the cwd walk-up. The flavour is still derived from
229    /// whether `<root>/mem-repo/.git/` is present, so callers that
230    /// already know the root (e.g. `memstead publish --workspace`) get
231    /// the same factory selection as [`Self::cli_engine`]. The split
232    /// also gives subcommands a chdir-free, unit-testable engine seam.
233    pub fn cli_engine_at(&self, root: &Path) -> anyhow::Result<CliEngine> {
234        if root.join("mem-repo").join(".git").is_dir() {
235            #[cfg(feature = "mem-repo")]
236            {
237                let mut engine =
238                    engine_from_workspace_root(root).map_err(|e| boot_error_to_cli(root, e))?;
239                engine.set_role(self.role);
240                return Ok(CliEngine::MemRepo(engine));
241            }
242            #[cfg(not(feature = "mem-repo"))]
243            {
244                return Err(CliError {
245                    kind: ExitKind::Generic,
246                    code: "UNSUPPORTED_WORKSPACE_SHAPE",
247                    message:
248                        "this is the lean build of memstead (folder-mount only); the workspace is mem-repo-shaped (`mem-repo/.git/` present). Install the full build (`cargo build --features mem-repo`) or run from a workspace whose mounts are all folder-backed."
249                            .to_string(),
250                    details: None,
251                }
252                .into());
253            }
254        }
255        let mut engine =
256            BaseEngine::from_workspace_root(root).map_err(|e| boot_error_to_cli(root, e))?;
257        engine.set_role(self.role);
258        Ok(CliEngine::Filesystem(engine))
259    }
260
261    /// Build the unified [`memstead_base::Engine`] for a mem-repo-shaped
262    /// workspace. Delegates to `engine_from_workspace_root` which
263    /// handles layout detection, mount enumeration, schema resolution,
264    /// and readMems hydration in one pass.
265    ///
266    /// Only compiled into the full build — the lean build never sees a
267    /// mem-repo workspace because `cli_engine()` rejects it before
268    /// reaching here.
269    #[cfg(feature = "mem-repo")]
270    pub fn engine(&self) -> anyhow::Result<BaseEngine> {
271        let cwd = std::env::current_dir().context("Could not determine current directory")?;
272
273        let Some(root) = find_workspace_root(&cwd) else {
274            return Err(workspace_not_initialised_error(
275                "No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead mem-repo init` to bootstrap).",
276            )
277            .into());
278        };
279
280        // Subcommands routed through `engine()` (rather than
281        // `cli_engine()`) require mem-repo shape — they read /
282        // write commit-shaped artefacts (`workspace dump` snapshots,
283        // `batch-update` commit envelopes) that have no analogue on a
284        // folder-mount-only workspace. Surface the mem-repo-only
285        // tag here so callers print an actionable message instead of
286        // booting into a foldery engine and erroring later.
287        if !root.join("mem-repo").join(".git").is_dir() {
288            return Err(CliError {
289                kind: ExitKind::Generic,
290                code: "UNSUPPORTED_WORKSPACE_SHAPE",
291                message:
292                    "this subcommand is mem-repo-only and not yet supported on filesystem-mem workspaces — run from a mem-repo workspace, or use `memstead status` / `memstead list` / `memstead search` / `memstead entity` / `memstead health` / `memstead create|update|delete|relate|rename` instead."
293                        .to_string(),
294                details: None,
295            }
296            .into());
297        }
298
299        let mut engine =
300            engine_from_workspace_root(&root).map_err(|e| boot_error_to_cli(&root, e))?;
301        engine.set_role(self.role);
302        Ok(engine)
303    }
304}
305
306/// Walk upward from `start` looking for the first ancestor that
307/// contains `.memstead/workspace.toml` (the post-rebuild workspace
308/// marker). Returns the first ancestor directory carrying the marker,
309/// or `None` if the walk reaches filesystem root without finding one.
310///
311/// Both files and directories are accepted as `start`. A plain file's
312/// parent is used as the first candidate; for a directory, the
313/// directory itself is the first candidate.
314///
315/// Deeper-marker semantics: because the walk is upward and stops at
316/// the first match, an inner workspace nested inside an outer one
317/// resolves to the inner.
318///
319/// Mirrors `memstead-mcp/src/main.rs::find_workspace_root` and the
320/// per-command walkers in `memstead-cli/src/commands/link.rs` /
321/// `memstead-cli/src/commands/publish.rs`. Keep the resolution rules in
322/// sync if any of these change.
323pub fn find_workspace_root(start: &Path) -> Option<PathBuf> {
324    let mut cursor: PathBuf = if start.is_dir() {
325        start.to_path_buf()
326    } else {
327        start.parent()?.to_path_buf()
328    };
329    loop {
330        if memstead_base::is_workspace_root(&cursor) {
331            return Some(cursor);
332        }
333        let parent = cursor.parent()?;
334        if parent == cursor {
335            return None;
336        }
337        cursor = parent.to_path_buf();
338    }
339}
340
341/// Compatibility alias for `find_workspace_root` — kept so existing
342/// CLI subcommands (export, changes, …) that historically routed
343/// through the lean-flavour walker continue to compile. Both walkers
344/// now find the same marker; the alias is intentional for
345/// call-site clarity (`find_workspace_root` reads as the canonical
346/// surface; `find_filesystem_workspace_root` documents the
347/// folder-mount-only intent of its caller).
348pub fn find_filesystem_workspace_root(start: &Path) -> Option<PathBuf> {
349    find_workspace_root(start)
350}
351
352/// Provenance bundle for every CLI-initiated mutation. `Actor::Cli` +
353/// `memstead-cli@<CARGO_PKG_VERSION>`. The `Tool:` trailer stays `None`: CLI
354/// subcommands aren't MCP tools and the commit subject (`memstead: create …`)
355/// already carries the action verb — a second taxonomy would drift.
356///
357/// Only used by mem-repo write paths today; filesystem-mem write
358/// paths assemble their own provenance directly. The function therefore
359/// only compiles when `mem-repo` is enabled.
360#[cfg(feature = "mem-repo")]
361pub fn cli_ctx() -> CommitContext<'static> {
362    cli_ctx_with_note(None)
363}
364
365/// The `memstead-cli@<version>` client identity stamped into the commit
366/// body's `Client:` provenance trailer. Shared by every CLI mutation
367/// path so the trailer is uniform across `create` / `update` / `relate`
368/// / `rename`. Un-gated (unlike [`cli_ctx_with_note`]) because the
369/// `relate` path passes the client to `relate_entity` directly rather
370/// than through a `CommitContext`, and that path compiles on both
371/// flavours.
372pub fn cli_client_id() -> ClientId {
373    ClientId {
374        name: "memstead-cli".to_string(),
375        version: env!("CARGO_PKG_VERSION").to_string(),
376    }
377}
378
379/// Provenance bundle carrying an optional agent-authored `--note`.
380/// The note rides into the same payload slot the MCP `note` parameter
381/// uses; the engine's `require_notes` policy gate fires `NOTE_MISSING`
382/// symmetrically across both surfaces.
383#[cfg(feature = "mem-repo")]
384pub fn cli_ctx_with_note(note: Option<String>) -> CommitContext<'static> {
385    CommitContext {
386        actor: Actor::Cli,
387        client: Some(cli_client_id()),
388        tool: None,
389        note,
390        role: Default::default(),
391        logical_operation_id: None,
392        entity_ids: None,
393    }
394}
395
396/// Build the unified [`memstead_base::Engine`] for a mem-repo-shaped
397/// workspace. Delegates to `engine_from_workspace_root` which
398/// handles layout detection, mount enumeration, schema resolution,
399/// and readMems hydration in one pass.
400///
401/// Subcommands routed through this helper require mem-repo shape —
402/// they read / write commit-shaped artefacts (`workspace dump`
403/// snapshots, `batch-update` commit envelopes) that have no analogue
404/// on a folder-mount-only workspace.
405#[cfg(feature = "mem-repo")]
406pub fn full_engine(_ctx: &CliContext) -> anyhow::Result<BaseEngine> {
407    // Typed, not INTERNAL: an unreadable or deleted working directory
408    // is an environment condition the caller can act on (`cd` somewhere
409    // that exists), and no leaf of a user-triggerable command may
410    // collapse into the generic sentinel.
411    let cwd = std::env::current_dir().map_err(|e| {
412        CliError::new(
413            ExitKind::Generic,
414            "INTERNAL_IO_ERROR",
415            format!("could not determine the current directory ({e}) — run from a directory that exists and is readable"),
416        )
417    })?;
418
419    let Some(root) = find_workspace_root(&cwd) else {
420        return Err(workspace_not_initialised_error(
421            "No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead mem-repo init` to bootstrap).",
422        )
423        .into());
424    };
425
426    if !root.join("mem-repo").join(".git").is_dir() {
427        return Err(CliError {
428            code: "UNSUPPORTED_WORKSPACE_SHAPE",
429            kind: ExitKind::Generic,
430            message:
431                "this subcommand is mem-repo-only and not yet supported on filesystem-mem workspaces — run from a mem-repo workspace, or use `memstead status` / `memstead list` / `memstead search` / `memstead entity` / `memstead health` / `memstead create|update|delete|relate|rename` instead."
432                    .to_string(),
433            details: None,
434        }
435        .into());
436    }
437
438    let mut engine = engine_from_workspace_root(&root).map_err(|e| boot_error_to_cli(&root, e))?;
439    engine.set_role(_ctx.role);
440    Ok(engine)
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use tempfile::TempDir;
447
448    fn touch_marker(ws: &std::path::Path) {
449        std::fs::create_dir_all(ws.join(".memstead")).unwrap();
450        std::fs::write(ws.join(".memstead").join("workspace.toml"), "").unwrap();
451    }
452
453    #[test]
454    fn find_workspace_root_walks_up_to_marker() {
455        let tmp = TempDir::new().unwrap();
456        let ws = tmp.path().join("ws");
457        let nested = ws.join("a").join("b").join("specs");
458        std::fs::create_dir_all(&nested).unwrap();
459        touch_marker(&ws);
460        let found =
461            find_workspace_root(&nested).expect("walk should find .memstead/workspace.toml");
462        assert_eq!(found.canonicalize().unwrap(), ws.canonicalize().unwrap());
463    }
464
465    #[test]
466    fn find_workspace_root_returns_none_when_absent() {
467        let tmp = TempDir::new().unwrap();
468        let nested = tmp.path().join("a").join("b");
469        std::fs::create_dir_all(&nested).unwrap();
470        assert!(find_workspace_root(&nested).is_none());
471    }
472
473    #[test]
474    fn find_workspace_root_stops_at_containing_dir() {
475        let tmp = TempDir::new().unwrap();
476        let ws = tmp.path().join("ws");
477        std::fs::create_dir_all(&ws).unwrap();
478        touch_marker(&ws);
479        let found = find_workspace_root(&ws).expect("ws itself carries .memstead/workspace.toml");
480        assert_eq!(found, ws);
481    }
482
483    #[test]
484    fn find_workspace_root_accepts_file_start() {
485        let tmp = TempDir::new().unwrap();
486        let ws = tmp.path().join("ws");
487        std::fs::create_dir_all(&ws).unwrap();
488        touch_marker(&ws);
489        let file = ws.join("some-file.md");
490        std::fs::write(&file, "").unwrap();
491        let found = find_workspace_root(&file).expect("file start should resolve to its dir");
492        assert_eq!(found, ws);
493    }
494
495    #[test]
496    fn find_workspace_root_deeper_marker_wins() {
497        // Outer and inner each carry `.memstead/workspace.toml`. The walk
498        // starts deep inside the inner dir and must resolve to the
499        // inner — deeper marker wins because the upward walk stops at
500        // the first match.
501        let tmp = TempDir::new().unwrap();
502        let outer = tmp.path().join("outer");
503        let inner = outer.join("inner");
504        let deep = inner.join("a").join("b");
505        std::fs::create_dir_all(&deep).unwrap();
506        touch_marker(&outer);
507        touch_marker(&inner);
508        let found = find_workspace_root(&deep).expect("walk should find the inner marker");
509        assert_eq!(found.canonicalize().unwrap(), inner.canonicalize().unwrap());
510    }
511}