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