memstead_cli/commands/
batch_create.rs1use 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 #[arg(long = "from", value_name = "FILE")]
47 pub from: PathBuf,
48 #[arg(long = "dry-run")]
53 pub dry_run: bool,
54}
55
56#[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 #[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 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 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
162fn 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}