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