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] = &[
58 "sections",
59 "append_sections",
60 "patch_sections",
61 "metadata",
62 "metadata_unset",
63 "declare_relations",
64 "anchors",
65 "anchors_unset",
66];
67
68#[derive(Debug, Deserialize)]
69#[serde(deny_unknown_fields)]
70struct EntryPayload {
71 id: String,
72 #[serde(default)]
73 expected_hash: Option<String>,
74 #[serde(default)]
75 auto_hash: bool,
76 #[serde(default)]
77 force: bool,
78 #[serde(default)]
79 sections: IndexMap<String, String>,
80 #[serde(default)]
81 append_sections: IndexMap<String, String>,
82 #[serde(default)]
83 patch_sections: IndexMap<String, PatchesPayload>,
84 #[serde(default)]
85 sections_unset: Vec<String>,
86 #[serde(default)]
93 mem: Option<String>,
94 #[serde(default)]
95 metadata: IndexMap<String, String>,
96 #[serde(default)]
97 metadata_unset: Vec<String>,
98 #[serde(default)]
104 declare_relations: Vec<RelationPayload>,
105 #[serde(default)]
109 anchors: Vec<memstead_base::anchor::AnchorInput>,
110 #[serde(default)]
114 anchors_unset: Vec<memstead_base::anchor::AnchorUnsetInput>,
115 #[serde(default)]
121 note: Option<String>,
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(deny_unknown_fields)]
126struct PatchPayload {
127 old: String,
128 new: String,
129 #[serde(default)]
130 all: bool,
131}
132
133#[derive(Debug, Deserialize)]
136#[serde(untagged)]
137enum PatchesPayload {
138 One(PatchPayload),
139 Many(Vec<PatchPayload>),
140}
141
142impl PatchesPayload {
143 fn into_vec(self) -> Vec<PatchPayload> {
144 match self {
145 PatchesPayload::One(p) => vec![p],
146 PatchesPayload::Many(v) => v,
147 }
148 }
149}
150
151#[derive(Debug, Deserialize)]
152#[serde(deny_unknown_fields)]
153struct RelationPayload {
154 target: String,
156 rel_type: String,
158 #[serde(default)]
159 description: Option<String>,
160}
161
162pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
163 let updates_array = super::batch::parse_batch_envelope(&args.from, "updates")?;
167
168 let mut entries: Vec<EntryPayload> = Vec::with_capacity(updates_array.len());
169 for (idx, entry_value) in updates_array.into_iter().enumerate() {
170 match serde_json::from_value::<EntryPayload>(entry_value.clone()) {
171 Ok(entry) => entries.push(entry),
172 Err(e) => return Err(build_entry_parse_error(idx, &entry_value, &e).into()),
173 }
174 }
175
176 let mut engine = crate::setup::full_engine(ctx)?;
177
178 let updates: Vec<(UpdateEntityArgs, Option<String>)> = entries
179 .into_iter()
180 .map(|entry| build_update_args(&engine, entry))
181 .collect::<anyhow::Result<Vec<_>>>()?;
182 let result = engine
183 .batch_update(
184 updates,
185 Actor::Cli,
186 Some(&crate::setup::cli_client_id()),
187 args.dry_run,
188 )
189 .map_err(CliError::from_engine_op)?;
190 let mem_changed = engine.take_mem_changed_notices();
193
194 if result.applied {
198 if ctx.json {
199 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
200 crate::commands::merge_mem_changed_json(&mut body, &mem_changed);
201 print_json(&body)?;
202 } else {
203 let mut md = super::batch::render_batch_markdown("update", &result, args.dry_run);
204 md.push_str(&crate::commands::render_mem_changed_block(&mem_changed));
205 print_markdown(&md);
206 }
207 return Ok(());
208 }
209
210 if !ctx.json {
221 print_markdown(&super::batch::render_batch_markdown(
222 "update",
223 &result,
224 args.dry_run,
225 ));
226 }
227 Err(super::batch::batch_refused_error("update", &result).into())
228}
229
230fn build_update_args(
237 engine: &memstead_base::Engine,
238 entry: EntryPayload,
239) -> anyhow::Result<(UpdateEntityArgs, Option<String>)> {
240 let mode_count =
241 entry.expected_hash.is_some() as u8 + entry.auto_hash as u8 + entry.force as u8;
242 let changes_content = !entry.sections.is_empty()
252 || !entry.append_sections.is_empty()
253 || !entry.patch_sections.is_empty()
254 || !entry.metadata.is_empty()
255 || !entry.metadata_unset.is_empty()
256 || !entry.declare_relations.is_empty();
257 let anchors_only =
258 (!entry.anchors.is_empty() || !entry.anchors_unset.is_empty()) && !changes_content;
259 if mode_count == 0 && !anchors_only {
260 return Err(CliError::new(
261 ExitKind::Validation,
262 "INVALID_INPUT",
263 format!(
264 "entry `{}`: exactly one of `expected_hash`, `auto_hash`, or `force` must be set",
265 entry.id
266 ),
267 )
268 .into());
269 }
270 if mode_count > 1 {
271 return Err(CliError::new(
272 ExitKind::Validation,
273 "INVALID_INPUT",
274 format!(
275 "entry `{}`: `expected_hash`, `auto_hash`, and `force` are mutually exclusive",
276 entry.id
277 ),
278 )
279 .into());
280 }
281
282 let note = entry.note.clone();
284 let id = EntityId::canonical(&entry.id);
285 if let Some(m) = entry.mem.as_deref()
288 && m != id.mem()
289 {
290 return Err(CliError::new(
291 ExitKind::Validation,
292 "INVALID_INPUT",
293 format!(
294 "entry `{}`: template `mem` {m:?} does not match the mem in the id — update cannot move an entity between mems (delete + create instead)",
295 entry.id
296 ),
297 )
298 .into());
299 }
300 let expected_hash = if entry.force {
301 None
302 } else if entry.auto_hash {
303 engine.get_entity(&id).map(|e| e.content_hash.clone())
308 } else if anchors_only {
309 entry.expected_hash.filter(|h| !h.is_empty())
315 } else {
316 entry.expected_hash
317 };
318
319 let patch_sections = entry
320 .patch_sections
321 .into_iter()
322 .map(|(k, v)| {
323 (
324 k,
325 v.into_vec()
326 .into_iter()
327 .map(|v| PatchArg {
328 old: v.old,
329 new: v.new,
330 all: v.all,
331 })
332 .collect(),
333 )
334 })
335 .collect();
336
337 let declare_relations = entry
338 .declare_relations
339 .into_iter()
340 .map(|r| RelateArg {
341 rel_type: r.rel_type,
342 target: EntityId::canonical(&r.target),
343 description: r.description,
344 })
345 .collect();
346
347 Ok((
348 UpdateEntityArgs {
349 anchors: entry.anchors,
350 anchors_unset: entry.anchors_unset,
351 id,
352 expected_hash,
353 sections: entry.sections,
354 append_sections: entry.append_sections,
355 patch_sections,
356 sections_unset: entry.sections_unset,
357 metadata: entry.metadata,
358 metadata_unset: entry.metadata_unset,
359 declare_relations,
360 dry_run: false,
361 relations_unset: Vec::new(),
362 },
363 note,
364 ))
365}
366
367fn build_entry_parse_error(
374 idx: usize,
375 entry_value: &serde_json::Value,
376 parse_err: &serde_json::Error,
377) -> CliError {
378 let known: std::collections::BTreeSet<&str> = [
379 "id",
380 "expected_hash",
381 "auto_hash",
382 "force",
383 "sections",
384 "append_sections",
385 "patch_sections",
386 "metadata",
387 "metadata_unset",
388 "declare_relations",
389 "anchors",
390 "anchors_unset",
391 "note",
392 ]
393 .into_iter()
394 .collect();
395 let mut unknown: Vec<String> = Vec::new();
396 if let Some(map) = entry_value.as_object() {
397 for k in map.keys() {
398 if !known.contains(k.as_str()) {
399 unknown.push(k.clone());
400 }
401 }
402 }
403 let suggested = unknown
408 .first()
409 .and_then(|u| nearest_recognised_key(u))
410 .map(String::from);
411 let message = if unknown.is_empty() {
412 format!("entry {idx}: invalid shape — {parse_err}")
413 } else {
414 let display = unknown.join(", ");
415 format!(
416 "entry {idx}: unknown field(s) {display} — recognised mutation keys are {:?}",
417 RECOGNISED_MUTATION_KEYS
418 )
419 };
420 let mut details = serde_json::json!({
421 "entry_index": idx,
422 "unknown_keys": unknown,
423 "parser_error": parse_err.to_string(),
424 "recognised_keys": RECOGNISED_MUTATION_KEYS,
425 });
426 if let Some(s) = suggested {
427 details["suggested"] = serde_json::Value::String(s);
428 }
429 CliError::new(ExitKind::Validation, "INVALID_INPUT", message).with_details(details)
430}
431
432fn nearest_recognised_key(attempted: &str) -> Option<&'static str> {
438 let lower = attempted.to_lowercase();
439 let mut best: Option<(&'static str, usize)> = None;
440 for &key in RECOGNISED_MUTATION_KEYS {
441 let score = if lower.starts_with(key) || key.starts_with(&lower) {
443 100
444 } else if lower.contains(key) || key.contains(&lower) {
445 80
446 } else {
447 shared_prefix_len(&lower, key) * 4
448 };
449 if score > 0 {
450 best = match best {
451 Some((_, best_score)) if best_score >= score => best,
452 _ => Some((key, score)),
453 };
454 }
455 }
456 best.map(|(k, _)| k)
457}
458
459fn shared_prefix_len(a: &str, b: &str) -> usize {
460 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
461}
462
463#[cfg(test)]
464mod tests {
465 #[test]
471 fn batch_entry_content_matches_the_engine_predicate() {
472 use memstead_base::UpdateEntityArgs;
473 let mut args = UpdateEntityArgs {
474 id: memstead_base::EntityId("m--e".into()),
475 expected_hash: None,
476 sections: Default::default(),
477 append_sections: Default::default(),
478 patch_sections: Default::default(),
479 sections_unset: Vec::new(),
480 metadata: Default::default(),
481 metadata_unset: Vec::new(),
482 dry_run: false,
483 declare_relations: Vec::new(),
484 anchors: Vec::new(),
485 anchors_unset: Vec::new(),
486 relations_unset: Vec::new(),
487 };
488 assert!(!args.changes_content(), "nothing named changes no content");
489 args.anchors.push(Default::default());
490 assert!(
491 !args.changes_content(),
492 "anchors are outside the content hash and must stay off this side"
493 );
494 args.sections.insert("purpose".into(), "x".into());
495 assert!(args.changes_content(), "a section change is content");
496 }
497
498 #[test]
502 fn batch_recognised_keys_are_a_subset_of_the_engines() {
503 let engine_keys: std::collections::BTreeSet<&str> =
504 memstead_base::engine::error::RECOGNISED_MUTATION_KEYS
505 .iter()
506 .copied()
507 .collect();
508 for key in super::RECOGNISED_MUTATION_KEYS {
509 assert!(
510 engine_keys.contains(key),
511 "batch advertises `{key}`, which the engine does not recognise"
512 );
513 }
514 }
515
516 use super::*;
517
518 #[test]
524 fn entry_parse_error_names_unknown_keys_and_suggests_nearest() {
525 let entry: serde_json::Value = serde_json::json!({
526 "id": "specs--target",
527 "auto_hash": true,
528 "section_replacements": {"identity": "X"},
529 });
530 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
531 let err = build_entry_parse_error(0, &entry, &parse_err);
532 let details = err.details.expect("details payload must be present");
533 assert_eq!(details["entry_index"].as_u64(), Some(0));
534 let unknown: Vec<String> = details["unknown_keys"]
535 .as_array()
536 .unwrap()
537 .iter()
538 .map(|v| v.as_str().unwrap().to_string())
539 .collect();
540 assert_eq!(unknown, vec!["section_replacements".to_string()]);
541 assert_eq!(details["suggested"].as_str(), Some("sections"));
542 assert_eq!(err.code, "INVALID_INPUT");
543 }
544
545 #[test]
547 fn entry_parse_error_suggests_metadata_for_meta_typo() {
548 let entry: serde_json::Value = serde_json::json!({
549 "id": "specs--target",
550 "auto_hash": true,
551 "meta": {"level": "M1"},
552 });
553 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
554 let err = build_entry_parse_error(7, &entry, &parse_err);
555 let details = err.details.expect("details payload");
556 assert_eq!(details["entry_index"].as_u64(), Some(7));
557 assert_eq!(details["suggested"].as_str(), Some("metadata"));
558 }
559
560 #[test]
565 fn entry_parse_accepts_every_documented_field() {
566 let entry: serde_json::Value = serde_json::json!({
567 "id": "specs--target",
568 "expected_hash": "abc",
569 "auto_hash": false,
570 "force": false,
571 "sections": {"identity": "A"},
572 "append_sections": {"purpose": "B"},
573 "patch_sections": {"identity": {"old": "X", "new": "Y", "all": true}},
574 "metadata": {"level": "M1"},
575 "metadata_unset": ["tags"],
576 "declare_relations": [{"target": "specs--other", "rel_type": "USES"}],
577 "note": "per-entry provenance",
578 });
579 let parsed = serde_json::from_value::<EntryPayload>(entry).expect("must parse");
580 assert_eq!(parsed.id, "specs--target");
581 assert_eq!(parsed.sections.len(), 1);
582 assert_eq!(parsed.declare_relations.len(), 1);
583 assert_eq!(parsed.declare_relations[0].rel_type, "USES");
584 assert_eq!(parsed.note.as_deref(), Some("per-entry provenance"));
586 }
587}