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, args.dry_run);
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(
158            "create",
159            &result,
160            args.dry_run,
161        ));
162    }
163    Err(super::batch::batch_refused_error("create", &result).into())
164}
165
166/// Map a single JSON entry to the engine's [`CreateEntityArgs`],
167/// resolving an omitted `mem` to the workspace's stable default
168/// (first writable mount) and checking a template-symmetry `id`
169/// against the title-derived slug — same contract as single create,
170/// with the entry index in the refusal.
171fn build_create_args(
172    engine: &memstead_base::Engine,
173    idx: usize,
174    entry: EntryPayload,
175) -> anyhow::Result<(CreateEntityArgs, Option<String>)> {
176    let mem = match entry.mem {
177        Some(v) => v,
178        None => match engine.default_writable_mem() {
179            Some(name) => name.to_string(),
180            None => {
181                return Err(CliError::new(
182                    ExitKind::Generic,
183                    "NO_WRITABLE_MEM",
184                    format!("entry {idx}: no writable mem loaded — set `mem` in the entry"),
185                )
186                .into());
187            }
188        },
189    };
190
191    if let Some(template_id) = entry.id.as_deref() {
192        let derived =
193            memstead_base::entity::id::validate_and_derive_slug(&entry.title).map_err(|e| {
194                CliError::new(
195                    ExitKind::Validation,
196                    "INVALID_TITLE",
197                    format!("entry {idx}: {e}"),
198                )
199            })?;
200        let derived_slug = derived.slug;
201        let slug_part = template_id
202            .rsplit_once("--")
203            .map(|(_, s)| s)
204            .unwrap_or(template_id);
205        if slug_part != derived_slug {
206            return Err(CliError::new(
207                ExitKind::Validation,
208                "INVALID_INPUT",
209                format!(
210                    "entry {idx}: template `id` {template_id:?} does not match the id derived \
211                     from the title (slug {derived_slug:?}) — create derives identity from the \
212                     title; fix the template's id or title"
213                ),
214            )
215            .into());
216        }
217    }
218
219    let note = entry.note;
220    Ok((
221        CreateEntityArgs {
222            anchors: entry.anchors,
223            mem,
224            title: entry.title,
225            entity_type: entry.entity_type,
226            sections: entry.sections,
227            metadata: entry.metadata,
228            relations: entry
229                .relations
230                .into_iter()
231                .map(|r| RelateArg {
232                    to: EntityId::canonical(&r.to),
233                    rel_type: r.rel_type,
234                    description: r.description,
235                })
236                .collect(),
237            dry_run: false,
238        },
239        note,
240    ))
241}