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 });
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
232fn 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
257pub(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 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
287fn 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 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"); assert_eq!(cfg.schema.as_display(), "default@1.0.0");
358 assert!(cfg.deps.is_empty());
359
360 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 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 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 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 #[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 let cfg = read_workspace_config(&target).unwrap();
431 assert_eq!(cfg.schema.as_display(), "agent-program@0.1.0");
432 }
433
434 #[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 #[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 #[test]
481 fn init_refuses_nested_workspace_under_existing_one() {
482 let tmp = TempDir::new().unwrap();
483 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 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 #[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}