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