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