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