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 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 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 lines.extend(crate::setup::shape_disclosure_lines(
235 crate::setup::WorkspaceShape::Filesystem,
236 ));
237 print_markdown(&lines.join("\n"));
238 Ok(())
239}
240
241fn 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
266pub(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 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
296fn 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 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"); assert_eq!(cfg.schema.as_display(), "default@1.0.0");
367 assert!(cfg.deps.is_empty());
368
369 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 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 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 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 #[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 let cfg = read_workspace_config(&target).unwrap();
440 assert_eq!(cfg.schema.as_display(), "agent-program@0.1.0");
441 }
442
443 #[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 #[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 #[test]
490 fn init_refuses_nested_workspace_under_existing_one() {
491 let tmp = TempDir::new().unwrap();
492 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 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 #[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}