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, PatchPayload>,
84 #[serde(default)]
85 metadata: IndexMap<String, String>,
86 #[serde(default)]
87 metadata_unset: Vec<String>,
88 #[serde(default)]
94 declare_relations: Vec<RelationPayload>,
95 #[serde(default)]
99 anchors: Vec<memstead_base::anchor::AnchorInput>,
100 #[serde(default)]
104 anchors_unset: Vec<memstead_base::anchor::AnchorUnsetInput>,
105 #[serde(default)]
111 note: Option<String>,
112}
113
114#[derive(Debug, Deserialize)]
115#[serde(deny_unknown_fields)]
116struct PatchPayload {
117 old: String,
118 new: String,
119 #[serde(default)]
120 all: bool,
121}
122
123#[derive(Debug, Deserialize)]
124#[serde(deny_unknown_fields)]
125struct RelationPayload {
126 target: String,
128 rel_type: String,
130 #[serde(default)]
131 description: Option<String>,
132}
133
134pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
135 let updates_array = super::batch::parse_batch_envelope(&args.from, "updates")?;
139
140 let mut entries: Vec<EntryPayload> = Vec::with_capacity(updates_array.len());
141 for (idx, entry_value) in updates_array.into_iter().enumerate() {
142 match serde_json::from_value::<EntryPayload>(entry_value.clone()) {
143 Ok(entry) => entries.push(entry),
144 Err(e) => return Err(build_entry_parse_error(idx, &entry_value, &e).into()),
145 }
146 }
147
148 let mut engine = crate::setup::full_engine(ctx)?;
149
150 let updates: Vec<(UpdateEntityArgs, Option<String>)> = entries
151 .into_iter()
152 .map(|entry| build_update_args(&engine, entry))
153 .collect::<anyhow::Result<Vec<_>>>()?;
154 let result = engine
155 .batch_update(
156 updates,
157 Actor::Cli,
158 Some(&crate::setup::cli_client_id()),
159 args.dry_run,
160 )
161 .map_err(CliError::from_engine_op)?;
162 let mem_changed = engine.take_mem_changed_notices();
165
166 if result.applied {
170 if ctx.json {
171 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
172 crate::commands::merge_mem_changed_json(&mut body, &mem_changed);
173 print_json(&body)?;
174 } else {
175 let mut md = super::batch::render_batch_markdown("update", &result, args.dry_run);
176 md.push_str(&crate::commands::render_mem_changed_block(&mem_changed));
177 print_markdown(&md);
178 }
179 return Ok(());
180 }
181
182 if !ctx.json {
193 print_markdown(&super::batch::render_batch_markdown(
194 "update",
195 &result,
196 args.dry_run,
197 ));
198 }
199 Err(super::batch::batch_refused_error("update", &result).into())
200}
201
202fn build_update_args(
209 engine: &memstead_base::Engine,
210 entry: EntryPayload,
211) -> anyhow::Result<(UpdateEntityArgs, Option<String>)> {
212 let mode_count =
213 entry.expected_hash.is_some() as u8 + entry.auto_hash as u8 + entry.force as u8;
214 let changes_content = !entry.sections.is_empty()
224 || !entry.append_sections.is_empty()
225 || !entry.patch_sections.is_empty()
226 || !entry.metadata.is_empty()
227 || !entry.metadata_unset.is_empty()
228 || !entry.declare_relations.is_empty();
229 let anchors_only =
230 (!entry.anchors.is_empty() || !entry.anchors_unset.is_empty()) && !changes_content;
231 if mode_count == 0 && !anchors_only {
232 return Err(CliError::new(
233 ExitKind::Validation,
234 "INVALID_INPUT",
235 format!(
236 "entry `{}`: exactly one of `expected_hash`, `auto_hash`, or `force` must be set",
237 entry.id
238 ),
239 )
240 .into());
241 }
242 if mode_count > 1 {
243 return Err(CliError::new(
244 ExitKind::Validation,
245 "INVALID_INPUT",
246 format!(
247 "entry `{}`: `expected_hash`, `auto_hash`, and `force` are mutually exclusive",
248 entry.id
249 ),
250 )
251 .into());
252 }
253
254 let note = entry.note.clone();
256 let id = EntityId::canonical(&entry.id);
257 let expected_hash = if entry.force {
258 None
259 } else if entry.auto_hash {
260 engine.get_entity(&id).map(|e| e.content_hash.clone())
265 } else if anchors_only {
266 entry.expected_hash.filter(|h| !h.is_empty())
272 } else {
273 entry.expected_hash
274 };
275
276 let patch_sections = entry
277 .patch_sections
278 .into_iter()
279 .map(|(k, v)| {
280 (
281 k,
282 PatchArg {
283 old: v.old,
284 new: v.new,
285 all: v.all,
286 },
287 )
288 })
289 .collect();
290
291 let declare_relations = entry
292 .declare_relations
293 .into_iter()
294 .map(|r| RelateArg {
295 rel_type: r.rel_type,
296 target: EntityId::canonical(&r.target),
297 description: r.description,
298 })
299 .collect();
300
301 Ok((
302 UpdateEntityArgs {
303 anchors: entry.anchors,
304 anchors_unset: entry.anchors_unset,
305 id,
306 expected_hash,
307 sections: entry.sections,
308 append_sections: entry.append_sections,
309 patch_sections,
310 metadata: entry.metadata,
311 metadata_unset: entry.metadata_unset,
312 declare_relations,
313 dry_run: false,
314 relations_unset: Vec::new(),
315 },
316 note,
317 ))
318}
319
320fn build_entry_parse_error(
327 idx: usize,
328 entry_value: &serde_json::Value,
329 parse_err: &serde_json::Error,
330) -> CliError {
331 let known: std::collections::BTreeSet<&str> = [
332 "id",
333 "expected_hash",
334 "auto_hash",
335 "force",
336 "sections",
337 "append_sections",
338 "patch_sections",
339 "metadata",
340 "metadata_unset",
341 "declare_relations",
342 "anchors",
343 "anchors_unset",
344 "note",
345 ]
346 .into_iter()
347 .collect();
348 let mut unknown: Vec<String> = Vec::new();
349 if let Some(map) = entry_value.as_object() {
350 for k in map.keys() {
351 if !known.contains(k.as_str()) {
352 unknown.push(k.clone());
353 }
354 }
355 }
356 let suggested = unknown
361 .first()
362 .and_then(|u| nearest_recognised_key(u))
363 .map(String::from);
364 let message = if unknown.is_empty() {
365 format!("entry {idx}: invalid shape — {parse_err}")
366 } else {
367 let display = unknown.join(", ");
368 format!(
369 "entry {idx}: unknown field(s) {display} — recognised mutation keys are {:?}",
370 RECOGNISED_MUTATION_KEYS
371 )
372 };
373 let mut details = serde_json::json!({
374 "entry_index": idx,
375 "unknown_keys": unknown,
376 "parser_error": parse_err.to_string(),
377 "recognised_keys": RECOGNISED_MUTATION_KEYS,
378 });
379 if let Some(s) = suggested {
380 details["suggested"] = serde_json::Value::String(s);
381 }
382 CliError::new(ExitKind::Validation, "INVALID_INPUT", message).with_details(details)
383}
384
385fn nearest_recognised_key(attempted: &str) -> Option<&'static str> {
391 let lower = attempted.to_lowercase();
392 let mut best: Option<(&'static str, usize)> = None;
393 for &key in RECOGNISED_MUTATION_KEYS {
394 let score = if lower.starts_with(key) || key.starts_with(&lower) {
396 100
397 } else if lower.contains(key) || key.contains(&lower) {
398 80
399 } else {
400 shared_prefix_len(&lower, key) * 4
401 };
402 if score > 0 {
403 best = match best {
404 Some((_, best_score)) if best_score >= score => best,
405 _ => Some((key, score)),
406 };
407 }
408 }
409 best.map(|(k, _)| k)
410}
411
412fn shared_prefix_len(a: &str, b: &str) -> usize {
413 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
414}
415
416#[cfg(test)]
417mod tests {
418 #[test]
424 fn batch_entry_content_matches_the_engine_predicate() {
425 use memstead_base::UpdateEntityArgs;
426 let mut args = UpdateEntityArgs {
427 id: memstead_base::EntityId("m--e".into()),
428 expected_hash: None,
429 sections: Default::default(),
430 append_sections: Default::default(),
431 patch_sections: Default::default(),
432 metadata: Default::default(),
433 metadata_unset: Vec::new(),
434 dry_run: false,
435 declare_relations: Vec::new(),
436 anchors: Vec::new(),
437 anchors_unset: Vec::new(),
438 relations_unset: Vec::new(),
439 };
440 assert!(!args.changes_content(), "nothing named changes no content");
441 args.anchors.push(Default::default());
442 assert!(
443 !args.changes_content(),
444 "anchors are outside the content hash and must stay off this side"
445 );
446 args.sections.insert("purpose".into(), "x".into());
447 assert!(args.changes_content(), "a section change is content");
448 }
449
450 #[test]
454 fn batch_recognised_keys_are_a_subset_of_the_engines() {
455 let engine_keys: std::collections::BTreeSet<&str> =
456 memstead_base::engine::error::RECOGNISED_MUTATION_KEYS
457 .iter()
458 .copied()
459 .collect();
460 for key in super::RECOGNISED_MUTATION_KEYS {
461 assert!(
462 engine_keys.contains(key),
463 "batch advertises `{key}`, which the engine does not recognise"
464 );
465 }
466 }
467
468 use super::*;
469
470 #[test]
476 fn entry_parse_error_names_unknown_keys_and_suggests_nearest() {
477 let entry: serde_json::Value = serde_json::json!({
478 "id": "specs--target",
479 "auto_hash": true,
480 "section_replacements": {"identity": "X"},
481 });
482 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
483 let err = build_entry_parse_error(0, &entry, &parse_err);
484 let details = err.details.expect("details payload must be present");
485 assert_eq!(details["entry_index"].as_u64(), Some(0));
486 let unknown: Vec<String> = details["unknown_keys"]
487 .as_array()
488 .unwrap()
489 .iter()
490 .map(|v| v.as_str().unwrap().to_string())
491 .collect();
492 assert_eq!(unknown, vec!["section_replacements".to_string()]);
493 assert_eq!(details["suggested"].as_str(), Some("sections"));
494 assert_eq!(err.code, "INVALID_INPUT");
495 }
496
497 #[test]
499 fn entry_parse_error_suggests_metadata_for_meta_typo() {
500 let entry: serde_json::Value = serde_json::json!({
501 "id": "specs--target",
502 "auto_hash": true,
503 "meta": {"level": "M1"},
504 });
505 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
506 let err = build_entry_parse_error(7, &entry, &parse_err);
507 let details = err.details.expect("details payload");
508 assert_eq!(details["entry_index"].as_u64(), Some(7));
509 assert_eq!(details["suggested"].as_str(), Some("metadata"));
510 }
511
512 #[test]
517 fn entry_parse_accepts_every_documented_field() {
518 let entry: serde_json::Value = serde_json::json!({
519 "id": "specs--target",
520 "expected_hash": "abc",
521 "auto_hash": false,
522 "force": false,
523 "sections": {"identity": "A"},
524 "append_sections": {"purpose": "B"},
525 "patch_sections": {"identity": {"old": "X", "new": "Y", "all": true}},
526 "metadata": {"level": "M1"},
527 "metadata_unset": ["tags"],
528 "declare_relations": [{"target": "specs--other", "rel_type": "USES"}],
529 "note": "per-entry provenance",
530 });
531 let parsed = serde_json::from_value::<EntryPayload>(entry).expect("must parse");
532 assert_eq!(parsed.id, "specs--target");
533 assert_eq!(parsed.sections.len(), 1);
534 assert_eq!(parsed.declare_relations.len(), 1);
535 assert_eq!(parsed.declare_relations[0].rel_type, "USES");
536 assert_eq!(parsed.note.as_deref(), Some("per-entry provenance"));
538 }
539}