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, 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 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
166fn 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}