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