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, args.dry_run);
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(
190 "update",
191 &result,
192 args.dry_run,
193 ));
194 }
195 Err(super::batch::batch_refused_error("update", &result).into())
196}
197
198fn build_update_args(
205 engine: &memstead_base::Engine,
206 entry: EntryPayload,
207) -> anyhow::Result<(UpdateEntityArgs, Option<String>)> {
208 let mode_count =
209 entry.expected_hash.is_some() as u8 + entry.auto_hash as u8 + entry.force as u8;
210 if mode_count == 0 {
211 return Err(CliError::new(
212 ExitKind::Validation,
213 "INVALID_INPUT",
214 format!(
215 "entry `{}`: exactly one of `expected_hash`, `auto_hash`, or `force` must be set",
216 entry.id
217 ),
218 )
219 .into());
220 }
221 if mode_count > 1 {
222 return Err(CliError::new(
223 ExitKind::Validation,
224 "INVALID_INPUT",
225 format!(
226 "entry `{}`: `expected_hash`, `auto_hash`, and `force` are mutually exclusive",
227 entry.id
228 ),
229 )
230 .into());
231 }
232
233 let note = entry.note.clone();
235 let id = EntityId::canonical(&entry.id);
236 let expected_hash = if entry.force {
237 None
238 } else if entry.auto_hash {
239 engine.get_entity(&id).map(|e| e.content_hash.clone())
244 } else {
245 entry.expected_hash
246 };
247
248 let patch_sections = entry
249 .patch_sections
250 .into_iter()
251 .map(|(k, v)| {
252 (
253 k,
254 PatchArg {
255 old: v.old,
256 new: v.new,
257 all: v.all,
258 },
259 )
260 })
261 .collect();
262
263 let declare_relations = entry
264 .declare_relations
265 .into_iter()
266 .map(|r| RelateArg {
267 rel_type: r.rel_type,
268 to: EntityId::canonical(&r.to),
269 description: r.description,
270 })
271 .collect();
272
273 Ok((
274 UpdateEntityArgs {
275 anchors: entry.anchors,
276 anchors_unset: entry.anchors_unset,
277 id,
278 expected_hash,
279 sections: entry.sections,
280 append_sections: entry.append_sections,
281 patch_sections,
282 metadata: entry.metadata,
283 metadata_unset: entry.metadata_unset,
284 declare_relations,
285 dry_run: false,
286 relations_unset: Vec::new(),
287 },
288 note,
289 ))
290}
291
292fn build_entry_parse_error(
299 idx: usize,
300 entry_value: &serde_json::Value,
301 parse_err: &serde_json::Error,
302) -> CliError {
303 let known: std::collections::BTreeSet<&str> = [
304 "id",
305 "expected_hash",
306 "auto_hash",
307 "force",
308 "sections",
309 "append_sections",
310 "patch_sections",
311 "metadata",
312 "metadata_unset",
313 "declare_relations",
314 "note",
315 ]
316 .into_iter()
317 .collect();
318 let mut unknown: Vec<String> = Vec::new();
319 if let Some(map) = entry_value.as_object() {
320 for k in map.keys() {
321 if !known.contains(k.as_str()) {
322 unknown.push(k.clone());
323 }
324 }
325 }
326 let suggested = unknown
331 .first()
332 .and_then(|u| nearest_recognised_key(u))
333 .map(String::from);
334 let message = if unknown.is_empty() {
335 format!("entry {idx}: invalid shape — {parse_err}")
336 } else {
337 let display = unknown.join(", ");
338 format!(
339 "entry {idx}: unknown field(s) {display} — recognised mutation keys are {:?}",
340 RECOGNISED_MUTATION_KEYS
341 )
342 };
343 let mut details = serde_json::json!({
344 "entry_index": idx,
345 "unknown_keys": unknown,
346 "parser_error": parse_err.to_string(),
347 "recognised_keys": RECOGNISED_MUTATION_KEYS,
348 });
349 if let Some(s) = suggested {
350 details["suggested"] = serde_json::Value::String(s);
351 }
352 CliError::new(ExitKind::Validation, "INVALID_INPUT", message).with_details(details)
353}
354
355fn nearest_recognised_key(attempted: &str) -> Option<&'static str> {
361 let lower = attempted.to_lowercase();
362 let mut best: Option<(&'static str, usize)> = None;
363 for &key in RECOGNISED_MUTATION_KEYS {
364 let score = if lower.starts_with(key) || key.starts_with(&lower) {
366 100
367 } else if lower.contains(key) || key.contains(&lower) {
368 80
369 } else {
370 shared_prefix_len(&lower, key) * 4
371 };
372 if score > 0 {
373 best = match best {
374 Some((_, best_score)) if best_score >= score => best,
375 _ => Some((key, score)),
376 };
377 }
378 }
379 best.map(|(k, _)| k)
380}
381
382fn shared_prefix_len(a: &str, b: &str) -> usize {
383 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
396 fn entry_parse_error_names_unknown_keys_and_suggests_nearest() {
397 let entry: serde_json::Value = serde_json::json!({
398 "id": "specs--target",
399 "auto_hash": true,
400 "section_replacements": {"identity": "X"},
401 });
402 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
403 let err = build_entry_parse_error(0, &entry, &parse_err);
404 let details = err.details.expect("details payload must be present");
405 assert_eq!(details["entry_index"].as_u64(), Some(0));
406 let unknown: Vec<String> = details["unknown_keys"]
407 .as_array()
408 .unwrap()
409 .iter()
410 .map(|v| v.as_str().unwrap().to_string())
411 .collect();
412 assert_eq!(unknown, vec!["section_replacements".to_string()]);
413 assert_eq!(details["suggested"].as_str(), Some("sections"));
414 assert_eq!(err.code, "INVALID_INPUT");
415 }
416
417 #[test]
419 fn entry_parse_error_suggests_metadata_for_meta_typo() {
420 let entry: serde_json::Value = serde_json::json!({
421 "id": "specs--target",
422 "auto_hash": true,
423 "meta": {"level": "M1"},
424 });
425 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
426 let err = build_entry_parse_error(7, &entry, &parse_err);
427 let details = err.details.expect("details payload");
428 assert_eq!(details["entry_index"].as_u64(), Some(7));
429 assert_eq!(details["suggested"].as_str(), Some("metadata"));
430 }
431
432 #[test]
437 fn entry_parse_accepts_every_documented_field() {
438 let entry: serde_json::Value = serde_json::json!({
439 "id": "specs--target",
440 "expected_hash": "abc",
441 "auto_hash": false,
442 "force": false,
443 "sections": {"identity": "A"},
444 "append_sections": {"purpose": "B"},
445 "patch_sections": {"identity": {"old": "X", "new": "Y", "all": true}},
446 "metadata": {"level": "M1"},
447 "metadata_unset": ["tags"],
448 "declare_relations": [{"to": "specs--other", "type": "USES"}],
449 "note": "per-entry provenance",
450 });
451 let parsed = serde_json::from_value::<EntryPayload>(entry).expect("must parse");
452 assert_eq!(parsed.id, "specs--target");
453 assert_eq!(parsed.sections.len(), 1);
454 assert_eq!(parsed.declare_relations.len(), 1);
455 assert_eq!(parsed.declare_relations[0].rel_type, "USES");
456 assert_eq!(parsed.note.as_deref(), Some("per-entry provenance"));
458 }
459}