1use std::path::{Path, PathBuf};
28
29use clap::Args;
30use memstead_base::filesystem::config::{
31 FILESYSTEM_WORKSPACE_FORMAT, config_path, init_filesystem_mem, validate_mem_name,
32};
33use memstead_schema::SchemaRef;
34use serde_json::json;
35
36use crate::CliError;
37use crate::output::{ExitKind, print_json, print_markdown};
38use crate::setup::CliContext;
39
40#[cfg(feature = "mem-repo")]
46const NESTED_WORKSPACE_HINT: &str = "If you meant to add a mem inside the existing \
47 workspace, run `memstead mem init` instead; for a separate graph, initialise in a \
48 folder outside the existing workspace.";
49#[cfg(not(feature = "mem-repo"))]
50const NESTED_WORKSPACE_HINT: &str = "Initialise in a folder outside the existing \
51 workspace instead.";
52
53#[derive(Args, Debug)]
55pub struct InitArgs {
56 #[arg(value_name = "PATH")]
58 pub path: Option<PathBuf>,
59
60 #[arg(long)]
62 pub name: String,
63
64 #[arg(long)]
69 pub schema: String,
70}
71
72pub fn run(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
73 let target = args
74 .path
75 .clone()
76 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
77
78 let schema_pin: SchemaRef = args.schema.parse().map_err(|e: String| CliError {
79 code: "INVALID_INPUT",
80 message: format!("invalid --schema {value:?}: {e}", value = args.schema),
81 kind: ExitKind::Validation,
82 details: None,
83 })?;
84
85 validate_mem_name(&args.name).map_err(|e| CliError {
88 code: "INVALID_INPUT",
89 message: format!("invalid --name: {e}"),
90 kind: ExitKind::Validation,
91 details: None,
92 })?;
93
94 let builtin = memstead_schema::builtins::load_builtin_schemas().map_err(|e| CliError {
105 code: "SCHEMA_RESOLVER_INIT_FAILED",
106 message: format!("load built-in schema catalogue: {e}"),
107 kind: ExitKind::Generic,
108 details: None,
109 })?;
110 let pin_unresolved =
111 memstead_base::engine::resolve_builtin_schema_pin_pub(&schema_pin, &builtin).is_none();
112 let unresolved_warning = pin_unresolved.then(|| unresolved_pin_warning(&schema_pin, &builtin));
113 if let Some(w) = &unresolved_warning {
114 eprintln!("memstead: WARNING [SCHEMA_NOT_FOUND]: {w}");
115 }
116
117 if target.exists() {
118 if !target.is_dir() {
119 return Err(CliError {
120 code: "INVALID_INPUT",
121 message: format!("target {} exists but is not a directory", target.display()),
122 kind: ExitKind::Validation,
123 details: None,
124 }
125 .into());
126 }
127 ensure_empty(&target)?;
128 } else {
129 std::fs::create_dir_all(&target).map_err(|e| CliError {
130 code: crate::INTERNAL_CODE,
131 message: format!(
132 "failed to create target directory {}: {e}",
133 target.display()
134 ),
135 kind: ExitKind::Generic,
136 details: None,
137 })?;
138 }
139
140 if let Some(found_at) = find_ancestor_workspace(&target)? {
147 return Err(CliError {
148 code: crate::WORKSPACE_ALREADY_EXISTS_ABOVE_CODE,
149 kind: ExitKind::Validation,
150 message: format!(
151 "an existing memstead workspace lives above {} at {}; \
152 `memstead init` refuses to nest workspaces. {}",
153 target.display(),
154 found_at.display(),
155 NESTED_WORKSPACE_HINT,
156 ),
157 details: Some(serde_json::json!({
158 "found_at": found_at.display().to_string(),
159 "hint": NESTED_WORKSPACE_HINT,
160 })),
161 }
162 .into());
163 }
164
165 init_filesystem_mem(&target, &args.name, &schema_pin).map_err(|e| CliError {
170 code: crate::INTERNAL_CODE,
171 message: format!("initialise filesystem mem: {e}"),
172 kind: ExitKind::Generic,
173 details: None,
174 })?;
175
176 let provenance_notice = memstead_base::ops::WarningHint::FolderMemProvenance {
181 mem: args.name.clone(),
182 };
183
184 if ctx.json {
185 let mut warnings = vec![json!({
186 "code": provenance_notice.code(),
187 "message": provenance_notice.message(),
188 })];
189 if let Some(w) = &unresolved_warning {
192 warnings.push(json!({ "code": "SCHEMA_NOT_FOUND", "message": w }));
193 }
194 let mut payload = json!({
195 "workspace_root": target.display().to_string(),
196 "config_path": config_path(&target).display().to_string(),
197 "name": args.name,
198 "schema": schema_pin.as_display(),
199 "format": FILESYSTEM_WORKSPACE_FORMAT,
200 "workspace_shape": crate::setup::WorkspaceShape::Filesystem.label(),
201 "workspace_shape_disclosure":
202 crate::setup::shape_disclosure(crate::setup::WorkspaceShape::Filesystem).to_json(),
203 });
204 payload["warnings"] = json!(warnings);
205 return print_json(&payload);
206 }
207
208 let mut lines = vec![
209 format!("# Initialised filesystem mem `{}`", args.name),
210 String::new(),
211 format!("- Workspace root: `{}`", target.display()),
212 format!("- Config: `{}`", config_path(&target).display()),
213 format!("- Schema pin: `{}`", schema_pin.as_display()),
214 String::new(),
215 "Next steps:".to_string(),
216 ];
217 if unresolved_warning.is_some() {
218 lines.push(format!(
219 "- **Install the pinned schema first**: `memstead schema install <package-dir>` \
220 (run inside this workspace) — `{}` resolves to no built-in schema, and every \
221 engine-booting command fails with `SCHEMA_NOT_FOUND` until the package is installed.",
222 schema_pin.as_display()
223 ));
224 }
225 lines.extend([
226 "- Drop `.md` entities into the workspace root.".to_string(),
227 "- `memstead install <scope>/<name>` to attach a registry-published mem \
228 as a read-only mem."
229 .to_string(),
230 "- `memstead publish` to push the mem to the registry.".to_string(),
231 String::new(),
232 format!(
233 "> [{}] {}",
234 provenance_notice.code(),
235 provenance_notice.message()
236 ),
237 String::new(),
238 ]);
239 lines.extend(crate::setup::shape_disclosure_lines(
242 crate::setup::WorkspaceShape::Filesystem,
243 ));
244 print_markdown(&lines.join("\n"));
245 Ok(())
246}
247
248fn unresolved_pin_warning(
253 pin: &SchemaRef,
254 builtin: &[std::sync::Arc<memstead_schema::Schema>],
255) -> String {
256 let available: Vec<String> = builtin
257 .iter()
258 .map(|s| {
259 let (name, version) = s.id();
260 format!("{name}@{version}")
261 })
262 .collect();
263 format!(
264 "--schema {pin} resolves to no built-in schema (built-ins: {avail}). \
265 The workspace is initialised, but every engine-booting command fails with \
266 SCHEMA_NOT_FOUND until the package is installed: run \
267 `memstead schema install <package-dir>` inside the new workspace.",
268 pin = pin.as_display(),
269 avail = available.join(", "),
270 )
271}
272
273pub(crate) fn find_ancestor_workspace(target: &Path) -> anyhow::Result<Option<PathBuf>> {
282 let abs = std::fs::canonicalize(target).map_err(|e| CliError {
283 code: crate::INTERNAL_CODE,
284 kind: ExitKind::Generic,
285 message: format!("canonicalize {}: {e}", target.display()),
286 details: None,
287 })?;
288 for ancestor in abs.ancestors().skip(1) {
292 if memstead_base::is_workspace_root(ancestor) {
293 return Ok(Some(
294 ancestor
295 .join(memstead_base::WORKSPACE_STORE_DIR)
296 .join("workspace.toml"),
297 ));
298 }
299 }
300 Ok(None)
301}
302
303fn ensure_empty(target: &Path) -> anyhow::Result<()> {
309 let mut iter = std::fs::read_dir(target).map_err(|e| CliError {
310 code: crate::INTERNAL_CODE,
311 message: format!("read target {}: {e}", target.display()),
312 kind: ExitKind::Generic,
313 details: None,
314 })?;
315 if let Some(entry) = iter.next().transpose().map_err(|e| CliError {
316 code: crate::INTERNAL_CODE,
317 message: format!("read target {}: {e}", target.display()),
318 kind: ExitKind::Generic,
319 details: None,
320 })? {
321 let found = entry.file_name().to_string_lossy().to_string();
322 return Err(CliError {
323 code: crate::TARGET_NOT_EMPTY_CODE,
324 message: format!(
325 "target {} is not empty (found `{}`); \
326 memstead init refuses to ingest existing content — clear or move files first, \
327 or pick a fresh folder",
328 target.display(),
329 found,
330 ),
331 kind: ExitKind::Validation,
332 details: Some(serde_json::json!({
333 "path": target.display().to_string(),
334 "found": [found],
335 })),
336 }
337 .into());
338 }
339 Ok(())
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use memstead_base::filesystem::config::read_workspace_config;
346 use tempfile::TempDir;
347
348 fn run_init(target: &Path, name: &str, schema: &str) -> anyhow::Result<()> {
349 let ctx = CliContext {
350 json: false,
351 quiet: false,
352 role: Default::default(),
353 };
354 run(
355 &ctx,
356 InitArgs {
357 path: Some(target.to_path_buf()),
358 name: name.to_string(),
359 schema: schema.to_string(),
360 },
361 )
362 }
363
364 #[test]
365 fn init_creates_config_and_subdirs_in_empty_folder() {
366 let tmp = TempDir::new().unwrap();
368 let root = tmp.path().join("demo");
369 run_init(&root, "demo", "default@1.0.0").unwrap();
370
371 let cfg = read_workspace_config(&root).unwrap();
372 assert_eq!(cfg.name, "demo"); assert_eq!(cfg.schema.as_display(), "default@1.0.0");
374
375 let raw: serde_json::Value =
378 serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
379 assert!(
380 raw.get("name").is_none(),
381 "config.json must not persist `name`"
382 );
383
384 assert!(root.join(".memstead").join("cache").is_dir());
385 assert!(root.join(".memstead").join("memstead-io").is_dir());
386 assert!(!root.join(".gitignore").exists());
388 }
389
390 #[test]
391 fn init_creates_target_when_missing() {
392 let tmp = TempDir::new().unwrap();
393 let target = tmp.path().join("nested-fresh");
394 run_init(&target, "demo", "default@1.0.0").unwrap();
395 assert!(target.join(".memstead").join("config.json").is_file());
396 }
397
398 #[test]
399 fn init_rejects_non_empty_folder() {
400 let tmp = TempDir::new().unwrap();
401 std::fs::write(tmp.path().join("preexisting.md"), b"# pre").unwrap();
402 let err = run_init(tmp.path(), "demo", "default@1.0.0").unwrap_err();
403 assert!(
404 err.to_string().contains("not empty"),
405 "expected 'not empty' rejection, got: {err}"
406 );
407 }
408
409 #[test]
410 fn init_rejects_invalid_schema_pin() {
411 let tmp = TempDir::new().unwrap();
412 let err = run_init(tmp.path(), "demo", "default@^1.0.0").unwrap_err();
414 assert!(
415 err.to_string().contains("invalid --schema"),
416 "expected schema rejection, got: {err}"
417 );
418 }
419
420 #[test]
421 fn init_rejects_invalid_name() {
422 let tmp = TempDir::new().unwrap();
426 let err = run_init(tmp.path(), "Demo Bad", "default@1.0.0").unwrap_err();
427 assert!(
428 err.to_string().contains("invalid --name"),
429 "expected --name rejection, got: {err}"
430 );
431 }
432
433 #[test]
439 fn init_succeeds_but_warns_on_unresolvable_schema_pin() {
440 let tmp = TempDir::new().unwrap();
441 let target = tmp.path().join("demo");
442 run_init(&target, "demo", "agent-program@0.1.0").unwrap();
443 let cfg = read_workspace_config(&target).unwrap();
446 assert_eq!(cfg.schema.as_display(), "agent-program@0.1.0");
447 }
448
449 #[test]
452 fn unresolved_pin_warning_names_pin_recovery_and_builtins() {
453 let builtin = memstead_schema::builtins::load_builtin_schemas().unwrap();
454 let pin: SchemaRef = "agent-program@0.1.0".parse().unwrap();
455 assert!(
456 memstead_base::engine::resolve_builtin_schema_pin_pub(&pin, &builtin).is_none(),
457 "test premise: agent-program is not a built-in"
458 );
459 let w = unresolved_pin_warning(&pin, &builtin);
460 assert!(w.contains("agent-program@0.1.0"), "got: {w}");
461 assert!(w.contains("memstead schema install"), "got: {w}");
462 assert!(w.contains("default@1.0.0"), "got: {w}");
463 assert!(w.contains("SCHEMA_NOT_FOUND"), "got: {w}");
464 }
465
466 #[test]
469 fn init_accepts_every_builtin_schema_pin() {
470 let builtin = memstead_schema::builtins::load_builtin_schemas().unwrap();
471 assert!(!builtin.is_empty());
472 for schema in builtin {
473 let (name, version) = schema.id();
474 let tmp = TempDir::new().unwrap();
475 let target = tmp.path().join("demo");
476 run_init(&target, "demo", &format!("{name}@{version}"))
477 .unwrap_or_else(|e| panic!("built-in pin {name}@{version} refused: {e}"));
478 }
479 }
480
481 #[test]
482 fn init_rejects_bare_name_schema_pin() {
483 let tmp = TempDir::new().unwrap();
484 let err = run_init(tmp.path(), "demo", "default").unwrap_err();
485 assert!(
486 err.to_string().contains("invalid --schema"),
487 "expected bare-name pin rejection, got: {err}"
488 );
489 }
490
491 #[test]
496 fn init_refuses_nested_workspace_under_existing_one() {
497 let tmp = TempDir::new().unwrap();
498 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
500 std::fs::write(
501 tmp.path().join(".memstead").join("workspace.toml"),
502 "format = \"memstead-git-branch-2\"\n",
503 )
504 .unwrap();
505
506 let inner = tmp.path().join("inner-mem");
508 std::fs::create_dir_all(&inner).unwrap();
509 let err = run_init(&inner, "inner", "default@1.0.0").unwrap_err();
510 let msg = err.to_string();
511 assert!(
512 msg.contains("nest workspaces") || msg.contains("memstead mem init"),
513 "expected nested-workspace refusal hint, got: {msg}"
514 );
515 }
516
517 #[test]
520 fn init_succeeds_when_no_ancestor_workspace() {
521 let tmp = TempDir::new().unwrap();
522 let target = tmp.path().join("clean");
523 std::fs::create_dir_all(&target).unwrap();
524 run_init(&target, "demo", "default@1.0.0").unwrap();
525 assert!(target.join(".memstead").join("workspace.toml").is_file());
526 }
527}