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/// Render a string as one POSIX shell word. Bare when every character
116/// is safe unquoted; otherwise single-quoted, with embedded `'` closed
117/// and re-opened the POSIX way (`'\''`).
118///
119/// A leading `-` forces quoting even though `-` is otherwise safe: an
120/// argument that starts with a dash is read as an option by whatever
121/// receives it. (Quoting alone does not save `cd`, which parses its
122/// argument after the shell strips quotes — callers printing a `cd`
123/// emit `cd --`.)
124///
125/// Lives here rather than beside its first caller because every message
126/// that interpolates a filesystem path into a command the reader is
127/// expected to run needs it, and the one that did not — the shape
128/// disclosure's other-shape command — was unrunnable for anyone whose
129/// binary path contained a space.
130pub fn shell_quote(value: &str) -> String {
131    let safe = |c: char| c.is_ascii_alphanumeric() || "._-/@:+,=".contains(c);
132    if !value.is_empty() && !value.starts_with('-') && value.chars().all(safe) {
133        return value.to_string();
134    }
135    format!("'{}'", value.replace('\'', r"'\''"))
136}
137
138/// The running binary, resolved and shell-quoted — the form to
139/// interpolate into any command a message tells the reader to run.
140fn memstead_word() -> String {
141    shell_quote(&memstead_program())
142}
143
144/// The `UNSUPPORTED_WORKSPACE_SHAPE` refusal, in one place because both
145/// mem-repo-only gates mint it and they must not drift. Names the
146/// recovering command and the verbs that do work here, both resolved to
147/// this binary — the refusal is read by someone who is about to type
148/// what it says.
149///
150/// Both gates that mint it are mem-repo-only, so the lean build never
151/// reaches this refusal (it has no mem-repo-only subcommand to refuse).
152#[cfg(feature = "mem-repo")]
153fn unsupported_workspace_shape_message() -> String {
154    let m = memstead_word();
155    format!(
156        "this subcommand is mem-repo-only and not yet supported on filesystem-mem workspaces — \
157         bootstrap one with `{m} mem-repo init` in a fresh folder, or use `{m} status` / \
158         `{m} list` / `{m} search` / `{m} entity` / `{m} health` / \
159         `{m} create|update|delete|relate|rename` here instead."
160    )
161}
162
163/// Resolve the running `memstead` binary to something the reader can
164/// actually type. Bare `memstead` when that name on `PATH` resolves to
165/// this very binary; otherwise the path we were invoked as.
166///
167/// A reader who ran `./target/debug/memstead`, or an unpacked download,
168/// or a binary under a versioned directory, has no `memstead` on
169/// `PATH` — and every printed command naming a bare `memstead` fails
170/// for them with `command not found`. Every message that tells someone
171/// to run this binary goes through here.
172pub fn memstead_program() -> String {
173    let Ok(exe) = std::env::current_exe() else {
174        return "memstead".to_string();
175    };
176    let canonical_exe = exe.canonicalize().unwrap_or_else(|_| exe.clone());
177    if let Some(paths) = std::env::var_os("PATH") {
178        for dir in std::env::split_paths(&paths) {
179            let candidate = dir.join("memstead");
180            if candidate.is_file() && candidate.canonicalize().is_ok_and(|c| c == canonical_exe) {
181                return "memstead".to_string();
182            }
183        }
184    }
185    exe.display().to_string()
186}
187
188/// The command that produces the *other* shape than the one a
189/// disclosure is describing. Feature-gated because every command a
190/// message names must exist in the binary that prints it: the lean
191/// build has no `mem-repo` subcommand group, so it points at the full
192/// build rather than at a verb it would reject. The program name is
193/// resolved rather than hardcoded, for the same reason the verify
194/// commands resolve it — this is an instruction, not a mention.
195#[cfg(feature = "mem-repo")]
196fn mem_repo_init_hint() -> String {
197    format!("`{} mem-repo init` in a fresh folder", memstead_word())
198}
199#[cfg(not(feature = "mem-repo"))]
200fn mem_repo_init_hint() -> String {
201    "the full build of memstead (this lean build has no `mem-repo` subcommand), then \
202     `memstead mem-repo init` in a fresh folder"
203        .to_string()
204}
205
206/// What a filesystem-mem workspace cannot do — stated with the same
207/// feature gate as the hint above, and for the same reason. The full
208/// build names `memstead install`, which exists there and refuses by
209/// shape; the lean build has no `install` subcommand at all, so naming
210/// it would send the reader to a verb that does not parse. The lean
211/// wording states the limit without borrowing a command it lacks.
212#[cfg(feature = "mem-repo")]
213const FILESYSTEM_CANNOT: &str = "**It cannot install mems from the registry.** `memstead install \
214     <scope>/<name>` (and the other mem-repo-only subcommands) refuse here with \
215     `UNSUPPORTED_WORKSPACE_SHAPE`.";
216#[cfg(not(feature = "mem-repo"))]
217const FILESYSTEM_CANNOT: &str = "**It cannot install mems from the registry, and holds exactly \
218     one mem.** The subcommands that do either are mem-repo-only, and this lean build does not \
219     carry them at all.";
220
221impl WorkspaceShape {
222    /// Resolve the shape of an existing workspace root. Routes through
223    /// the engine's shared probe so the CLI, the refusals, and the MCP
224    /// boot line can never disagree about the same directory.
225    pub fn at(workspace_root: &Path) -> Self {
226        if memstead_base::is_mem_repo_shaped(workspace_root) {
227            WorkspaceShape::MemRepo
228        } else {
229            WorkspaceShape::Filesystem
230        }
231    }
232
233    /// The one spelling of this shape, shared with the engine.
234    pub fn label(self) -> &'static str {
235        match self {
236            WorkspaceShape::MemRepo => "mem-repo",
237            WorkspaceShape::Filesystem => "filesystem-mem",
238        }
239    }
240}
241
242/// The three-part disclosure a workspace-creating command owes its
243/// caller: which shape was just made, one concrete thing that shape
244/// cannot do, and the exact command that produces the other one.
245///
246/// Held as parts rather than pre-rendered prose because both receipts
247/// carry it: the markdown block a human reads, and the `--json`
248/// envelope an agent reads. A label alone on the machine surface would
249/// name the fork without disclosing it, which is the failure this whole
250/// disclosure exists to end — so both renderings come from one value.
251pub struct ShapeDisclosure {
252    /// The shape just created.
253    pub shape: WorkspaceShape,
254    /// One sentence on what this shape is.
255    pub summary: &'static str,
256    /// One concrete thing this shape cannot do, in markdown.
257    pub cannot: &'static str,
258    /// The shape a caller would get instead.
259    pub other_shape: WorkspaceShape,
260    /// The exact command producing [`Self::other_shape`], in markdown.
261    pub other_shape_command: String,
262}
263
264/// The disclosure for a shape.
265///
266/// `quickstart`, `init`, and `mem-repo init` all print this — the
267/// disclosure is symmetric, not a warning bolted onto one branch. It
268/// belongs in the creating command's own receipt because that is the
269/// moment the fork is decided and the output the newcomer is already
270/// reading; a sentence elsewhere (the `install --help` clause)
271/// demonstrably arrives after the workspace exists.
272pub fn shape_disclosure(shape: WorkspaceShape) -> ShapeDisclosure {
273    match shape {
274        WorkspaceShape::Filesystem => ShapeDisclosure {
275            shape,
276            summary: "One mem, plain `.md` files in this folder, no git history — nothing \
277                      else to set up.",
278            cannot: FILESYSTEM_CANNOT,
279            other_shape: WorkspaceShape::MemRepo,
280            other_shape_command: format!(
281                "**The other shape** — mem-repo: many mems, git-backed, registry-capable — \
282                 comes from {hint}. Switching later means starting a second \
283                 workspace, so decide now if you intend to install mems.",
284                hint = mem_repo_init_hint(),
285            ),
286        },
287        WorkspaceShape::MemRepo => ShapeDisclosure {
288            shape,
289            summary: "Many mems on git branches, full history — every subcommand works here, \
290                      including `memstead install <scope>/<name>`.",
291            cannot: "**It costs a git repository.** The mems live in `mem-repo/.git/` and \
292                     every mutation is a commit — not a folder of files you can hand-edit.",
293            other_shape: WorkspaceShape::Filesystem,
294            other_shape_command: format!(
295                "**The other shape** — filesystem-mem: one mem, plain `.md` files, no git — \
296                 comes from `{} quickstart` in a fresh folder.",
297                memstead_word(),
298            ),
299        },
300    }
301}
302
303impl ShapeDisclosure {
304    /// The markdown block for a human-facing receipt.
305    pub fn lines(&self) -> Vec<String> {
306        vec![
307            format!("## Workspace shape: {}", self.shape.label()),
308            String::new(),
309            self.summary.to_string(),
310            String::new(),
311            format!("- {}", self.cannot),
312            format!("- {}", self.other_shape_command),
313        ]
314    }
315
316    /// The same three parts for a `--json` receipt. The agent surface
317    /// gets the limit and the recovering command, not just the label.
318    pub fn to_json(&self) -> serde_json::Value {
319        serde_json::json!({
320            "shape": self.shape.label(),
321            "summary": self.summary,
322            "cannot": self.cannot,
323            "other_shape": self.other_shape.label(),
324            "other_shape_command": self.other_shape_command,
325        })
326    }
327}
328
329/// Convenience for callers that only render markdown.
330pub fn shape_disclosure_lines(shape: WorkspaceShape) -> Vec<String> {
331    shape_disclosure(shape).lines()
332}
333
334/// Engine instance + the workspace flavour it serves. Subcommands
335/// match on the variant to call the right engine API; the read-side
336/// store accessor (`engine.store()`) lives on both flavours so simple
337/// read commands can share most of their bodies.
338///
339/// The `MemRepo` variant is only present under the `mem-repo`
340/// feature. In the lean build (`--no-default-features`) the enum
341/// collapses to a single `Filesystem` arm — every subcommand's
342/// dispatch elides the missing arm via `cfg`.
343pub enum CliEngine {
344    #[cfg(feature = "mem-repo")]
345    MemRepo(BaseEngine),
346    /// Filesystem-mem flavour, served by the unified [`memstead_base::Engine`].
347    Filesystem(BaseEngine),
348}
349
350impl CliEngine {
351    /// The unified base engine behind whichever flavour booted. Both
352    /// variants wrap [`BaseEngine`]; commands that treat the flavours
353    /// identically destructure here instead of carrying a per-site
354    /// match (which, in the lean build's single-variant enum, is the
355    /// `infallible_destructuring_match` shape the isolated lean clippy
356    /// leg flags).
357    pub fn base(&self) -> &BaseEngine {
358        #[cfg(feature = "mem-repo")]
359        {
360            match self {
361                CliEngine::MemRepo(e) => e,
362                CliEngine::Filesystem(e) => e,
363            }
364        }
365        #[cfg(not(feature = "mem-repo"))]
366        {
367            let CliEngine::Filesystem(e) = self;
368            e
369        }
370    }
371
372    /// Mutable twin of [`Self::base`].
373    pub fn base_mut(&mut self) -> &mut BaseEngine {
374        #[cfg(feature = "mem-repo")]
375        {
376            match self {
377                CliEngine::MemRepo(e) => e,
378                CliEngine::Filesystem(e) => e,
379            }
380        }
381        #[cfg(not(feature = "mem-repo"))]
382        {
383            let CliEngine::Filesystem(e) = self;
384            e
385        }
386    }
387
388    /// Owning twin of [`Self::base`].
389    pub fn into_base(self) -> BaseEngine {
390        #[cfg(feature = "mem-repo")]
391        {
392            match self {
393                CliEngine::MemRepo(e) => e,
394                CliEngine::Filesystem(e) => e,
395            }
396        }
397        #[cfg(not(feature = "mem-repo"))]
398        {
399            let CliEngine::Filesystem(e) = self;
400            e
401        }
402    }
403}
404
405impl CliContext {
406    /// Resolve the workspace flavour by walking up from cwd. Returns
407    /// `None` when no `.memstead/workspace.toml` is found in any ancestor.
408    ///
409    /// Post-rebuild the marker is shape-neutral — the same
410    /// `.memstead/workspace.toml` carries both folder-only workspaces and
411    /// mem-repo workspaces. The flavour tag comes from whether the
412    /// workspace root also carries `mem-repo/.git/` (mem-repo
413    /// flavour) or not (folder-only flavour). The lean CLI uses this
414    /// distinction to surface "this is the lean binary" when the
415    /// operator points it at a workspace with git-branch mounts.
416    pub fn workspace_shape(&self) -> Option<(WorkspaceShape, PathBuf)> {
417        let cwd = std::env::current_dir().ok()?;
418        let root = find_workspace_root(&cwd)?;
419        Some((WorkspaceShape::at(&root), root))
420    }
421
422    /// Build a [`CliEngine`] from the current cwd. The workspace
423    /// marker `.memstead/workspace.toml` resolves either flavour; the
424    /// presence of `mem-repo/.git/` switches the engine factory.
425    ///
426    /// On the lean build (`--no-default-features`) the mem-repo
427    /// branch surfaces a clear "not built into this binary" error so
428    /// a user pointing the lean build at a mem-repo workspace
429    /// gets an actionable signal rather than a confusing "no
430    /// workspace" bail.
431    pub fn cli_engine(&self) -> anyhow::Result<CliEngine> {
432        match self.workspace_shape() {
433            Some((_, root)) => self.cli_engine_at(&root),
434            None => Err(workspace_not_initialised_error(
435                "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).",
436            )
437            .into()),
438        }
439    }
440
441    /// Build a [`CliEngine`] rooted at an explicit workspace directory,
442    /// skipping the cwd walk-up. The flavour is still derived from
443    /// whether `<root>/mem-repo/.git/` is present, so callers that
444    /// already know the root (e.g. `memstead publish --workspace`) get
445    /// the same factory selection as [`Self::cli_engine`]. The split
446    /// also gives subcommands a chdir-free, unit-testable engine seam.
447    pub fn cli_engine_at(&self, root: &Path) -> anyhow::Result<CliEngine> {
448        if memstead_base::is_mem_repo_shaped(root) {
449            #[cfg(feature = "mem-repo")]
450            {
451                let mut engine =
452                    engine_from_workspace_root(root).map_err(|e| boot_error_to_cli(root, e))?;
453                engine.set_role(self.role);
454                return Ok(CliEngine::MemRepo(engine));
455            }
456            #[cfg(not(feature = "mem-repo"))]
457            {
458                return Err(CliError {
459                    kind: ExitKind::Generic,
460                    code: "UNSUPPORTED_WORKSPACE_SHAPE",
461                    message:
462                        "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."
463                            .to_string(),
464                    details: None,
465                }
466                .into());
467            }
468        }
469        let mut engine =
470            BaseEngine::from_workspace_root(root).map_err(|e| boot_error_to_cli(root, e))?;
471        engine.set_role(self.role);
472        Ok(CliEngine::Filesystem(engine))
473    }
474
475    /// Build the unified [`memstead_base::Engine`] for a mem-repo-shaped
476    /// workspace. Delegates to `engine_from_workspace_root` which
477    /// handles layout detection, mount enumeration, schema resolution,
478    /// and readMems hydration in one pass.
479    ///
480    /// Only compiled into the full build — the lean build never sees a
481    /// mem-repo workspace because `cli_engine()` rejects it before
482    /// reaching here.
483    #[cfg(feature = "mem-repo")]
484    pub fn engine(&self) -> anyhow::Result<BaseEngine> {
485        let cwd = std::env::current_dir().context("Could not determine current directory")?;
486
487        let Some(root) = find_workspace_root(&cwd) else {
488            return Err(workspace_not_initialised_error(
489                "No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead mem-repo init` to bootstrap).",
490            )
491            .into());
492        };
493
494        // Subcommands routed through `engine()` (rather than
495        // `cli_engine()`) require mem-repo shape — they read /
496        // write commit-shaped artefacts (`workspace dump` snapshots,
497        // `batch-update` commit envelopes) that have no analogue on a
498        // folder-mount-only workspace. Surface the mem-repo-only
499        // tag here so callers print an actionable message instead of
500        // booting into a foldery engine and erroring later.
501        if !memstead_base::is_mem_repo_shaped(&root) {
502            return Err(CliError {
503                kind: ExitKind::Generic,
504                code: "UNSUPPORTED_WORKSPACE_SHAPE",
505                message: unsupported_workspace_shape_message(),
506                details: None,
507            }
508            .into());
509        }
510
511        let mut engine =
512            engine_from_workspace_root(&root).map_err(|e| boot_error_to_cli(&root, e))?;
513        engine.set_role(self.role);
514        Ok(engine)
515    }
516}
517
518/// Walk upward from `start` looking for the first ancestor that
519/// contains `.memstead/workspace.toml` (the post-rebuild workspace
520/// marker). Returns the first ancestor directory carrying the marker,
521/// or `None` if the walk reaches filesystem root without finding one.
522///
523/// Both files and directories are accepted as `start`. A plain file's
524/// parent is used as the first candidate; for a directory, the
525/// directory itself is the first candidate.
526///
527/// Deeper-marker semantics: because the walk is upward and stops at
528/// the first match, an inner workspace nested inside an outer one
529/// resolves to the inner.
530///
531/// Mirrors `memstead-mcp/src/main.rs::find_workspace_root` and the
532/// per-command walkers in `memstead-cli/src/commands/link.rs` /
533/// `memstead-cli/src/commands/publish.rs`. Keep the resolution rules in
534/// sync if any of these change.
535pub fn find_workspace_root(start: &Path) -> Option<PathBuf> {
536    let mut cursor: PathBuf = if start.is_dir() {
537        start.to_path_buf()
538    } else {
539        start.parent()?.to_path_buf()
540    };
541    loop {
542        if memstead_base::is_workspace_root(&cursor) {
543            return Some(cursor);
544        }
545        let parent = cursor.parent()?;
546        if parent == cursor {
547            return None;
548        }
549        cursor = parent.to_path_buf();
550    }
551}
552
553/// Compatibility alias for `find_workspace_root` — kept so existing
554/// CLI subcommands (export, changes, …) that historically routed
555/// through the lean-flavour walker continue to compile. Both walkers
556/// now find the same marker; the alias is intentional for
557/// call-site clarity (`find_workspace_root` reads as the canonical
558/// surface; `find_filesystem_workspace_root` documents the
559/// folder-mount-only intent of its caller).
560pub fn find_filesystem_workspace_root(start: &Path) -> Option<PathBuf> {
561    find_workspace_root(start)
562}
563
564/// Provenance bundle for every CLI-initiated mutation. `Actor::Cli` +
565/// `memstead-cli@<CARGO_PKG_VERSION>`. The `Tool:` trailer stays `None`: CLI
566/// subcommands aren't MCP tools and the commit subject (`memstead: create …`)
567/// already carries the action verb — a second taxonomy would drift.
568///
569/// Only used by mem-repo write paths today; filesystem-mem write
570/// paths assemble their own provenance directly. The function therefore
571/// only compiles when `mem-repo` is enabled.
572#[cfg(feature = "mem-repo")]
573pub fn cli_ctx() -> CommitContext<'static> {
574    cli_ctx_with_note(None)
575}
576
577/// The `memstead-cli@<version>` client identity stamped into the commit
578/// body's `Client:` provenance trailer. Shared by every CLI mutation
579/// path so the trailer is uniform across `create` / `update` / `relate`
580/// / `rename`. Un-gated (unlike [`cli_ctx_with_note`]) because the
581/// `relate` path passes the client to `relate_entity` directly rather
582/// than through a `CommitContext`, and that path compiles on both
583/// flavours.
584pub fn cli_client_id() -> ClientId {
585    ClientId {
586        name: "memstead-cli".to_string(),
587        version: env!("CARGO_PKG_VERSION").to_string(),
588    }
589}
590
591/// Provenance bundle carrying an optional agent-authored `--note`.
592/// The note rides into the same payload slot the MCP `note` parameter
593/// uses; the engine's `require_notes` policy gate fires `NOTE_MISSING`
594/// symmetrically across both surfaces.
595#[cfg(feature = "mem-repo")]
596pub fn cli_ctx_with_note(note: Option<String>) -> CommitContext<'static> {
597    CommitContext {
598        actor: Actor::Cli,
599        client: Some(cli_client_id()),
600        tool: None,
601        note,
602        role: Default::default(),
603        logical_operation_id: None,
604        entity_ids: None,
605    }
606}
607
608/// Build the unified [`memstead_base::Engine`] for a mem-repo-shaped
609/// workspace. Delegates to `engine_from_workspace_root` which
610/// handles layout detection, mount enumeration, schema resolution,
611/// and readMems hydration in one pass.
612///
613/// Subcommands routed through this helper require mem-repo shape —
614/// they read / write commit-shaped artefacts (`workspace dump`
615/// snapshots, `batch-update` commit envelopes) that have no analogue
616/// on a folder-mount-only workspace.
617#[cfg(feature = "mem-repo")]
618pub fn full_engine(_ctx: &CliContext) -> anyhow::Result<BaseEngine> {
619    // Typed, not INTERNAL: an unreadable or deleted working directory
620    // is an environment condition the caller can act on (`cd` somewhere
621    // that exists), and no leaf of a user-triggerable command may
622    // collapse into the generic sentinel.
623    let cwd = std::env::current_dir().map_err(|e| {
624        CliError::new(
625            ExitKind::Generic,
626            "INTERNAL_IO_ERROR",
627            format!("could not determine the current directory ({e}) — run from a directory that exists and is readable"),
628        )
629    })?;
630
631    let Some(root) = find_workspace_root(&cwd) else {
632        return Err(workspace_not_initialised_error(
633            "No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead mem-repo init` to bootstrap).",
634        )
635        .into());
636    };
637
638    if !memstead_base::is_mem_repo_shaped(&root) {
639        return Err(CliError {
640            code: "UNSUPPORTED_WORKSPACE_SHAPE",
641            kind: ExitKind::Generic,
642            message: unsupported_workspace_shape_message(),
643            details: None,
644        }
645        .into());
646    }
647
648    let mut engine = engine_from_workspace_root(&root).map_err(|e| boot_error_to_cli(&root, e))?;
649    engine.set_role(_ctx.role);
650    Ok(engine)
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use tempfile::TempDir;
657
658    fn touch_marker(ws: &std::path::Path) {
659        std::fs::create_dir_all(ws.join(".memstead")).unwrap();
660        std::fs::write(ws.join(".memstead").join("workspace.toml"), "").unwrap();
661    }
662
663    #[test]
664    fn find_workspace_root_walks_up_to_marker() {
665        let tmp = TempDir::new().unwrap();
666        let ws = tmp.path().join("ws");
667        let nested = ws.join("a").join("b").join("specs");
668        std::fs::create_dir_all(&nested).unwrap();
669        touch_marker(&ws);
670        let found =
671            find_workspace_root(&nested).expect("walk should find .memstead/workspace.toml");
672        assert_eq!(found.canonicalize().unwrap(), ws.canonicalize().unwrap());
673    }
674
675    #[test]
676    fn find_workspace_root_returns_none_when_absent() {
677        let tmp = TempDir::new().unwrap();
678        let nested = tmp.path().join("a").join("b");
679        std::fs::create_dir_all(&nested).unwrap();
680        assert!(find_workspace_root(&nested).is_none());
681    }
682
683    #[test]
684    fn find_workspace_root_stops_at_containing_dir() {
685        let tmp = TempDir::new().unwrap();
686        let ws = tmp.path().join("ws");
687        std::fs::create_dir_all(&ws).unwrap();
688        touch_marker(&ws);
689        let found = find_workspace_root(&ws).expect("ws itself carries .memstead/workspace.toml");
690        assert_eq!(found, ws);
691    }
692
693    #[test]
694    fn find_workspace_root_accepts_file_start() {
695        let tmp = TempDir::new().unwrap();
696        let ws = tmp.path().join("ws");
697        std::fs::create_dir_all(&ws).unwrap();
698        touch_marker(&ws);
699        let file = ws.join("some-file.md");
700        std::fs::write(&file, "").unwrap();
701        let found = find_workspace_root(&file).expect("file start should resolve to its dir");
702        assert_eq!(found, ws);
703    }
704
705    #[test]
706    fn find_workspace_root_deeper_marker_wins() {
707        // Outer and inner each carry `.memstead/workspace.toml`. The walk
708        // starts deep inside the inner dir and must resolve to the
709        // inner — deeper marker wins because the upward walk stops at
710        // the first match.
711        let tmp = TempDir::new().unwrap();
712        let outer = tmp.path().join("outer");
713        let inner = outer.join("inner");
714        let deep = inner.join("a").join("b");
715        std::fs::create_dir_all(&deep).unwrap();
716        touch_marker(&outer);
717        touch_marker(&inner);
718        let found = find_workspace_root(&deep).expect("walk should find the inner marker");
719        assert_eq!(found.canonicalize().unwrap(), inner.canonicalize().unwrap());
720    }
721}