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    // Folder-mem provenance notice: this storage class has no version
172    // control, so say at creation what provenance means here. Shares
173    // the engine's typed warning so the CLI and `memstead_mem_create`
174    // read as one voice. A warning, never a refusal.
175    let provenance_notice = memstead_base::ops::WarningHint::FolderMemProvenance {
176        mem: args.name.clone(),
177    };
178
179    if ctx.json {
180        let mut warnings = vec![json!({
181            "code": provenance_notice.code(),
182            "message": provenance_notice.message(),
183        })];
184        // Additive optional entry on the stable success shape — only
185        // present when the pin is unresolved at init time.
186        if let Some(w) = &unresolved_warning {
187            warnings.push(json!({ "code": "SCHEMA_NOT_FOUND", "message": w }));
188        }
189        let mut payload = json!({
190            "workspace_root": target.display().to_string(),
191            "config_path": config_path(&target).display().to_string(),
192            "name": args.name,
193            "schema": schema_pin.as_display(),
194            "format": FILESYSTEM_WORKSPACE_FORMAT,
195            "workspace_shape": crate::setup::WorkspaceShape::Filesystem.label(),
196            "workspace_shape_disclosure":
197                crate::setup::shape_disclosure(crate::setup::WorkspaceShape::Filesystem).to_json(),
198        });
199        payload["warnings"] = json!(warnings);
200        return print_json(&payload);
201    }
202
203    let mut lines = vec![
204        format!("# Initialised filesystem mem `{}`", args.name),
205        String::new(),
206        format!("- Workspace root: `{}`", target.display()),
207        format!("- Config:         `{}`", config_path(&target).display()),
208        format!("- Schema pin:     `{}`", schema_pin.as_display()),
209        String::new(),
210        "Next steps:".to_string(),
211    ];
212    if unresolved_warning.is_some() {
213        lines.push(format!(
214            "- **Install the pinned schema first**: `memstead schema install <package-dir>` \
215             (run inside this workspace) — `{}` resolves to no built-in schema, and every \
216             engine-booting command fails with `SCHEMA_NOT_FOUND` until the package is installed.",
217            schema_pin.as_display()
218        ));
219    }
220    lines.extend([
221        "- Drop `.md` entities into the workspace root.".to_string(),
222        "- `memstead link <scope/name>` to add a cross-mem dependency.".to_string(),
223        "- `memstead publish` to push the mem to the registry.".to_string(),
224        String::new(),
225        format!(
226            "> [{}] {}",
227            provenance_notice.code(),
228            provenance_notice.message()
229        ),
230        String::new(),
231    ]);
232    // `init` picks the same fork `quickstart` does — silently, and for
233    // the same reader. The disclosure is identical on both verbs.
234    lines.extend(crate::setup::shape_disclosure_lines(
235        crate::setup::WorkspaceShape::Filesystem,
236    ));
237    print_markdown(&lines.join("\n"));
238    Ok(())
239}
240
241/// The loud-warning text for a schema pin that resolves to no built-in
242/// schema at init time. Names the pin, the recovery command, and the
243/// available built-ins, so the follow-up (`memstead schema install`) is
244/// discoverable from the warning alone.
245fn unresolved_pin_warning(
246    pin: &SchemaRef,
247    builtin: &[std::sync::Arc<memstead_schema::Schema>],
248) -> String {
249    let available: Vec<String> = builtin
250        .iter()
251        .map(|s| {
252            let (name, version) = s.id();
253            format!("{name}@{version}")
254        })
255        .collect();
256    format!(
257        "--schema {pin} resolves to no built-in schema (built-ins: {avail}). \
258         The workspace is initialised, but every engine-booting command fails with \
259         SCHEMA_NOT_FOUND until the package is installed: run \
260         `memstead schema install <package-dir>` inside the new workspace.",
261        pin = pin.as_display(),
262        avail = available.join(", "),
263    )
264}
265
266/// Walk parent directories looking for `.memstead/workspace.toml`.
267/// Returns the absolute path of the first match, or `None` if no
268/// ancestor carries the marker. Stops at the filesystem root. Symlinks are
269/// not dereferenced — `ancestors()` operates on the resolved
270/// `canonicalize`d path, which traverses symlinks once at the
271/// boundary and then stays on the resolved filesystem.
272/// Shared with `memstead quickstart`, which enforces the same
273/// no-nested-workspaces rule.
274pub(crate) fn find_ancestor_workspace(target: &Path) -> anyhow::Result<Option<PathBuf>> {
275    let abs = std::fs::canonicalize(target).map_err(|e| CliError {
276        code: crate::INTERNAL_CODE,
277        kind: ExitKind::Generic,
278        message: format!("canonicalize {}: {e}", target.display()),
279        details: None,
280    })?;
281    // Skip `abs` itself — the target is what we're initialising; we
282    // only care about ancestors. `ancestors()` yields `abs` first,
283    // then each parent.
284    for ancestor in abs.ancestors().skip(1) {
285        if memstead_base::is_workspace_root(ancestor) {
286            return Ok(Some(
287                ancestor
288                    .join(memstead_base::WORKSPACE_STORE_DIR)
289                    .join("workspace.toml"),
290            ));
291        }
292    }
293    Ok(None)
294}
295
296/// Strict-mode emptiness check. The folder is "empty" when it contains
297/// no entries at all — a `.git/` from a parent repo (the user's outer
298/// project) is fine because that lives outside `target`. A pre-existing
299/// `.memstead/`, any `.md` file, or any other content forces the user to
300/// resolve the conflict before init proceeds.
301fn ensure_empty(target: &Path) -> anyhow::Result<()> {
302    let mut iter = std::fs::read_dir(target).map_err(|e| CliError {
303        code: crate::INTERNAL_CODE,
304        message: format!("read target {}: {e}", target.display()),
305        kind: ExitKind::Generic,
306        details: None,
307    })?;
308    if let Some(entry) = iter.next().transpose().map_err(|e| CliError {
309        code: crate::INTERNAL_CODE,
310        message: format!("read target {}: {e}", target.display()),
311        kind: ExitKind::Generic,
312        details: None,
313    })? {
314        let found = entry.file_name().to_string_lossy().to_string();
315        return Err(CliError {
316            code: crate::TARGET_NOT_EMPTY_CODE,
317            message: format!(
318                "target {} is not empty (found `{}`); \
319                 memstead init refuses to ingest existing content — clear or move files first, \
320                 or pick a fresh folder",
321                target.display(),
322                found,
323            ),
324            kind: ExitKind::Validation,
325            details: Some(serde_json::json!({
326                "path": target.display().to_string(),
327                "found": [found],
328            })),
329        }
330        .into());
331    }
332    Ok(())
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use memstead_base::filesystem::config::read_workspace_config;
339    use tempfile::TempDir;
340
341    fn run_init(target: &Path, name: &str, schema: &str) -> anyhow::Result<()> {
342        let ctx = CliContext {
343            json: false,
344            quiet: false,
345            role: Default::default(),
346        };
347        run(
348            &ctx,
349            InitArgs {
350                path: Some(target.to_path_buf()),
351                name: name.to_string(),
352                schema: schema.to_string(),
353            },
354        )
355    }
356
357    #[test]
358    fn init_creates_config_and_subdirs_in_empty_folder() {
359        // Identity is path-derived: the mem lives in a folder named after it.
360        let tmp = TempDir::new().unwrap();
361        let root = tmp.path().join("demo");
362        run_init(&root, "demo", "default@1.0.0").unwrap();
363
364        let cfg = read_workspace_config(&root).unwrap();
365        assert_eq!(cfg.name, "demo"); // filled from the basename, not config.json
366        assert_eq!(cfg.schema.as_display(), "default@1.0.0");
367        assert!(cfg.deps.is_empty());
368
369        // The persisted config carries no `name` (path-derived; the schema
370        // validator tombstones a stray one).
371        let raw: serde_json::Value =
372            serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
373        assert!(
374            raw.get("name").is_none(),
375            "config.json must not persist `name`"
376        );
377
378        assert!(root.join(".memstead").join("cache").is_dir());
379        assert!(root.join(".memstead").join("memstead-io").is_dir());
380        // No .gitignore is written.
381        assert!(!root.join(".gitignore").exists());
382    }
383
384    #[test]
385    fn init_creates_target_when_missing() {
386        let tmp = TempDir::new().unwrap();
387        let target = tmp.path().join("nested-fresh");
388        run_init(&target, "demo", "default@1.0.0").unwrap();
389        assert!(target.join(".memstead").join("config.json").is_file());
390    }
391
392    #[test]
393    fn init_rejects_non_empty_folder() {
394        let tmp = TempDir::new().unwrap();
395        std::fs::write(tmp.path().join("preexisting.md"), b"# pre").unwrap();
396        let err = run_init(tmp.path(), "demo", "default@1.0.0").unwrap_err();
397        assert!(
398            err.to_string().contains("not empty"),
399            "expected 'not empty' rejection, got: {err}"
400        );
401    }
402
403    #[test]
404    fn init_rejects_invalid_schema_pin() {
405        let tmp = TempDir::new().unwrap();
406        // Range syntax is rejected upstream by SchemaRef's FromStr.
407        let err = run_init(tmp.path(), "demo", "default@^1.0.0").unwrap_err();
408        assert!(
409            err.to_string().contains("invalid --schema"),
410            "expected schema rejection, got: {err}"
411        );
412    }
413
414    #[test]
415    fn init_rejects_invalid_name() {
416        // The name is path-derived and no longer round-trips through
417        // `config.json`, so the slug shape is enforced at the CLI boundary:
418        // an invalid `--name` is rejected up front rather than on a later read.
419        let tmp = TempDir::new().unwrap();
420        let err = run_init(tmp.path(), "Demo Bad", "default@1.0.0").unwrap_err();
421        assert!(
422            err.to_string().contains("invalid --name"),
423            "expected --name rejection, got: {err}"
424        );
425    }
426
427    /// A well-formed pin that resolves to no built-in schema still
428    /// initialises (init-then-`schema install` is the designed — and on
429    /// the lean build the only — custom-schema flow), but never
430    /// silently: the run emits the `SCHEMA_NOT_FOUND` warning whose
431    /// text names the recovery command.
432    #[test]
433    fn init_succeeds_but_warns_on_unresolvable_schema_pin() {
434        let tmp = TempDir::new().unwrap();
435        let target = tmp.path().join("demo");
436        run_init(&target, "demo", "agent-program@0.1.0").unwrap();
437        // The workspace exists and carries the pin verbatim — the
438        // follow-up `memstead schema install` completes the flow.
439        let cfg = read_workspace_config(&target).unwrap();
440        assert_eq!(cfg.schema.as_display(), "agent-program@0.1.0");
441    }
442
443    /// The warning text carries everything needed to recover: the pin,
444    /// the `schema install` command, and the built-in alternatives.
445    #[test]
446    fn unresolved_pin_warning_names_pin_recovery_and_builtins() {
447        let builtin = memstead_schema::builtins::load_builtin_schemas().unwrap();
448        let pin: SchemaRef = "agent-program@0.1.0".parse().unwrap();
449        assert!(
450            memstead_base::engine::resolve_builtin_schema_pin_pub(&pin, &builtin).is_none(),
451            "test premise: agent-program is not a built-in"
452        );
453        let w = unresolved_pin_warning(&pin, &builtin);
454        assert!(w.contains("agent-program@0.1.0"), "got: {w}");
455        assert!(w.contains("memstead schema install"), "got: {w}");
456        assert!(w.contains("default@1.0.0"), "got: {w}");
457        assert!(w.contains("SCHEMA_NOT_FOUND"), "got: {w}");
458    }
459
460    /// Every built-in schema is pinnable at init — the refusal above
461    /// only fires for pins outside the built-in catalogue.
462    #[test]
463    fn init_accepts_every_builtin_schema_pin() {
464        let builtin = memstead_schema::builtins::load_builtin_schemas().unwrap();
465        assert!(!builtin.is_empty());
466        for schema in builtin {
467            let (name, version) = schema.id();
468            let tmp = TempDir::new().unwrap();
469            let target = tmp.path().join("demo");
470            run_init(&target, "demo", &format!("{name}@{version}"))
471                .unwrap_or_else(|e| panic!("built-in pin {name}@{version} refused: {e}"));
472        }
473    }
474
475    #[test]
476    fn init_rejects_bare_name_schema_pin() {
477        let tmp = TempDir::new().unwrap();
478        let err = run_init(tmp.path(), "demo", "default").unwrap_err();
479        assert!(
480            err.to_string().contains("invalid --schema"),
481            "expected bare-name pin rejection, got: {err}"
482        );
483    }
484
485    /// A fresh `memstead init` in a subdirectory of an existing workspace
486    /// refuses with the typed `WORKSPACE_ALREADY_EXISTS_ABOVE`
487    /// envelope rather than silently nesting a new workspace inside
488    /// the existing one.
489    #[test]
490    fn init_refuses_nested_workspace_under_existing_one() {
491        let tmp = TempDir::new().unwrap();
492        // Seed an outer workspace at tmp.
493        std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
494        std::fs::write(
495            tmp.path().join(".memstead").join("workspace.toml"),
496            "format = \"memstead-git-branch-2\"\n",
497        )
498        .unwrap();
499
500        // Attempt a nested init under a sibling subdir.
501        let inner = tmp.path().join("inner-mem");
502        std::fs::create_dir_all(&inner).unwrap();
503        let err = run_init(&inner, "inner", "default@1.0.0").unwrap_err();
504        let msg = err.to_string();
505        assert!(
506            msg.contains("nest workspaces") || msg.contains("memstead mem init"),
507            "expected nested-workspace refusal hint, got: {msg}"
508        );
509    }
510
511    /// A fresh init in a clean directory (no ancestor workspace)
512    /// still succeeds.
513    #[test]
514    fn init_succeeds_when_no_ancestor_workspace() {
515        let tmp = TempDir::new().unwrap();
516        let target = tmp.path().join("clean");
517        std::fs::create_dir_all(&target).unwrap();
518        run_init(&target, "demo", "default@1.0.0").unwrap();
519        assert!(target.join(".memstead").join("workspace.toml").is_file());
520    }
521}