Skip to main content

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}