Skip to main content

memstead_cli/commands/
batch_create.rs

1//! `memstead batch-create --from <file.json>` — create many entities in
2//! one call: one workspace load, one commit per touched mem,
3//! all-or-nothing with report-all refusals.
4//!
5//! Each entry is the same shape the single-entity `create --from`
6//! accepts (minus a per-entry `dry_run` — rehearsal is batch-level via
7//! `--dry-run`, which validates the whole batch and commits nothing),
8//! and carries its own provenance `note`. There is deliberately no
9//! batch-level note flag.
10//!
11//! Intra-batch references resolve as REAL targets: an entry's
12//! `relations` (and body wiki-links) may point at entities created by
13//! sibling entries in the same batch — cycles included where the
14//! schema permits them — with full target-type shape validation and no
15//! transient stubs.
16//!
17//! ```json
18//! { "creates": [
19//!     { "title": "Alpha", "entity_type": "spec",
20//!       "sections": { "identity": "..." },
21//!       "relations": [ { "to": "specs--beta", "type": "USES" } ],
22//!       "note": "why alpha exists" },
23//!     { "title": "Beta", "entity_type": "spec",
24//!       "sections": { "identity": "..." } }
25//! ] }
26//! ```
27
28use std::path::PathBuf;
29
30use clap::Parser;
31use indexmap::IndexMap;
32
33use serde::Deserialize;
34
35use memstead_base::EntityId;
36use memstead_base::ops::RelateArg;
37use memstead_base::{CreateEntityArgs, vcs::Actor};
38
39use crate::CliError;
40use crate::output::{ExitKind, print_json, print_markdown};
41use crate::setup::CliContext;
42
43#[derive(Parser, Debug)]
44pub struct Args {
45    /// JSON file with a top-level `creates: [...]` array.
46    #[arg(long = "from", value_name = "FILE")]
47    pub from: PathBuf,
48    /// Rehearse the whole batch: run the full validation pass
49    /// (intra-batch references resolve, identical refusals,
50    /// report-all) and report the would-be receipt, creating nothing.
51    /// `commit_sha` stays empty (the rehearsal marker).
52    #[arg(long = "dry-run")]
53    pub dry_run: bool,
54}
55
56/// Per-entry payload — the single `create --from` shape, per entry.
57/// `id` is tolerated for template symmetry and only *checked* against
58/// the title-derived slug (same contract as single create);
59/// `expected_hash` is tolerated and ignored (nothing exists yet to
60/// compare against). A per-entry `dry_run` key is NOT accepted —
61/// rehearsal is batch-level (`--dry-run` validates the whole batch as
62/// one graph state), so the key refuses as unknown rather than
63/// silently half-previewing.
64#[derive(Debug, Deserialize)]
65#[serde(deny_unknown_fields)]
66struct EntryPayload {
67    title: String,
68    entity_type: String,
69    #[serde(default)]
70    mem: Option<String>,
71    #[serde(default)]
72    sections: IndexMap<String, String>,
73    #[serde(default)]
74    metadata: IndexMap<String, String>,
75    #[serde(default)]
76    relations: Vec<RelationPayload>,
77    #[serde(default)]
78    anchors: Vec<memstead_base::anchor::AnchorInput>,
79    /// Agent-authored provenance note for THIS entry's commit record —
80    /// mirrors `batch-update`'s per-entry note handling exactly.
81    #[serde(default)]
82    note: Option<String>,
83    #[serde(default)]
84    id: Option<String>,
85    #[serde(default)]
86    #[allow(dead_code)]
87    expected_hash: Option<String>,
88}
89
90#[derive(Debug, Deserialize)]
91#[serde(deny_unknown_fields)]
92struct RelationPayload {
93    to: String,
94    #[serde(rename = "type")]
95    rel_type: String,
96    #[serde(default)]
97    description: Option<String>,
98}
99
100pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
101    let entry_values = super::batch::parse_batch_envelope(&args.from, "creates")?;
102
103    let mut entries: Vec<EntryPayload> = Vec::with_capacity(entry_values.len());
104    for (idx, entry_value) in entry_values.into_iter().enumerate() {
105        match serde_json::from_value::<EntryPayload>(entry_value) {
106            Ok(entry) => entries.push(entry),
107            Err(e) => {
108                return Err(CliError::new(
109                    ExitKind::Validation,
110                    "INVALID_INPUT",
111                    format!("entry {idx}: invalid shape — {e}"),
112                )
113                .with_details(serde_json::json!({
114                    "entry_index": idx,
115                    "parser_error": e.to_string(),
116                }))
117                .into());
118            }
119        }
120    }
121
122    let mut engine = crate::setup::full_engine(ctx)?;
123
124    let creates: Vec<(CreateEntityArgs, Option<String>)> = entries
125        .into_iter()
126        .enumerate()
127        .map(|(idx, entry)| build_create_args(&engine, idx, entry))
128        .collect::<anyhow::Result<Vec<_>>>()?;
129    let result = engine
130        .batch_create(
131            creates,
132            Actor::Cli,
133            Some(&crate::setup::cli_client_id()),
134            args.dry_run,
135        )
136        .map_err(CliError::from_engine_op)?;
137    // Reload-before-op runs inside `batch_create` for every mem the
138    // batch touches; drain any `mem_changed` notice it stashed.
139    let mem_changed = engine.take_mem_changed_notices();
140
141    if result.applied {
142        if ctx.json {
143            let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
144            crate::commands::merge_mem_changed_json(&mut body, &mem_changed);
145            print_json(&body)?;
146        } else {
147            let mut md = super::batch::render_batch_markdown("create", &result);
148            md.push_str(&crate::commands::render_mem_changed_block(&mem_changed));
149            print_markdown(&md);
150        }
151        return Ok(());
152    }
153
154    // Refused batch: standard error envelope with the full result on
155    // `details` — same contract as `batch-update` (CLI F12).
156    if !ctx.json {
157        print_markdown(&super::batch::render_batch_markdown("create", &result));
158    }
159    Err(super::batch::batch_refused_error("create", &result).into())
160}
161
162/// Map a single JSON entry to the engine's [`CreateEntityArgs`],
163/// resolving an omitted `mem` to the workspace's stable default
164/// (first writable mount) and checking a template-symmetry `id`
165/// against the title-derived slug — same contract as single create,
166/// with the entry index in the refusal.
167fn build_create_args(
168    engine: &memstead_base::Engine,
169    idx: usize,
170    entry: EntryPayload,
171) -> anyhow::Result<(CreateEntityArgs, Option<String>)> {
172    let mem = match entry.mem {
173        Some(v) => v,
174        None => match engine.default_writable_mem() {
175            Some(name) => name.to_string(),
176            None => {
177                return Err(CliError::new(
178                    ExitKind::Generic,
179                    "NO_WRITABLE_MEM",
180                    format!("entry {idx}: no writable mem loaded — set `mem` in the entry"),
181                )
182                .into());
183            }
184        },
185    };
186
187    if let Some(template_id) = entry.id.as_deref() {
188        let derived =
189            memstead_base::entity::id::validate_and_derive_slug(&entry.title).map_err(|e| {
190                CliError::new(
191                    ExitKind::Validation,
192                    "INVALID_TITLE",
193                    format!("entry {idx}: {e}"),
194                )
195            })?;
196        let derived_slug = derived.slug;
197        let slug_part = template_id
198            .rsplit_once("--")
199            .map(|(_, s)| s)
200            .unwrap_or(template_id);
201        if slug_part != derived_slug {
202            return Err(CliError::new(
203                ExitKind::Validation,
204                "INVALID_INPUT",
205                format!(
206                    "entry {idx}: template `id` {template_id:?} does not match the id derived \
207                     from the title (slug {derived_slug:?}) — create derives identity from the \
208                     title; fix the template's id or title"
209                ),
210            )
211            .into());
212        }
213    }
214
215    let note = entry.note;
216    Ok((
217        CreateEntityArgs {
218            anchors: entry.anchors,
219            mem,
220            title: entry.title,
221            entity_type: entry.entity_type,
222            sections: entry.sections,
223            metadata: entry.metadata,
224            relations: entry
225                .relations
226                .into_iter()
227                .map(|r| RelateArg {
228                    to: EntityId::canonical(&r.to),
229                    rel_type: r.rel_type,
230                    description: r.description,
231                })
232                .collect(),
233            dry_run: false,
234        },
235        note,
236    ))
237}