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    /// The JSON type field is `entity_type` (not `type`), matching the
73    /// response envelopes — a previous `--json` response pipes back in
74    /// unchanged.
75    #[arg(long = "from", value_name = "FILE")]
76    pub from: Option<PathBuf>,
77
78    /// Preview only — validate and compute the result without writing to
79    /// disk, mutating the store, or producing a commit. Response carries
80    /// the prospective id / file_path / content_hash plus any warnings.
81    #[arg(long = "dry-run")]
82    pub dry_run: bool,
83
84    /// Agent-authored provenance note (≤280 chars, one sentence
85    /// describing why this mutation happened). Lands in the per-mem
86    /// commit body between the mechanical subject line and the
87    /// provenance trailers. When `[mutations].require_notes = true` in
88    /// workspace config a missing note adds a `NOTE_MISSING` warning
89    /// to the response (the mutation still commits). When `--from` also
90    /// carries a `note`, this flag takes precedence.
91    #[arg(long)]
92    pub note: Option<String>,
93}
94
95/// On-disk JSON payload shape — mirrors MCP `CreateParams` exactly.
96///
97/// The type field is `entity_type`, matching the response envelopes
98/// every read/write surface emits, so an agent can pipe a previous
99/// `memstead create --json` response back through `--from` for a
100/// follow-up create without renaming the field.
101#[derive(Debug, Deserialize)]
102#[serde(deny_unknown_fields)]
103struct CreatePayload {
104    title: String,
105    entity_type: String,
106    mem: Option<String>,
107    #[serde(default)]
108    sections: IndexMap<String, String>,
109    #[serde(default)]
110    metadata: IndexMap<String, String>,
111    #[serde(default)]
112    relations: Vec<RelationPayload>,
113    /// Provenance anchors — matches the MCP `memstead_create` `anchors[]`
114    /// shape. Each element is validated engine-side into a typed
115    /// `INVALID_ANCHOR` refusal on malformed input.
116    #[serde(default)]
117    anchors: Vec<memstead_base::anchor::AnchorInput>,
118    /// Agent-authored provenance note — matches the MCP `memstead_create`
119    /// shape's `note`. Optional; the command-line `--note` takes
120    /// precedence when both are supplied.
121    #[serde(default)]
122    note: Option<String>,
123}
124
125#[derive(Debug, Deserialize)]
126#[serde(deny_unknown_fields)]
127#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
128struct RelationPayload {
129    to: String,
130    #[serde(rename = "type")]
131    rel_type: String,
132    #[serde(default)]
133    description: Option<String>,
134}
135
136pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
137    let payload = if let Some(ref file) = args.from {
138        let bytes = std::fs::read(file).map_err(|e| {
139            CliError::new(
140                ExitKind::Generic,
141                "INVALID_INPUT",
142                format!("failed to read {}: {e}", file.display()),
143            )
144        })?;
145        let parsed: CreatePayload = serde_json::from_slice(&bytes).map_err(|e| {
146            CliError::new(
147                ExitKind::Validation,
148                "INVALID_INPUT",
149                format!("invalid JSON in {}: {e}", file.display()),
150            )
151            .with_details(serde_json::json!({
152                "path": file.display().to_string(),
153                "parser_error": e.to_string(),
154            }))
155        })?;
156        parsed
157    } else {
158        let title = args.title.clone().ok_or_else(|| {
159            CliError::new(
160                ExitKind::Validation,
161                "INVALID_INPUT",
162                "missing --title (or pass --from <file.json>)",
163            )
164        })?;
165        let entity_type = args.entity_type.clone().ok_or_else(|| {
166            CliError::new(
167                ExitKind::Validation,
168                "INVALID_INPUT",
169                "missing --type (or pass --from <file.json>)",
170            )
171        })?;
172        CreatePayload {
173            title,
174            entity_type,
175            mem: args.mem.clone(),
176            sections: parse_kv_list(&args.sections, "--section")?,
177            metadata: parse_kv_list(&args.metadata, "--metadata")?,
178            relations: parse_relation_list(&args.relations)?,
179            anchors: parse_anchor_list(&args.anchors)?,
180            note: None,
181        }
182    };
183
184    // `--note` (CLI flag) wins over a `note` carried in the `--from`
185    // payload when both are present; otherwise the file's note is used.
186    let note = args.note.clone().or_else(|| payload.note.clone());
187
188    match ctx.cli_engine()? {
189        #[cfg(feature = "mem-repo")]
190        CliEngine::MemRepo(mut engine) => {
191            let mem = match payload.mem {
192                Some(v) => v,
193                None => first_writable_mem(&engine)?,
194            };
195
196            let create_args = CreateEntityArgs {
197                anchors: payload.anchors,
198                title: payload.title,
199                mem,
200                entity_type: payload.entity_type,
201                sections: payload.sections,
202                metadata: payload.metadata,
203                relations: payload
204                    .relations
205                    .into_iter()
206                    .map(|r| RelateArg {
207                        to: EntityId::canonical(&r.to),
208                        rel_type: r.rel_type,
209                        description: r.description,
210                    })
211                    .collect(),
212                dry_run: args.dry_run,
213            };
214
215            let result = engine
216                .create_entity_with_ctx(create_args, &crate::setup::cli_ctx_with_note(note.clone()))
217                .map_err(CliError::from_engine_op)?;
218            let mem_changed = engine.take_mem_changed_notices();
219
220            if ctx.json {
221                let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
222                super::merge_mem_changed_json(&mut body, &mem_changed);
223                print_json(&body)?;
224            } else {
225                let warnings = if result.warnings.is_empty() {
226                    String::new()
227                } else {
228                    let rendered: Vec<String> =
229                        result.warnings.iter().map(ToString::to_string).collect();
230                    let warnings_block =
231                        format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "),);
232                    let guidance_block = super::render_type_guidance_block(&result.type_guidance);
233                    format!("{warnings_block}{guidance_block}")
234                };
235                let incoming_block = if result.incoming.is_empty() {
236                    String::new()
237                } else {
238                    let heading = if args.dry_run {
239                        format!("Would adopt incoming edges ({})", result.incoming.len())
240                    } else {
241                        format!("Adopted incoming edges ({})", result.incoming.len())
242                    };
243                    let rows: Vec<String> = result
244                        .incoming
245                        .iter()
246                        .map(|r| {
247                            format!("- {} --[{}]--> (this) [{}]", r.from, r.rel_type, r.source)
248                        })
249                        .collect();
250                    format!("\n\n## {}\n\n{}", heading, rows.join("\n"))
251                };
252                let title_heading = if args.dry_run {
253                    format!("Dry run — would create `{}`", result.id)
254                } else {
255                    format!("Created `{}`", result.id)
256                };
257                let mem_changed_block = super::render_mem_changed_block(&mem_changed);
258                print_markdown(&format!(
259                    "# {}\n\n- Title: {}\n- Mem: {}\n- File: {}\n- Hash: `{}`{}{}{}",
260                    title_heading,
261                    result.title,
262                    result.mem,
263                    result.file_path,
264                    result.content_hash,
265                    warnings,
266                    incoming_block,
267                    mem_changed_block,
268                ));
269            }
270        }
271        CliEngine::Filesystem(mut engine) => {
272            // Filesystem-mem `memstead create` accepts `--mem` for shape
273            // parity (matches mem-repo CLI), but the engine is single-
274            // mem; an explicit `--mem` mismatch with the workspace's
275            // pinned mem errors out so the user sees the misconfig
276            // rather than a silent no-op.
277            let workspace_mem = engine
278                .mem_names()
279                .into_iter()
280                .next()
281                .map(String::from)
282                .unwrap_or_default();
283            if let Some(requested) = payload.mem.as_deref()
284                && requested != workspace_mem
285            {
286                return Err(CliError::new(
287                        ExitKind::NotFound,
288                        "UNKNOWN_MEM",
289                        format!(
290                            "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, request specified `{requested}`"
291                        ),
292                    )
293                    .into());
294            }
295            // `--relation` and `--dry-run` are not yet honoured on the
296            // filesystem path — the unified `Engine::create_entity`
297            // surface accepts neither. Surface that as a clear
298            // validation error rather than silently dropping the flags.
299            if !payload.relations.is_empty() {
300                return Err(CliError::new(
301                    ExitKind::Validation,
302                    "INVALID_INPUT",
303                    "--relation is not yet supported on filesystem-mem `memstead create` — use `memstead relate` after creation",
304                )
305                .into());
306            }
307            if args.dry_run {
308                return Err(CliError::new(
309                    ExitKind::Validation,
310                    "INVALID_INPUT",
311                    "--dry-run is not yet supported on filesystem-mem `memstead create`",
312                )
313                .into());
314            }
315
316            let create_args = CreateEntityArgs {
317                anchors: payload.anchors,
318                mem: workspace_mem,
319                title: payload.title.clone(),
320                entity_type: payload.entity_type,
321                sections: payload.sections,
322                metadata: payload.metadata,
323                relations: Vec::new(),
324                dry_run: false,
325            };
326            let outcome = engine
327                .create_entity(create_args, Actor::Cli, None, note.as_deref())
328                .map_err(CliError::from_engine_op)?;
329
330            if ctx.json {
331                // WarningHint's Serialize impl produces the
332                // `{code, message, details}` envelope that full
333                // already used, so the wire shape is unchanged.
334                print_json(&serde_json::json!({
335                    "id": outcome.id.as_ref(),
336                    "title": payload.title,
337                    "file_path": outcome.file_path,
338                    "_hash": outcome.content_hash,
339                    "warnings": outcome.warnings,
340                    "type_guidance": outcome.type_guidance,
341                }))?;
342            } else {
343                let warnings = if outcome.warnings.is_empty() {
344                    String::new()
345                } else {
346                    // WarningHint's Display impl renders human-
347                    // readable text per variant.
348                    let rendered: Vec<String> =
349                        outcome.warnings.iter().map(|w| w.to_string()).collect();
350                    let warnings_block =
351                        format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "),);
352                    let guidance_block = super::render_type_guidance_block(&outcome.type_guidance);
353                    format!("{warnings_block}{guidance_block}")
354                };
355                print_markdown(&format!(
356                    "# Created `{}`\n\n- Title: {}\n- Mem: {}\n- File: {}\n- Hash: `{}`{}",
357                    outcome.id,
358                    payload.title,
359                    outcome.id.mem(),
360                    outcome.file_path,
361                    outcome.content_hash,
362                    warnings,
363                ));
364            }
365        }
366    }
367    Ok(())
368}
369
370fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
371    let mut out = IndexMap::with_capacity(items.len());
372    for raw in items {
373        let (k, v) = raw.split_once('=').ok_or_else(|| {
374            CliError::new(
375                ExitKind::Validation,
376                "INVALID_INPUT",
377                format!("{flag}: expected KEY=VALUE, got `{raw}`"),
378            )
379        })?;
380        out.insert(k.to_string(), v.to_string());
381    }
382    Ok(out)
383}
384
385/// Parse repeated `--anchor '<json>'` flag values into engine
386/// `AnchorInput`s. Each value is a JSON object of the anchor shape; a
387/// syntactically-broken JSON is a CLI input error, while a well-formed but
388/// semantically-invalid anchor (unknown class/grain, etc.) flows through
389/// to the engine's typed `INVALID_ANCHOR` refusal at mutation time.
390pub(crate) fn parse_anchor_list(
391    items: &[String],
392) -> anyhow::Result<Vec<memstead_base::anchor::AnchorInput>> {
393    let mut out = Vec::with_capacity(items.len());
394    for raw in items {
395        let anchor: memstead_base::anchor::AnchorInput =
396            serde_json::from_str(raw).map_err(|e| {
397                CliError::new(
398                    ExitKind::Validation,
399                    "INVALID_INPUT",
400                    format!("--anchor: expected a JSON anchor object, got `{raw}`: {e}"),
401                )
402            })?;
403        out.push(anchor);
404    }
405    Ok(out)
406}
407
408fn parse_relation_list(items: &[String]) -> anyhow::Result<Vec<RelationPayload>> {
409    let mut out = Vec::with_capacity(items.len());
410    for raw in items {
411        let (rel_type, to) = raw.split_once(':').ok_or_else(|| {
412            CliError::new(
413                ExitKind::Validation,
414                "INVALID_INPUT",
415                format!("--relation: expected TYPE:target-id, got `{raw}`"),
416            )
417        })?;
418        out.push(RelationPayload {
419            rel_type: rel_type.to_string(),
420            to: to.to_string(),
421            description: None,
422        });
423    }
424    Ok(out)
425}
426
427#[cfg(feature = "mem-repo")]
428fn first_writable_mem(engine: &memstead_base::Engine) -> anyhow::Result<String> {
429    // Resolve through the shared stable-default contract so the CLI and
430    // MCP omitted-`mem` paths always agree: the first writable mount in
431    // declaration order — the seed mem — not an alphabetically-first or
432    // set-order pick that shifts when an unrelated mem is added.
433    match engine.default_writable_mem() {
434        Some(name) => Ok(name.to_string()),
435        None => Err(CliError::new(
436            ExitKind::Generic,
437            "NO_WRITABLE_MEM",
438            "no writable mem loaded — pass --mem <name>",
439        )
440        .into()),
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    /// The `--from` payload accepts a top-level `note`, matching the MCP
449    /// `memstead_create` shape the help text claims parity with. A payload
450    /// without `note` still deserialises (the field is optional).
451    #[test]
452    fn create_payload_accepts_optional_note() {
453        let with_note: CreatePayload =
454            serde_json::from_str(r#"{"title":"X","entity_type":"spec","note":"why this landed"}"#)
455                .expect("payload with note must parse");
456        assert_eq!(with_note.note.as_deref(), Some("why this landed"));
457
458        let without: CreatePayload = serde_json::from_str(r#"{"title":"X","entity_type":"spec"}"#)
459            .expect("note-less payload must still parse");
460        assert!(without.note.is_none());
461    }
462
463    /// `--note` (CLI flag) takes precedence over a `note` in the file;
464    /// the file's note is used only when the flag is absent.
465    #[test]
466    fn cli_note_takes_precedence_over_file_note() {
467        let cli = Some("from-flag".to_string());
468        let file = Some("from-file".to_string());
469        assert_eq!(
470            cli.clone().or_else(|| file.clone()).as_deref(),
471            Some("from-flag")
472        );
473        assert_eq!(None.or_else(|| file.clone()).as_deref(), Some("from-file"));
474    }
475}