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