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            identity: None,
354        };
355        run(
356            &ctx,
357            InitArgs {
358                path: Some(target.to_path_buf()),
359                name: name.to_string(),
360                schema: schema.to_string(),
361            },
362        )
363    }
364
365    #[test]
366    fn init_creates_config_and_subdirs_in_empty_folder() {
367        // Identity is path-derived: the mem lives in a folder named after it.
368        let tmp = TempDir::new().unwrap();
369        let root = tmp.path().join("demo");
370        run_init(&root, "demo", "default@1.0.0").unwrap();
371
372        let cfg = read_workspace_config(&root).unwrap();
373        assert_eq!(cfg.name, "demo"); // filled from the basename, not config.json
374        assert_eq!(cfg.schema.as_display(), "default@1.0.0");
375
376        // The persisted config carries no `name` (path-derived; the schema
377        // validator tombstones a stray one).
378        let raw: serde_json::Value =
379            serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
380        assert!(
381            raw.get("name").is_none(),
382            "config.json must not persist `name`"
383        );
384
385        assert!(root.join(".memstead").join("cache").is_dir());
386        assert!(root.join(".memstead").join("memstead-io").is_dir());
387        // No .gitignore is written.
388        assert!(!root.join(".gitignore").exists());
389    }
390
391    #[test]
392    fn init_creates_target_when_missing() {
393        let tmp = TempDir::new().unwrap();
394        let target = tmp.path().join("nested-fresh");
395        run_init(&target, "demo", "default@1.0.0").unwrap();
396        assert!(target.join(".memstead").join("config.json").is_file());
397    }
398
399    #[test]
400    fn init_rejects_non_empty_folder() {
401        let tmp = TempDir::new().unwrap();
402        std::fs::write(tmp.path().join("preexisting.md"), b"# pre").unwrap();
403        let err = run_init(tmp.path(), "demo", "default@1.0.0").unwrap_err();
404        assert!(
405            err.to_string().contains("not empty"),
406            "expected 'not empty' rejection, got: {err}"
407        );
408    }
409
410    #[test]
411    fn init_rejects_invalid_schema_pin() {
412        let tmp = TempDir::new().unwrap();
413        // Range syntax is rejected upstream by SchemaRef's FromStr.
414        let err = run_init(tmp.path(), "demo", "default@^1.0.0").unwrap_err();
415        assert!(
416            err.to_string().contains("invalid --schema"),
417            "expected schema rejection, got: {err}"
418        );
419    }
420
421    #[test]
422    fn init_rejects_invalid_name() {
423        // The name is path-derived and no longer round-trips through
424        // `config.json`, so the slug shape is enforced at the CLI boundary:
425        // an invalid `--name` is rejected up front rather than on a later read.
426        let tmp = TempDir::new().unwrap();
427        let err = run_init(tmp.path(), "Demo Bad", "default@1.0.0").unwrap_err();
428        assert!(
429            err.to_string().contains("invalid --name"),
430            "expected --name rejection, got: {err}"
431        );
432    }
433
434    /// A well-formed pin that resolves to no built-in schema still
435    /// initialises (init-then-`schema install` is the designed — and on
436    /// the lean build the only — custom-schema flow), but never
437    /// silently: the run emits the `SCHEMA_NOT_FOUND` warning whose
438    /// text names the recovery command.
439    #[test]
440    fn init_succeeds_but_warns_on_unresolvable_schema_pin() {
441        let tmp = TempDir::new().unwrap();
442        let target = tmp.path().join("demo");
443        run_init(&target, "demo", "agent-program@0.1.0").unwrap();
444        // The workspace exists and carries the pin verbatim — the
445        // follow-up `memstead schema install` completes the flow.
446        let cfg = read_workspace_config(&target).unwrap();
447        assert_eq!(cfg.schema.as_display(), "agent-program@0.1.0");
448    }
449
450    /// The warning text carries everything needed to recover: the pin,
451    /// the `schema install` command, and the built-in alternatives.
452    #[test]
453    fn unresolved_pin_warning_names_pin_recovery_and_builtins() {
454        let builtin = memstead_schema::builtins::load_builtin_schemas().unwrap();
455        let pin: SchemaRef = "agent-program@0.1.0".parse().unwrap();
456        assert!(
457            memstead_base::engine::resolve_builtin_schema_pin_pub(&pin, &builtin).is_none(),
458            "test premise: agent-program is not a built-in"
459        );
460        let w = unresolved_pin_warning(&pin, &builtin);
461        assert!(w.contains("agent-program@0.1.0"), "got: {w}");
462        assert!(w.contains("memstead schema install"), "got: {w}");
463        assert!(w.contains("default@1.0.0"), "got: {w}");
464        assert!(w.contains("SCHEMA_NOT_FOUND"), "got: {w}");
465    }
466
467    /// Every built-in schema is pinnable at init — the refusal above
468    /// only fires for pins outside the built-in catalogue.
469    #[test]
470    fn init_accepts_every_builtin_schema_pin() {
471        let builtin = memstead_schema::builtins::load_builtin_schemas().unwrap();
472        assert!(!builtin.is_empty());
473        for schema in builtin {
474            let (name, version) = schema.id();
475            let tmp = TempDir::new().unwrap();
476            let target = tmp.path().join("demo");
477            run_init(&target, "demo", &format!("{name}@{version}"))
478                .unwrap_or_else(|e| panic!("built-in pin {name}@{version} refused: {e}"));
479        }
480    }
481
482    #[test]
483    fn init_rejects_bare_name_schema_pin() {
484        let tmp = TempDir::new().unwrap();
485        let err = run_init(tmp.path(), "demo", "default").unwrap_err();
486        assert!(
487            err.to_string().contains("invalid --schema"),
488            "expected bare-name pin rejection, got: {err}"
489        );
490    }
491
492    /// A fresh `memstead init` in a subdirectory of an existing workspace
493    /// refuses with the typed `WORKSPACE_ALREADY_EXISTS_ABOVE`
494    /// envelope rather than silently nesting a new workspace inside
495    /// the existing one.
496    #[test]
497    fn init_refuses_nested_workspace_under_existing_one() {
498        let tmp = TempDir::new().unwrap();
499        // Seed an outer workspace at tmp.
500        std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
501        std::fs::write(
502            tmp.path().join(".memstead").join("workspace.toml"),
503            "format = \"memstead-git-branch-2\"\n",
504        )
505        .unwrap();
506
507        // Attempt a nested init under a sibling subdir.
508        let inner = tmp.path().join("inner-mem");
509        std::fs::create_dir_all(&inner).unwrap();
510        let err = run_init(&inner, "inner", "default@1.0.0").unwrap_err();
511        let msg = err.to_string();
512        assert!(
513            msg.contains("nest workspaces") || msg.contains("memstead mem init"),
514            "expected nested-workspace refusal hint, got: {msg}"
515        );
516    }
517
518    /// A fresh init in a clean directory (no ancestor workspace)
519    /// still succeeds.
520    #[test]
521    fn init_succeeds_when_no_ancestor_workspace() {
522        let tmp = TempDir::new().unwrap();
523        let target = tmp.path().join("clean");
524        std::fs::create_dir_all(&target).unwrap();
525        run_init(&target, "demo", "default@1.0.0").unwrap();
526        assert!(target.join(".memstead").join("workspace.toml").is_file());
527    }
528}