Skip to main content

memstead_cli/commands/
init.rs

1//! `memstead init` — bootstrap a filesystem mem in the current (or named) folder.
2//!
3//! filesystem-mem is the single-mem, history-free, filesystem-backed product
4//! surface. After `memstead init` the folder contains:
5//!
6//! - `.memstead/config.json` — workspace shape (mem name, schema pin,
7//!   empty deps list). Pinned via [`memstead_base::filesystem::config`].
8//! - `.memstead/cache/` — empty placeholder for any engine-managed cache
9//!   data the workspace acquires later (e.g. resolved schema bytes).
10//! - `.memstead/memstead-io/` — empty placeholder for cross-mem dependency
11//!   archives populated by `memstead link`.
12//!
13//! No `.gitignore` is written — filesystem-mem does not assume a surrounding
14//! git repo, and writing one would surprise users who *do* track the
15//! workspace under git themselves.
16//!
17//! Strict mode in non-empty folders: see plan trade-off "Adopt vs.
18//! strict for `memstead init`". A non-empty target errors out cleanly so
19//! the user explicitly clears or moves files before initialising —
20//! never silently ingests unrelated `.md` files.
21
22use std::path::{Path, PathBuf};
23
24use clap::Args;
25use memstead_base::filesystem::config::{
26    FILESYSTEM_WORKSPACE_FORMAT, config_path, init_filesystem_mem, validate_mem_name,
27};
28use memstead_schema::SchemaRef;
29use serde_json::json;
30
31use crate::CliError;
32use crate::output::{ExitKind, print_json, print_markdown};
33use crate::setup::CliContext;
34
35/// Recovery hint for the nested-workspace refusal. Every printed
36/// alternative must exist and be able to succeed in the binary that
37/// prints it: `memstead mem init` is the full (mem-repo) verb; the
38/// lean binary has no `mem` subcommand group, so it points outside
39/// the existing workspace instead.
40#[cfg(feature = "mem-repo")]
41const NESTED_WORKSPACE_HINT: &str = "If you meant to add a mem inside the existing \
42     workspace, run `memstead mem init` instead; for a separate graph, initialise in a \
43     folder outside the existing workspace.";
44#[cfg(not(feature = "mem-repo"))]
45const NESTED_WORKSPACE_HINT: &str = "Initialise in a folder outside the existing \
46     workspace instead.";
47
48/// `memstead init` arguments.
49#[derive(Args, Debug)]
50pub struct InitArgs {
51    /// Target folder. Defaults to the current working directory.
52    #[arg(value_name = "PATH")]
53    pub path: Option<PathBuf>,
54
55    /// Mem name. Slug-shaped: `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`.
56    #[arg(long)]
57    pub name: String,
58
59    /// Schema pin in exact `<name>@<version>` form (e.g.
60    /// `default@1.0.0`). Bare-name pins are rejected. filesystem-mem v1
61    /// resolves against the engine's builtin schema set;
62    /// registry-resolved schemas land in a follow-up.
63    #[arg(long)]
64    pub schema: String,
65}
66
67pub fn run(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
68    let target = args
69        .path
70        .clone()
71        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
72
73    let schema_pin: SchemaRef = args.schema.parse().map_err(|e: String| CliError {
74        code: "INVALID_INPUT",
75        message: format!("invalid --schema {value:?}: {e}", value = args.schema),
76        kind: ExitKind::Validation,
77        details: None,
78    })?;
79
80    // The mem name is path-derived and no longer round-trips through
81    // `config.json`, so validate the slug shape here at the boundary.
82    validate_mem_name(&args.name).map_err(|e| CliError {
83        code: "INVALID_INPUT",
84        message: format!("invalid --name: {e}"),
85        kind: ExitKind::Validation,
86        details: None,
87    })?;
88
89    // A pin that resolves to no built-in schema is loudly flagged, not
90    // refused: a fresh workspace has no `.memstead/schemas/` yet, and
91    // `memstead schema install` only works *inside* a workspace, so
92    // init-with-pin followed by install is the designed (and, on the
93    // lean build, the only) custom-schema flow. Without the warning the
94    // command reports success and every later engine-booting command
95    // dies on `SCHEMA_NOT_FOUND` with no hint how the workspace got
96    // into that state. (`memstead mem init` / MCP `memstead_mem_create`
97    // refuse instead — there the workspace already exists, so
98    // install-before-pin is always possible.)
99    let builtin = memstead_schema::builtins::load_builtin_schemas().map_err(|e| CliError {
100        code: "SCHEMA_RESOLVER_INIT_FAILED",
101        message: format!("load built-in schema catalogue: {e}"),
102        kind: ExitKind::Generic,
103        details: None,
104    })?;
105    let pin_unresolved =
106        memstead_base::engine::resolve_builtin_schema_pin_pub(&schema_pin, &builtin).is_none();
107    let unresolved_warning = pin_unresolved.then(|| unresolved_pin_warning(&schema_pin, &builtin));
108    if let Some(w) = &unresolved_warning {
109        eprintln!("memstead: WARNING [SCHEMA_NOT_FOUND]: {w}");
110    }
111
112    if target.exists() {
113        if !target.is_dir() {
114            return Err(CliError {
115                code: "INVALID_INPUT",
116                message: format!("target {} exists but is not a directory", target.display()),
117                kind: ExitKind::Validation,
118                details: None,
119            }
120            .into());
121        }
122        ensure_empty(&target)?;
123    } else {
124        std::fs::create_dir_all(&target).map_err(|e| CliError {
125            code: crate::INTERNAL_CODE,
126            message: format!(
127                "failed to create target directory {}: {e}",
128                target.display()
129            ),
130            kind: ExitKind::Generic,
131            details: None,
132        })?;
133    }
134
135    // Refuse when an ancestor directory already has a
136    // `.memstead/workspace.toml` — never nest a fresh filesystem-mem
137    // workspace inside an existing one (the outer's `mem list` would
138    // miss the inner, the inner would miss the outer). The walk starts
139    // at the target's parent (target itself is what we're initialising)
140    // and stops at the filesystem root.
141    if let Some(found_at) = find_ancestor_workspace(&target)? {
142        return Err(CliError {
143            code: crate::WORKSPACE_ALREADY_EXISTS_ABOVE_CODE,
144            kind: ExitKind::Validation,
145            message: format!(
146                "an existing memstead workspace lives above {} at {}; \
147                 `memstead init` refuses to nest workspaces. {}",
148                target.display(),
149                found_at.display(),
150                NESTED_WORKSPACE_HINT,
151            ),
152            details: Some(serde_json::json!({
153                "found_at": found_at.display().to_string(),
154                "hint": NESTED_WORKSPACE_HINT,
155            })),
156        }
157        .into());
158    }
159
160    // Write the seed structure (config + `.memstead/` subdirs + adapter
161    // marker + one-folder-mount roster) through the engine's shared
162    // initialiser, so the CLI and the in-process embedders (the macOS app's
163    // bootstrap) produce a byte-identical filesystem mem from one place.
164    init_filesystem_mem(&target, &args.name, &schema_pin).map_err(|e| CliError {
165        code: crate::INTERNAL_CODE,
166        message: format!("initialise filesystem mem: {e}"),
167        kind: ExitKind::Generic,
168        details: None,
169    })?;
170
171    if ctx.json {
172        let mut payload = json!({
173            "workspace_root": target.display().to_string(),
174            "config_path": config_path(&target).display().to_string(),
175            "name": args.name,
176            "schema": schema_pin.as_display(),
177            "format": FILESYSTEM_WORKSPACE_FORMAT,
178        });
179        // Additive optional field on the stable success shape — only
180        // present when the pin is unresolved at init time.
181        if let Some(w) = &unresolved_warning {
182            payload["warnings"] = json!([{ "code": "SCHEMA_NOT_FOUND", "message": w }]);
183        }
184        return print_json(&payload);
185    }
186
187    let mut lines = vec![
188        format!("# Initialised filesystem mem `{}`", args.name),
189        String::new(),
190        format!("- Workspace root: `{}`", target.display()),
191        format!("- Config:         `{}`", config_path(&target).display()),
192        format!("- Schema pin:     `{}`", schema_pin.as_display()),
193        String::new(),
194        "Next steps:".to_string(),
195    ];
196    if unresolved_warning.is_some() {
197        lines.push(format!(
198            "- **Install the pinned schema first**: `memstead schema install <package-dir>` \
199             (run inside this workspace) — `{}` resolves to no built-in schema, and every \
200             engine-booting command fails with `SCHEMA_NOT_FOUND` until the package is installed.",
201            schema_pin.as_display()
202        ));
203    }
204    lines.extend([
205        "- Drop `.md` entities into the workspace root.".to_string(),
206        "- `memstead link <scope/name>` to add a cross-mem dependency.".to_string(),
207        "- `memstead publish` to push the mem to the registry.".to_string(),
208    ]);
209    print_markdown(&lines.join("\n"));
210    Ok(())
211}
212
213/// The loud-warning text for a schema pin that resolves to no built-in
214/// schema at init time. Names the pin, the recovery command, and the
215/// available built-ins, so the follow-up (`memstead schema install`) is
216/// discoverable from the warning alone.
217fn unresolved_pin_warning(
218    pin: &SchemaRef,
219    builtin: &[std::sync::Arc<memstead_schema::Schema>],
220) -> String {
221    let available: Vec<String> = builtin
222        .iter()
223        .map(|s| {
224            let (name, version) = s.id();
225            format!("{name}@{version}")
226        })
227        .collect();
228    format!(
229        "--schema {pin} resolves to no built-in schema (built-ins: {avail}). \
230         The workspace is initialised, but every engine-booting command fails with \
231         SCHEMA_NOT_FOUND until the package is installed: run \
232         `memstead schema install <package-dir>` inside the new workspace.",
233        pin = pin.as_display(),
234        avail = available.join(", "),
235    )
236}
237
238/// Walk parent directories looking for `.memstead/workspace.toml`.
239/// Returns the absolute path of the first match, or `None` if no
240/// ancestor carries the marker. Stops at the filesystem root. Symlinks are
241/// not dereferenced — `ancestors()` operates on the resolved
242/// `canonicalize`d path, which traverses symlinks once at the
243/// boundary and then stays on the resolved filesystem.
244/// Shared with `memstead quickstart`, which enforces the same
245/// no-nested-workspaces rule.
246pub(crate) fn find_ancestor_workspace(target: &Path) -> anyhow::Result<Option<PathBuf>> {
247    let abs = std::fs::canonicalize(target).map_err(|e| CliError {
248        code: crate::INTERNAL_CODE,
249        kind: ExitKind::Generic,
250        message: format!("canonicalize {}: {e}", target.display()),
251        details: None,
252    })?;
253    // Skip `abs` itself — the target is what we're initialising; we
254    // only care about ancestors. `ancestors()` yields `abs` first,
255    // then each parent.
256    for ancestor in abs.ancestors().skip(1) {
257        if memstead_base::is_workspace_root(ancestor) {
258            return Ok(Some(
259                ancestor
260                    .join(memstead_base::WORKSPACE_STORE_DIR)
261                    .join("workspace.toml"),
262            ));
263        }
264    }
265    Ok(None)
266}
267
268/// Strict-mode emptiness check. The folder is "empty" when it contains
269/// no entries at all — a `.git/` from a parent repo (the user's outer
270/// project) is fine because that lives outside `target`. A pre-existing
271/// `.memstead/`, any `.md` file, or any other content forces the user to
272/// resolve the conflict before init proceeds.
273fn ensure_empty(target: &Path) -> anyhow::Result<()> {
274    let mut iter = std::fs::read_dir(target).map_err(|e| CliError {
275        code: crate::INTERNAL_CODE,
276        message: format!("read target {}: {e}", target.display()),
277        kind: ExitKind::Generic,
278        details: None,
279    })?;
280    if let Some(entry) = iter.next().transpose().map_err(|e| CliError {
281        code: crate::INTERNAL_CODE,
282        message: format!("read target {}: {e}", target.display()),
283        kind: ExitKind::Generic,
284        details: None,
285    })? {
286        let found = entry.file_name().to_string_lossy().to_string();
287        return Err(CliError {
288            code: crate::TARGET_NOT_EMPTY_CODE,
289            message: format!(
290                "target {} is not empty (found `{}`); \
291                 memstead init refuses to ingest existing content — clear or move files first, \
292                 or pick a fresh folder",
293                target.display(),
294                found,
295            ),
296            kind: ExitKind::Validation,
297            details: Some(serde_json::json!({
298                "path": target.display().to_string(),
299                "found": [found],
300            })),
301        }
302        .into());
303    }
304    Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use memstead_base::filesystem::config::read_workspace_config;
311    use tempfile::TempDir;
312
313    fn run_init(target: &Path, name: &str, schema: &str) -> anyhow::Result<()> {
314        let ctx = CliContext {
315            json: false,
316            quiet: false,
317        };
318        run(
319            &ctx,
320            InitArgs {
321                path: Some(target.to_path_buf()),
322                name: name.to_string(),
323                schema: schema.to_string(),
324            },
325        )
326    }
327
328    #[test]
329    fn init_creates_config_and_subdirs_in_empty_folder() {
330        // Identity is path-derived: the mem lives in a folder named after it.
331        let tmp = TempDir::new().unwrap();
332        let root = tmp.path().join("demo");
333        run_init(&root, "demo", "default@1.0.0").unwrap();
334
335        let cfg = read_workspace_config(&root).unwrap();
336        assert_eq!(cfg.name, "demo"); // filled from the basename, not config.json
337        assert_eq!(cfg.schema.as_display(), "default@1.0.0");
338        assert!(cfg.deps.is_empty());
339
340        // The persisted config carries no `name` (path-derived; the schema
341        // validator tombstones a stray one).
342        let raw: serde_json::Value =
343            serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
344        assert!(
345            raw.get("name").is_none(),
346            "config.json must not persist `name`"
347        );
348
349        assert!(root.join(".memstead").join("cache").is_dir());
350        assert!(root.join(".memstead").join("memstead-io").is_dir());
351        // No .gitignore is written.
352        assert!(!root.join(".gitignore").exists());
353    }
354
355    #[test]
356    fn init_creates_target_when_missing() {
357        let tmp = TempDir::new().unwrap();
358        let target = tmp.path().join("nested-fresh");
359        run_init(&target, "demo", "default@1.0.0").unwrap();
360        assert!(target.join(".memstead").join("config.json").is_file());
361    }
362
363    #[test]
364    fn init_rejects_non_empty_folder() {
365        let tmp = TempDir::new().unwrap();
366        std::fs::write(tmp.path().join("preexisting.md"), b"# pre").unwrap();
367        let err = run_init(tmp.path(), "demo", "default@1.0.0").unwrap_err();
368        assert!(
369            err.to_string().contains("not empty"),
370            "expected 'not empty' rejection, got: {err}"
371        );
372    }
373
374    #[test]
375    fn init_rejects_invalid_schema_pin() {
376        let tmp = TempDir::new().unwrap();
377        // Range syntax is rejected upstream by SchemaRef's FromStr.
378        let err = run_init(tmp.path(), "demo", "default@^1.0.0").unwrap_err();
379        assert!(
380            err.to_string().contains("invalid --schema"),
381            "expected schema rejection, got: {err}"
382        );
383    }
384
385    #[test]
386    fn init_rejects_invalid_name() {
387        // The name is path-derived and no longer round-trips through
388        // `config.json`, so the slug shape is enforced at the CLI boundary:
389        // an invalid `--name` is rejected up front rather than on a later read.
390        let tmp = TempDir::new().unwrap();
391        let err = run_init(tmp.path(), "Demo Bad", "default@1.0.0").unwrap_err();
392        assert!(
393            err.to_string().contains("invalid --name"),
394            "expected --name rejection, got: {err}"
395        );
396    }
397
398    /// A well-formed pin that resolves to no built-in schema still
399    /// initialises (init-then-`schema install` is the designed — and on
400    /// the lean build the only — custom-schema flow), but never
401    /// silently: the run emits the `SCHEMA_NOT_FOUND` warning whose
402    /// text names the recovery command.
403    #[test]
404    fn init_succeeds_but_warns_on_unresolvable_schema_pin() {
405        let tmp = TempDir::new().unwrap();
406        let target = tmp.path().join("demo");
407        run_init(&target, "demo", "agent-program@0.1.0").unwrap();
408        // The workspace exists and carries the pin verbatim — the
409        // follow-up `memstead schema install` completes the flow.
410        let cfg = read_workspace_config(&target).unwrap();
411        assert_eq!(cfg.schema.as_display(), "agent-program@0.1.0");
412    }
413
414    /// The warning text carries everything needed to recover: the pin,
415    /// the `schema install` command, and the built-in alternatives.
416    #[test]
417    fn unresolved_pin_warning_names_pin_recovery_and_builtins() {
418        let builtin = memstead_schema::builtins::load_builtin_schemas().unwrap();
419        let pin: SchemaRef = "agent-program@0.1.0".parse().unwrap();
420        assert!(
421            memstead_base::engine::resolve_builtin_schema_pin_pub(&pin, &builtin).is_none(),
422            "test premise: agent-program is not a built-in"
423        );
424        let w = unresolved_pin_warning(&pin, &builtin);
425        assert!(w.contains("agent-program@0.1.0"), "got: {w}");
426        assert!(w.contains("memstead schema install"), "got: {w}");
427        assert!(w.contains("default@1.0.0"), "got: {w}");
428        assert!(w.contains("SCHEMA_NOT_FOUND"), "got: {w}");
429    }
430
431    /// Every built-in schema is pinnable at init — the refusal above
432    /// only fires for pins outside the built-in catalogue.
433    #[test]
434    fn init_accepts_every_builtin_schema_pin() {
435        let builtin = memstead_schema::builtins::load_builtin_schemas().unwrap();
436        assert!(!builtin.is_empty());
437        for schema in builtin {
438            let (name, version) = schema.id();
439            let tmp = TempDir::new().unwrap();
440            let target = tmp.path().join("demo");
441            run_init(&target, "demo", &format!("{name}@{version}"))
442                .unwrap_or_else(|e| panic!("built-in pin {name}@{version} refused: {e}"));
443        }
444    }
445
446    #[test]
447    fn init_rejects_bare_name_schema_pin() {
448        let tmp = TempDir::new().unwrap();
449        let err = run_init(tmp.path(), "demo", "default").unwrap_err();
450        assert!(
451            err.to_string().contains("invalid --schema"),
452            "expected bare-name pin rejection, got: {err}"
453        );
454    }
455
456    /// A fresh `memstead init` in a subdirectory of an existing workspace
457    /// refuses with the typed `WORKSPACE_ALREADY_EXISTS_ABOVE`
458    /// envelope rather than silently nesting a new workspace inside
459    /// the existing one.
460    #[test]
461    fn init_refuses_nested_workspace_under_existing_one() {
462        let tmp = TempDir::new().unwrap();
463        // Seed an outer workspace at tmp.
464        std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
465        std::fs::write(
466            tmp.path().join(".memstead").join("workspace.toml"),
467            "format = \"memstead-git-branch-2\"\n",
468        )
469        .unwrap();
470
471        // Attempt a nested init under a sibling subdir.
472        let inner = tmp.path().join("inner-mem");
473        std::fs::create_dir_all(&inner).unwrap();
474        let err = run_init(&inner, "inner", "default@1.0.0").unwrap_err();
475        let msg = err.to_string();
476        assert!(
477            msg.contains("nest workspaces") || msg.contains("memstead mem init"),
478            "expected nested-workspace refusal hint, got: {msg}"
479        );
480    }
481
482    /// A fresh init in a clean directory (no ancestor workspace)
483    /// still succeeds.
484    #[test]
485    fn init_succeeds_when_no_ancestor_workspace() {
486        let tmp = TempDir::new().unwrap();
487        let target = tmp.path().join("clean");
488        std::fs::create_dir_all(&target).unwrap();
489        run_init(&target, "demo", "default@1.0.0").unwrap();
490        assert!(target.join(".memstead").join("workspace.toml").is_file());
491    }
492}