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}