1use std::path::PathBuf;
24
25use clap::Parser;
26use indexmap::IndexMap;
27use serde::Deserialize;
28
29use memstead_base::EntityId;
30use memstead_base::ops::{PatchArg, RelateArg};
31use memstead_base::{UpdateEntityArgs, vcs::Actor};
32
33use crate::CliError;
34use crate::output::{ExitKind, print_json, print_markdown};
35use crate::setup::CliContext;
36
37#[derive(Parser, Debug)]
38pub struct Args {
39 #[arg(long = "from", value_name = "FILE")]
41 pub from: PathBuf,
42 #[arg(long = "dry-run")]
47 pub dry_run: bool,
48}
49
50const RECOGNISED_MUTATION_KEYS: &[&str] = &[
53 "sections",
54 "append_sections",
55 "patch_sections",
56 "metadata",
57 "metadata_unset",
58 "declare_relations",
59 "anchors",
60 "anchors_unset",
61];
62
63#[derive(Debug, Deserialize)]
64#[serde(deny_unknown_fields)]
65struct EntryPayload {
66 id: String,
67 #[serde(default)]
68 expected_hash: Option<String>,
69 #[serde(default)]
70 auto_hash: bool,
71 #[serde(default)]
72 force: bool,
73 #[serde(default)]
74 sections: IndexMap<String, String>,
75 #[serde(default)]
76 append_sections: IndexMap<String, String>,
77 #[serde(default)]
78 patch_sections: IndexMap<String, PatchPayload>,
79 #[serde(default)]
80 metadata: IndexMap<String, String>,
81 #[serde(default)]
82 metadata_unset: Vec<String>,
83 #[serde(default)]
89 declare_relations: Vec<RelationPayload>,
90 #[serde(default)]
94 anchors: Vec<memstead_base::anchor::AnchorInput>,
95 #[serde(default)]
99 anchors_unset: Vec<memstead_base::anchor::AnchorUnsetInput>,
100 #[serde(default)]
106 note: Option<String>,
107}
108
109#[derive(Debug, Deserialize)]
110#[serde(deny_unknown_fields)]
111struct PatchPayload {
112 old: String,
113 new: String,
114 #[serde(default)]
115 all: bool,
116}
117
118#[derive(Debug, Deserialize)]
119#[serde(deny_unknown_fields)]
120struct RelationPayload {
121 to: String,
123 #[serde(rename = "type")]
125 rel_type: String,
126 #[serde(default)]
127 description: Option<String>,
128}
129
130pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
131 let updates_array = super::batch::parse_batch_envelope(&args.from, "updates")?;
135
136 let mut entries: Vec<EntryPayload> = Vec::with_capacity(updates_array.len());
137 for (idx, entry_value) in updates_array.into_iter().enumerate() {
138 match serde_json::from_value::<EntryPayload>(entry_value.clone()) {
139 Ok(entry) => entries.push(entry),
140 Err(e) => return Err(build_entry_parse_error(idx, &entry_value, &e).into()),
141 }
142 }
143
144 let mut engine = crate::setup::full_engine(ctx)?;
145
146 let updates: Vec<(UpdateEntityArgs, Option<String>)> = entries
147 .into_iter()
148 .map(|entry| build_update_args(&engine, entry))
149 .collect::<anyhow::Result<Vec<_>>>()?;
150 let result = engine
151 .batch_update(
152 updates,
153 Actor::Cli,
154 Some(&crate::setup::cli_client_id()),
155 args.dry_run,
156 )
157 .map_err(CliError::from_engine_op)?;
158 let mem_changed = engine.take_mem_changed_notices();
161
162 if result.applied {
166 if ctx.json {
167 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
168 crate::commands::merge_mem_changed_json(&mut body, &mem_changed);
169 print_json(&body)?;
170 } else {
171 let mut md = super::batch::render_batch_markdown("update", &result);
172 md.push_str(&crate::commands::render_mem_changed_block(&mem_changed));
173 print_markdown(&md);
174 }
175 return Ok(());
176 }
177
178 if !ctx.json {
189 print_markdown(&super::batch::render_batch_markdown("update", &result));
190 }
191 Err(super::batch::batch_refused_error("update", &result).into())
192}
193
194fn build_update_args(
201 engine: &memstead_base::Engine,
202 entry: EntryPayload,
203) -> anyhow::Result<(UpdateEntityArgs, Option<String>)> {
204 let mode_count =
205 entry.expected_hash.is_some() as u8 + entry.auto_hash as u8 + entry.force as u8;
206 if mode_count == 0 {
207 return Err(CliError::new(
208 ExitKind::Validation,
209 "INVALID_INPUT",
210 format!(
211 "entry `{}`: exactly one of `expected_hash`, `auto_hash`, or `force` must be set",
212 entry.id
213 ),
214 )
215 .into());
216 }
217 if mode_count > 1 {
218 return Err(CliError::new(
219 ExitKind::Validation,
220 "INVALID_INPUT",
221 format!(
222 "entry `{}`: `expected_hash`, `auto_hash`, and `force` are mutually exclusive",
223 entry.id
224 ),
225 )
226 .into());
227 }
228
229 let note = entry.note.clone();
231 let id = EntityId::canonical(&entry.id);
232 let expected_hash = if entry.force {
233 None
234 } else if entry.auto_hash {
235 engine.get_entity(&id).map(|e| e.content_hash.clone())
240 } else {
241 entry.expected_hash
242 };
243
244 let patch_sections = entry
245 .patch_sections
246 .into_iter()
247 .map(|(k, v)| {
248 (
249 k,
250 PatchArg {
251 old: v.old,
252 new: v.new,
253 all: v.all,
254 },
255 )
256 })
257 .collect();
258
259 let declare_relations = entry
260 .declare_relations
261 .into_iter()
262 .map(|r| RelateArg {
263 rel_type: r.rel_type,
264 to: EntityId::canonical(&r.to),
265 description: r.description,
266 })
267 .collect();
268
269 Ok((
270 UpdateEntityArgs {
271 anchors: entry.anchors,
272 anchors_unset: entry.anchors_unset,
273 id,
274 expected_hash,
275 sections: entry.sections,
276 append_sections: entry.append_sections,
277 patch_sections,
278 metadata: entry.metadata,
279 metadata_unset: entry.metadata_unset,
280 declare_relations,
281 dry_run: false,
282 relations_unset: Vec::new(),
283 },
284 note,
285 ))
286}
287
288fn build_entry_parse_error(
295 idx: usize,
296 entry_value: &serde_json::Value,
297 parse_err: &serde_json::Error,
298) -> CliError {
299 let known: std::collections::BTreeSet<&str> = [
300 "id",
301 "expected_hash",
302 "auto_hash",
303 "force",
304 "sections",
305 "append_sections",
306 "patch_sections",
307 "metadata",
308 "metadata_unset",
309 "declare_relations",
310 "note",
311 ]
312 .into_iter()
313 .collect();
314 let mut unknown: Vec<String> = Vec::new();
315 if let Some(map) = entry_value.as_object() {
316 for k in map.keys() {
317 if !known.contains(k.as_str()) {
318 unknown.push(k.clone());
319 }
320 }
321 }
322 let suggested = unknown
327 .first()
328 .and_then(|u| nearest_recognised_key(u))
329 .map(String::from);
330 let message = if unknown.is_empty() {
331 format!("entry {idx}: invalid shape — {parse_err}")
332 } else {
333 let display = unknown.join(", ");
334 format!(
335 "entry {idx}: unknown field(s) {display} — recognised mutation keys are {:?}",
336 RECOGNISED_MUTATION_KEYS
337 )
338 };
339 let mut details = serde_json::json!({
340 "entry_index": idx,
341 "unknown_keys": unknown,
342 "parser_error": parse_err.to_string(),
343 "recognised_keys": RECOGNISED_MUTATION_KEYS,
344 });
345 if let Some(s) = suggested {
346 details["suggested"] = serde_json::Value::String(s);
347 }
348 CliError::new(ExitKind::Validation, "INVALID_INPUT", message).with_details(details)
349}
350
351fn nearest_recognised_key(attempted: &str) -> Option<&'static str> {
357 let lower = attempted.to_lowercase();
358 let mut best: Option<(&'static str, usize)> = None;
359 for &key in RECOGNISED_MUTATION_KEYS {
360 let score = if lower.starts_with(key) || key.starts_with(&lower) {
362 100
363 } else if lower.contains(key) || key.contains(&lower) {
364 80
365 } else {
366 shared_prefix_len(&lower, key) * 4
367 };
368 if score > 0 {
369 best = match best {
370 Some((_, best_score)) if best_score >= score => best,
371 _ => Some((key, score)),
372 };
373 }
374 }
375 best.map(|(k, _)| k)
376}
377
378fn shared_prefix_len(a: &str, b: &str) -> usize {
379 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
392 fn entry_parse_error_names_unknown_keys_and_suggests_nearest() {
393 let entry: serde_json::Value = serde_json::json!({
394 "id": "specs--target",
395 "auto_hash": true,
396 "section_replacements": {"identity": "X"},
397 });
398 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
399 let err = build_entry_parse_error(0, &entry, &parse_err);
400 let details = err.details.expect("details payload must be present");
401 assert_eq!(details["entry_index"].as_u64(), Some(0));
402 let unknown: Vec<String> = details["unknown_keys"]
403 .as_array()
404 .unwrap()
405 .iter()
406 .map(|v| v.as_str().unwrap().to_string())
407 .collect();
408 assert_eq!(unknown, vec!["section_replacements".to_string()]);
409 assert_eq!(details["suggested"].as_str(), Some("sections"));
410 assert_eq!(err.code, "INVALID_INPUT");
411 }
412
413 #[test]
415 fn entry_parse_error_suggests_metadata_for_meta_typo() {
416 let entry: serde_json::Value = serde_json::json!({
417 "id": "specs--target",
418 "auto_hash": true,
419 "meta": {"level": "M1"},
420 });
421 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
422 let err = build_entry_parse_error(7, &entry, &parse_err);
423 let details = err.details.expect("details payload");
424 assert_eq!(details["entry_index"].as_u64(), Some(7));
425 assert_eq!(details["suggested"].as_str(), Some("metadata"));
426 }
427
428 #[test]
433 fn entry_parse_accepts_every_documented_field() {
434 let entry: serde_json::Value = serde_json::json!({
435 "id": "specs--target",
436 "expected_hash": "abc",
437 "auto_hash": false,
438 "force": false,
439 "sections": {"identity": "A"},
440 "append_sections": {"purpose": "B"},
441 "patch_sections": {"identity": {"old": "X", "new": "Y", "all": true}},
442 "metadata": {"level": "M1"},
443 "metadata_unset": ["tags"],
444 "declare_relations": [{"to": "specs--other", "type": "USES"}],
445 "note": "per-entry provenance",
446 });
447 let parsed = serde_json::from_value::<EntryPayload>(entry).expect("must parse");
448 assert_eq!(parsed.id, "specs--target");
449 assert_eq!(parsed.sections.len(), 1);
450 assert_eq!(parsed.declare_relations.len(), 1);
451 assert_eq!(parsed.declare_relations[0].rel_type, "USES");
452 assert_eq!(parsed.note.as_deref(), Some("per-entry provenance"));
454 }
455}