1use 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#[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#[derive(Args, Debug)]
50pub struct InitArgs {
51 #[arg(value_name = "PATH")]
53 pub path: Option<PathBuf>,
54
55 #[arg(long)]
57 pub name: String,
58
59 #[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 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 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 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 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 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
213fn 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
238pub(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 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
268fn 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 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"); assert_eq!(cfg.schema.as_display(), "default@1.0.0");
338 assert!(cfg.deps.is_empty());
339
340 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 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 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 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 #[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 let cfg = read_workspace_config(&target).unwrap();
411 assert_eq!(cfg.schema.as_display(), "agent-program@0.1.0");
412 }
413
414 #[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 #[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 #[test]
461 fn init_refuses_nested_workspace_under_existing_one() {
462 let tmp = TempDir::new().unwrap();
463 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 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 #[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}