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