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 let cwd = std::env::current_dir().map_err(|e| {
408 CliError::new(
409 ExitKind::Generic,
410 crate::INTERNAL_CODE,
411 format!("could not determine current directory: {e}"),
412 )
413 })?;
414
415 let Some(root) = find_workspace_root(&cwd) else {
416 return Err(workspace_not_initialised_error(
417 "No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead mem-repo init` to bootstrap).",
418 )
419 .into());
420 };
421
422 if !root.join("mem-repo").join(".git").is_dir() {
423 return Err(CliError {
424 code: "UNSUPPORTED_WORKSPACE_SHAPE",
425 kind: ExitKind::Generic,
426 message:
427 "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."
428 .to_string(),
429 details: None,
430 }
431 .into());
432 }
433
434 let mut engine = engine_from_workspace_root(&root).map_err(|e| boot_error_to_cli(&root, e))?;
435 engine.set_role(_ctx.role);
436 Ok(engine)
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use tempfile::TempDir;
443
444 fn touch_marker(ws: &std::path::Path) {
445 std::fs::create_dir_all(ws.join(".memstead")).unwrap();
446 std::fs::write(ws.join(".memstead").join("workspace.toml"), "").unwrap();
447 }
448
449 #[test]
450 fn find_workspace_root_walks_up_to_marker() {
451 let tmp = TempDir::new().unwrap();
452 let ws = tmp.path().join("ws");
453 let nested = ws.join("a").join("b").join("specs");
454 std::fs::create_dir_all(&nested).unwrap();
455 touch_marker(&ws);
456 let found =
457 find_workspace_root(&nested).expect("walk should find .memstead/workspace.toml");
458 assert_eq!(found.canonicalize().unwrap(), ws.canonicalize().unwrap());
459 }
460
461 #[test]
462 fn find_workspace_root_returns_none_when_absent() {
463 let tmp = TempDir::new().unwrap();
464 let nested = tmp.path().join("a").join("b");
465 std::fs::create_dir_all(&nested).unwrap();
466 assert!(find_workspace_root(&nested).is_none());
467 }
468
469 #[test]
470 fn find_workspace_root_stops_at_containing_dir() {
471 let tmp = TempDir::new().unwrap();
472 let ws = tmp.path().join("ws");
473 std::fs::create_dir_all(&ws).unwrap();
474 touch_marker(&ws);
475 let found = find_workspace_root(&ws).expect("ws itself carries .memstead/workspace.toml");
476 assert_eq!(found, ws);
477 }
478
479 #[test]
480 fn find_workspace_root_accepts_file_start() {
481 let tmp = TempDir::new().unwrap();
482 let ws = tmp.path().join("ws");
483 std::fs::create_dir_all(&ws).unwrap();
484 touch_marker(&ws);
485 let file = ws.join("some-file.md");
486 std::fs::write(&file, "").unwrap();
487 let found = find_workspace_root(&file).expect("file start should resolve to its dir");
488 assert_eq!(found, ws);
489 }
490
491 #[test]
492 fn find_workspace_root_deeper_marker_wins() {
493 // Outer and inner each carry `.memstead/workspace.toml`. The walk
494 // starts deep inside the inner dir and must resolve to the
495 // inner — deeper marker wins because the upward walk stops at
496 // the first match.
497 let tmp = TempDir::new().unwrap();
498 let outer = tmp.path().join("outer");
499 let inner = outer.join("inner");
500 let deep = inner.join("a").join("b");
501 std::fs::create_dir_all(&deep).unwrap();
502 touch_marker(&outer);
503 touch_marker(&inner);
504 let found = find_workspace_root(&deep).expect("walk should find the inner marker");
505 assert_eq!(found.canonicalize().unwrap(), inner.canonicalize().unwrap());
506 }
507}