memstead_cli/commands/create.rs
1//! `memstead create` — create a new entity from flags or a JSON file.
2//!
3//! Mirrors `memstead_create` in the MCP surface. Two input modes:
4//!
5//! * **Flags.** `--title`, `--type` (required), plus repeatable
6//! `--section key=value`, `--metadata key=value`, `--relation type:to`.
7//! * **JSON file.** `--from payload.json`. Same shape as `memstead_create`'s
8//! `CreateParams`.
9
10use std::path::PathBuf;
11
12use clap::Parser;
13use indexmap::IndexMap;
14use serde::Deserialize;
15
16use memstead_base::CreateEntityArgs;
17use memstead_base::EntityId;
18use memstead_base::ops::RelateArg;
19use memstead_base::vcs::Actor;
20
21use crate::CliError;
22use crate::output::{ExitKind, print_json, print_markdown};
23use crate::setup::{CliContext, CliEngine};
24
25#[derive(Parser, Debug)]
26#[command(after_long_help = super::create_after_long_help())]
27pub struct Args {
28 /// Entity title. Required unless `--from` is given.
29 #[arg(long)]
30 pub title: Option<String>,
31
32 /// Entity type (e.g. `spec`, `memo`, `concept`).
33 /// Required unless `--from` is given.
34 #[arg(long = "type")]
35 pub entity_type: Option<String>,
36
37 /// Mem name. Defaults to the first writable mem.
38 #[arg(long)]
39 pub mem: Option<String>,
40
41 /// Section content: repeatable `--section key=value`. Body
42 /// wiki-links must take slug-form (`[[idempotency]]`, not the
43 /// title-case `[[Idempotency]]`) — a non-slug target refuses with
44 /// `INVALID_WIKI_LINK_TARGET` carrying a `proposed_slug` to retry with.
45 #[arg(long = "section", value_name = "KEY=VALUE")]
46 pub sections: Vec<String>,
47
48 /// Metadata override: repeatable `--metadata key=value`.
49 #[arg(long = "metadata", value_name = "KEY=VALUE")]
50 pub metadata: Vec<String>,
51
52 /// Initial relationship: repeatable `--relation TYPE:target-id`.
53 /// Works on both workspace shapes. A target that does not exist yet
54 /// is materialised as a forward-reference stub, same as on the MCP
55 /// surface.
56 #[arg(long = "relation", value_name = "TYPE:TARGET")]
57 pub relations: Vec<String>,
58
59 /// Provenance anchor: repeatable `--anchor '<json>'`, each a JSON
60 /// object of the anchor shape (`{ "artifact": "...", "grain": "file",
61 /// "class": "anchored", "hash": "...", "hash_stability": "stable" }`).
62 /// Written into the mem-branch anchors sidecar in the same commit as
63 /// the entity. A malformed anchor refuses `INVALID_ANCHOR`. Ignored
64 /// when `--from` is given (the file's `anchors[]` is authoritative).
65 #[arg(long = "anchor", value_name = "JSON")]
66 pub anchors: Vec<String>,
67
68 /// JSON file matching the MCP `memstead_create` args shape. If set,
69 /// all `--title` / `--type` / `--section` / `--metadata` / `--relation`
70 /// / `--anchor` flags are ignored (the file is the single source of truth).
71 /// `--note` still applies (winning over the file's `note`), and
72 /// `--dry-run` ORs with the file's `dry_run` — same semantics as
73 /// `update --from`, so one template feeds both commands.
74 /// The JSON type field is `entity_type` (not `type`), matching the
75 /// response envelopes — a previous `--json` response pipes back in
76 /// unchanged.
77 #[arg(long = "from", value_name = "FILE")]
78 pub from: Option<PathBuf>,
79
80 /// Preview only — validate and compute the result without writing to
81 /// disk, mutating the store, or producing a commit. Response carries
82 /// the prospective id / file_path / content_hash plus any warnings.
83 /// MEM-REPO WORKSPACES ONLY — refused with `INVALID_INPUT` on the
84 /// filesystem-mem workspace `memstead quickstart` produces.
85 #[arg(long = "dry-run")]
86 pub dry_run: bool,
87
88 /// Agent-authored provenance note (≤280 chars, one sentence
89 /// describing why this mutation happened). Lands in the per-mem
90 /// commit body between the mechanical subject line and the
91 /// provenance trailers. When `[mutations].require_notes = true` in
92 /// workspace config a missing note adds a `NOTE_MISSING` warning
93 /// to the response (the mutation still commits). When `--from` also
94 /// carries a `note`, this flag takes precedence.
95 #[arg(long)]
96 pub note: Option<String>,
97}
98
99/// On-disk JSON payload shape — mirrors MCP `CreateParams` exactly.
100///
101/// The type field is `entity_type`, matching the response envelopes
102/// every read/write surface emits, so an agent can pipe a previous
103/// `memstead create --json` response back through `--from` for a
104/// follow-up create without renaming the field.
105#[derive(Debug, Deserialize)]
106#[serde(deny_unknown_fields)]
107struct CreatePayload {
108 title: String,
109 entity_type: String,
110 mem: Option<String>,
111 #[serde(default)]
112 sections: IndexMap<String, String>,
113 #[serde(default)]
114 metadata: IndexMap<String, String>,
115 #[serde(default)]
116 relations: Vec<RelationPayload>,
117 /// Provenance anchors — matches the MCP `memstead_create` `anchors[]`
118 /// shape. Each element is validated engine-side into a typed
119 /// `INVALID_ANCHOR` refusal on malformed input.
120 #[serde(default)]
121 anchors: Vec<memstead_base::anchor::AnchorInput>,
122 /// Agent-authored provenance note — matches the MCP `memstead_create`
123 /// shape's `note`. Optional; the command-line `--note` takes
124 /// precedence when both are supplied.
125 #[serde(default)]
126 note: Option<String>,
127 /// Preview-only marker — OR-ed with the `--dry-run` flag, same
128 /// semantics as `update --from`. One JSON template can therefore
129 /// feed both `create --from` and `update --from`. The
130 /// optimistic-locking selectors (`auto_hash`, `force`) are
131 /// deliberately flag-only on both commands: a stored payload must
132 /// never be able to disable locking on a future run.
133 #[serde(default)]
134 dry_run: bool,
135 /// Tolerated for template symmetry with `update --from` (one JSON
136 /// document feeds both commands). Create derives the entity id
137 /// from the title, so a supplied `id` is only *checked*: a value
138 /// whose slug part does not match the derived slug refuses with
139 /// `INVALID_INPUT` rather than silently landing elsewhere.
140 #[serde(default)]
141 id: Option<String>,
142 /// Tolerated for template symmetry with `update --from`. Create
143 /// has no stored state to compare a hash against; the value is
144 /// ignored (documented, not silent — this doc is the statement).
145 #[serde(default)]
146 #[allow(dead_code)]
147 expected_hash: Option<String>,
148}
149
150#[derive(Debug, Deserialize)]
151#[serde(deny_unknown_fields)]
152#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
153struct RelationPayload {
154 to: String,
155 #[serde(rename = "type")]
156 rel_type: String,
157 #[serde(default)]
158 description: Option<String>,
159}
160
161pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
162 let payload = if let Some(ref file) = args.from {
163 let bytes = std::fs::read(file).map_err(|e| {
164 CliError::new(
165 ExitKind::Generic,
166 "INVALID_INPUT",
167 format!("failed to read {}: {e}", file.display()),
168 )
169 })?;
170 let mut parsed: CreatePayload = serde_json::from_slice(&bytes).map_err(|e| {
171 CliError::new(
172 ExitKind::Validation,
173 "INVALID_INPUT",
174 format!("invalid JSON in {}: {e}", file.display()),
175 )
176 .with_details(serde_json::json!({
177 "path": file.display().to_string(),
178 "parser_error": e.to_string(),
179 }))
180 })?;
181 // `--dry-run` and the file's `dry_run` OR — same semantics as
182 // `update --from`; the flag can force a preview, never disable one.
183 parsed.dry_run |= args.dry_run;
184 parsed
185 } else {
186 let title = args.title.clone().ok_or_else(|| {
187 CliError::new(
188 ExitKind::Validation,
189 "INVALID_INPUT",
190 "missing --title (or pass --from <file.json>)",
191 )
192 })?;
193 let entity_type = args.entity_type.clone().ok_or_else(|| {
194 CliError::new(
195 ExitKind::Validation,
196 "INVALID_INPUT",
197 "missing --type (or pass --from <file.json>)",
198 )
199 })?;
200 CreatePayload {
201 title,
202 entity_type,
203 mem: args.mem.clone(),
204 sections: parse_kv_list(&args.sections, "--section")?,
205 metadata: parse_kv_list(&args.metadata, "--metadata")?,
206 relations: parse_relation_list(&args.relations)?,
207 anchors: parse_anchor_list(&args.anchors)?,
208 note: None,
209 dry_run: args.dry_run,
210 id: None,
211 expected_hash: None,
212 }
213 };
214
215 // `dry_run` is settled at payload level (file OR flag) — read it
216 // from here on, never from `args`, so the `--from` file's value
217 // is honoured on every branch below.
218 let dry_run = payload.dry_run;
219
220 // Template-symmetry `id` consistency check: create derives its id
221 // from the title, so a template `id` must agree with the derived
222 // slug — catching template drift instead of silently creating a
223 // second entity beside the intended one. `expected_hash` is
224 // tolerated and ignored (nothing exists yet to compare against).
225 if let Some(template_id) = payload.id.as_deref() {
226 let derived = memstead_base::entity::id::validate_and_derive_slug(&payload.title)
227 .map_err(|e| CliError::new(ExitKind::Validation, "INVALID_TITLE", e.to_string()))?;
228 let derived_slug = derived.slug;
229 let slug_part = template_id
230 .rsplit_once("--")
231 .map(|(_, s)| s)
232 .unwrap_or(template_id);
233 if slug_part != derived_slug {
234 return Err(CliError::new(
235 ExitKind::Validation,
236 "INVALID_INPUT",
237 format!(
238 "template `id` {template_id:?} does not match the id derived from the \
239 title (slug {derived_slug:?}) — create derives identity from the title; \
240 fix the template's id or title"
241 ),
242 )
243 .into());
244 }
245 }
246
247 // `--note` (CLI flag) wins over a `note` carried in the `--from`
248 // payload when both are present; otherwise the file's note is used.
249 let note = args.note.clone().or_else(|| payload.note.clone());
250
251 match ctx.cli_engine()? {
252 #[cfg(feature = "mem-repo")]
253 CliEngine::MemRepo(mut engine) => {
254 let mem = match payload.mem {
255 Some(v) => v,
256 None => first_writable_mem(&engine)?,
257 };
258
259 let create_args = CreateEntityArgs {
260 anchors: payload.anchors,
261 title: payload.title,
262 mem,
263 entity_type: payload.entity_type,
264 sections: payload.sections,
265 metadata: payload.metadata,
266 relations: payload
267 .relations
268 .into_iter()
269 .map(|r| RelateArg {
270 to: EntityId::canonical(&r.to),
271 rel_type: r.rel_type,
272 description: r.description,
273 })
274 .collect(),
275 dry_run,
276 };
277
278 let result = engine
279 .create_entity_with_ctx(create_args, &crate::setup::cli_ctx_with_note(note.clone()))
280 .map_err(CliError::from_engine_op)?;
281 let mem_changed = engine.take_mem_changed_notices();
282
283 if ctx.json {
284 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
285 super::merge_mem_changed_json(&mut body, &mem_changed);
286 print_json(&body)?;
287 } else {
288 let warnings = if result.warnings.is_empty() {
289 String::new()
290 } else {
291 let rendered: Vec<String> =
292 result.warnings.iter().map(ToString::to_string).collect();
293 let warnings_block =
294 format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "),);
295 let guidance_block = super::render_type_guidance_block(&result.type_guidance);
296 format!("{warnings_block}{guidance_block}")
297 };
298 let incoming_block = if result.incoming.is_empty() {
299 String::new()
300 } else {
301 let heading = if dry_run {
302 format!("Would adopt incoming edges ({})", result.incoming.len())
303 } else {
304 format!("Adopted incoming edges ({})", result.incoming.len())
305 };
306 let rows: Vec<String> = result
307 .incoming
308 .iter()
309 .map(|r| {
310 format!("- {} --[{}]--> (this) [{}]", r.from, r.rel_type, r.source)
311 })
312 .collect();
313 format!("\n\n## {}\n\n{}", heading, rows.join("\n"))
314 };
315 let title_heading = if dry_run {
316 format!("Dry run — would create `{}`", result.id)
317 } else {
318 format!("Created `{}`", result.id)
319 };
320 let mem_changed_block = super::render_mem_changed_block(&mem_changed);
321 print_markdown(&format!(
322 "# {}\n\n- Title: {}\n- Mem: {}\n- File: {}\n- Hash: `{}`{}{}{}",
323 title_heading,
324 result.title,
325 result.mem,
326 result.file_path,
327 result.content_hash,
328 warnings,
329 incoming_block,
330 mem_changed_block,
331 ));
332 }
333 }
334 CliEngine::Filesystem(mut engine) => {
335 // Filesystem-mem `memstead create` accepts `--mem` for shape
336 // parity (matches mem-repo CLI), but the engine is single-
337 // mem; an explicit `--mem` mismatch with the workspace's
338 // pinned mem errors out so the user sees the misconfig
339 // rather than a silent no-op.
340 let workspace_mem = engine
341 .mem_names()
342 .into_iter()
343 .next()
344 .map(String::from)
345 .unwrap_or_default();
346 if let Some(requested) = payload.mem.as_deref()
347 && requested != workspace_mem
348 {
349 return Err(CliError::new(
350 ExitKind::NotFound,
351 "UNKNOWN_MEM",
352 format!(
353 "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, request specified `{requested}`"
354 ),
355 )
356 .into());
357 }
358 // `--relation` IS honoured here: `Engine::create_entity`
359 // and `create_entity_with_ctx` share one `prepare_create`,
360 // which validates and materialises inline relations
361 // regardless of which backend serves the mount — the MCP
362 // surface has been creating entities with their edges on
363 // this shape all along. The guard that used to sit here was
364 // CLI-local, and refusing what the sibling surface performs
365 // reads as an engine limit that does not exist.
366 //
367 // `--dry-run` is untouched by that lift and still refuses:
368 // this branch has never exercised the preview path, and a
369 // rehearsal that quietly landed a real write is the one
370 // failure mode worth keeping a refusal for.
371 if dry_run {
372 return Err(CliError::new(
373 ExitKind::Validation,
374 "INVALID_INPUT",
375 "--dry-run is not yet supported on filesystem-mem `memstead create`",
376 )
377 .into());
378 }
379
380 let create_args = CreateEntityArgs {
381 anchors: payload.anchors,
382 mem: workspace_mem,
383 title: payload.title.clone(),
384 entity_type: payload.entity_type,
385 sections: payload.sections,
386 metadata: payload.metadata,
387 relations: payload
388 .relations
389 .into_iter()
390 .map(|r| RelateArg {
391 to: EntityId::canonical(&r.to),
392 rel_type: r.rel_type,
393 description: r.description,
394 })
395 .collect(),
396 dry_run: false,
397 };
398 let outcome = engine
399 .create_entity(
400 create_args,
401 Actor::Cli,
402 Some(&crate::setup::cli_client_id()),
403 note.as_deref(),
404 )
405 .map_err(CliError::from_engine_op)?;
406
407 if ctx.json {
408 // WarningHint's Serialize impl produces the
409 // `{code, message, details}` envelope that full
410 // already used, so the wire shape is unchanged.
411 print_json(&serde_json::json!({
412 "id": outcome.id.as_ref(),
413 "title": payload.title,
414 "file_path": outcome.file_path,
415 "_hash": outcome.content_hash,
416 "warnings": outcome.warnings,
417 "type_guidance": outcome.type_guidance,
418 }))?;
419 } else {
420 let warnings = if outcome.warnings.is_empty() {
421 String::new()
422 } else {
423 // WarningHint's Display impl renders human-
424 // readable text per variant.
425 let rendered: Vec<String> =
426 outcome.warnings.iter().map(|w| w.to_string()).collect();
427 let warnings_block =
428 format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "),);
429 let guidance_block = super::render_type_guidance_block(&outcome.type_guidance);
430 format!("{warnings_block}{guidance_block}")
431 };
432 print_markdown(&format!(
433 "# Created `{}`\n\n- Title: {}\n- Mem: {}\n- File: {}\n- Hash: `{}`{}",
434 outcome.id,
435 payload.title,
436 outcome.id.mem(),
437 outcome.file_path,
438 outcome.content_hash,
439 warnings,
440 ));
441 }
442 }
443 }
444 Ok(())
445}
446
447fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
448 let mut out = IndexMap::with_capacity(items.len());
449 for raw in items {
450 let (k, v) = raw.split_once('=').ok_or_else(|| {
451 CliError::new(
452 ExitKind::Validation,
453 "INVALID_INPUT",
454 format!("{flag}: expected KEY=VALUE, got `{raw}`"),
455 )
456 })?;
457 out.insert(k.to_string(), v.to_string());
458 }
459 Ok(out)
460}
461
462/// Parse repeated `--anchor '<json>'` flag values into engine
463/// `AnchorInput`s. Each value is a JSON object of the anchor shape; a
464/// syntactically-broken JSON is a CLI input error, while a well-formed but
465/// semantically-invalid anchor (unknown class/grain, etc.) flows through
466/// to the engine's typed `INVALID_ANCHOR` refusal at mutation time.
467pub(crate) fn parse_anchor_list(
468 items: &[String],
469) -> anyhow::Result<Vec<memstead_base::anchor::AnchorInput>> {
470 let mut out = Vec::with_capacity(items.len());
471 for raw in items {
472 let anchor: memstead_base::anchor::AnchorInput =
473 serde_json::from_str(raw).map_err(|e| {
474 CliError::new(
475 ExitKind::Validation,
476 "INVALID_INPUT",
477 format!("--anchor: expected a JSON anchor object, got `{raw}`: {e}"),
478 )
479 })?;
480 out.push(anchor);
481 }
482 Ok(out)
483}
484
485fn parse_relation_list(items: &[String]) -> anyhow::Result<Vec<RelationPayload>> {
486 let mut out = Vec::with_capacity(items.len());
487 for raw in items {
488 let (rel_type, to) = raw.split_once(':').ok_or_else(|| {
489 CliError::new(
490 ExitKind::Validation,
491 "INVALID_INPUT",
492 format!("--relation: expected TYPE:target-id, got `{raw}`"),
493 )
494 })?;
495 out.push(RelationPayload {
496 rel_type: rel_type.to_string(),
497 to: to.to_string(),
498 description: None,
499 });
500 }
501 Ok(out)
502}
503
504#[cfg(feature = "mem-repo")]
505fn first_writable_mem(engine: &memstead_base::Engine) -> anyhow::Result<String> {
506 // Resolve through the shared stable-default contract so the CLI and
507 // MCP omitted-`mem` paths always agree: the first writable mount in
508 // declaration order — the seed mem — not an alphabetically-first or
509 // set-order pick that shifts when an unrelated mem is added.
510 match engine.default_writable_mem() {
511 Some(name) => Ok(name.to_string()),
512 None => Err(CliError::new(
513 ExitKind::Generic,
514 "NO_WRITABLE_MEM",
515 "no writable mem loaded — pass --mem <name>",
516 )
517 .into()),
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 /// The `--from` payload accepts a top-level `note`, matching the MCP
526 /// `memstead_create` shape the help text claims parity with. A payload
527 /// without `note` still deserialises (the field is optional).
528 #[test]
529 fn create_payload_accepts_optional_note() {
530 let with_note: CreatePayload =
531 serde_json::from_str(r#"{"title":"X","entity_type":"spec","note":"why this landed"}"#)
532 .expect("payload with note must parse");
533 assert_eq!(with_note.note.as_deref(), Some("why this landed"));
534
535 let without: CreatePayload = serde_json::from_str(r#"{"title":"X","entity_type":"spec"}"#)
536 .expect("note-less payload must still parse");
537 assert!(without.note.is_none());
538 }
539
540 /// `--note` (CLI flag) takes precedence over a `note` in the file;
541 /// the file's note is used only when the flag is absent.
542 #[test]
543 fn cli_note_takes_precedence_over_file_note() {
544 let cli = Some("from-flag".to_string());
545 let file = Some("from-file".to_string());
546 assert_eq!(
547 cli.clone().or_else(|| file.clone()).as_deref(),
548 Some("from-flag")
549 );
550 assert_eq!(None.or_else(|| file.clone()).as_deref(), Some("from-file"));
551 }
552}