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 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}