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}
43
44const RECOGNISED_MUTATION_KEYS: &[&str] = &[
47 "sections",
48 "append_sections",
49 "patch_sections",
50 "metadata",
51 "metadata_unset",
52 "declare_relations",
53 "anchors",
54];
55
56#[derive(Debug, Deserialize)]
57#[serde(deny_unknown_fields)]
58struct EntryPayload {
59 id: String,
60 #[serde(default)]
61 expected_hash: Option<String>,
62 #[serde(default)]
63 auto_hash: bool,
64 #[serde(default)]
65 force: bool,
66 #[serde(default)]
67 sections: IndexMap<String, String>,
68 #[serde(default)]
69 append_sections: IndexMap<String, String>,
70 #[serde(default)]
71 patch_sections: IndexMap<String, PatchPayload>,
72 #[serde(default)]
73 metadata: IndexMap<String, String>,
74 #[serde(default)]
75 metadata_unset: Vec<String>,
76 #[serde(default)]
82 declare_relations: Vec<RelationPayload>,
83 #[serde(default)]
87 anchors: Vec<memstead_base::anchor::AnchorInput>,
88 #[serde(default)]
94 note: Option<String>,
95}
96
97#[derive(Debug, Deserialize)]
98#[serde(deny_unknown_fields)]
99struct PatchPayload {
100 old: String,
101 new: String,
102 #[serde(default)]
103 all: bool,
104}
105
106#[derive(Debug, Deserialize)]
107#[serde(deny_unknown_fields)]
108struct RelationPayload {
109 to: String,
111 #[serde(rename = "type")]
113 rel_type: String,
114 #[serde(default)]
115 description: Option<String>,
116}
117
118pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
119 let bytes = std::fs::read(&args.from).map_err(|e| {
120 CliError::new(
121 ExitKind::Generic,
122 "INVALID_INPUT",
123 format!("failed to read {}: {e}", args.from.display()),
124 )
125 })?;
126
127 let envelope: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
131 CliError::new(
132 ExitKind::Validation,
133 "INVALID_INPUT",
134 format!("invalid JSON in {}: {e}", args.from.display()),
135 )
136 .with_details(serde_json::json!({
137 "path": args.from.display().to_string(),
138 "parser_error": e.to_string(),
139 }))
140 })?;
141 let updates_value = envelope
142 .get("updates")
143 .cloned()
144 .unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
145 let updates_array = match &updates_value {
146 serde_json::Value::Array(a) => a.clone(),
147 _ => {
148 return Err(CliError::new(
149 ExitKind::Validation,
150 "INVALID_INPUT",
151 "`updates` must be a JSON array",
152 )
153 .into());
154 }
155 };
156 if let serde_json::Value::Object(map) = &envelope {
158 let unknown: Vec<String> = map
159 .keys()
160 .filter(|k| k.as_str() != "updates")
161 .cloned()
162 .collect();
163 if !unknown.is_empty() {
164 return Err(CliError::new(
165 ExitKind::Validation,
166 "INVALID_INPUT",
167 format!(
168 "unknown top-level key(s) {unknown:?} — only `updates: [...]` is recognised"
169 ),
170 )
171 .with_details(serde_json::json!({
172 "unknown_keys": unknown,
173 "suggested": "updates",
174 }))
175 .into());
176 }
177 }
178
179 if updates_array.is_empty() {
180 return Err(
181 CliError::new(ExitKind::Validation, "INVALID_INPUT", "updates[] is empty").into(),
182 );
183 }
184
185 let mut entries: Vec<EntryPayload> = Vec::with_capacity(updates_array.len());
186 for (idx, entry_value) in updates_array.into_iter().enumerate() {
187 match serde_json::from_value::<EntryPayload>(entry_value.clone()) {
188 Ok(entry) => entries.push(entry),
189 Err(e) => return Err(build_entry_parse_error(idx, &entry_value, &e).into()),
190 }
191 }
192
193 let mut engine = crate::setup::pro_engine(ctx)?;
194
195 let updates: Vec<(UpdateEntityArgs, Option<String>)> = entries
196 .into_iter()
197 .map(|entry| build_update_args(&engine, entry))
198 .collect::<anyhow::Result<Vec<_>>>()?;
199 let result = engine
200 .batch_update(updates, Actor::Cli, None)
201 .map_err(CliError::from_engine_op)?;
202 let mem_changed = engine.take_mem_changed_notices();
205
206 if result.applied {
210 if ctx.json {
211 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
212 crate::commands::merge_mem_changed_json(&mut body, &mem_changed);
213 print_json(&body)?;
214 } else {
215 let mut md = render_batch_markdown(&result);
216 md.push_str(&crate::commands::render_mem_changed_block(&mem_changed));
217 print_markdown(&md);
218 }
219 return Ok(());
220 }
221
222 if !ctx.json {
233 print_markdown(&render_batch_markdown(&result));
234 }
235 Err(batch_refused_error(&result).into())
236}
237
238fn render_batch_markdown(result: &memstead_base::ops::BatchResult) -> String {
242 let header = if result.applied {
243 format!(
244 "# Batch update applied — {} item(s) in one commit",
245 result.succeeded
246 )
247 } else {
248 format!(
249 "# Batch update REFUSED — {} item(s) failed, nothing committed",
250 result.failed
251 )
252 };
253 let mut lines = vec![header, String::new()];
254 for entry in &result.results {
255 let marker = if entry.error.is_some() {
256 "✗"
257 } else if entry.action == "not_applied" {
258 "·"
259 } else {
260 "✓"
261 };
262 let detail = entry
263 .error
264 .as_ref()
265 .map(|e| format!(" — [{}] {}", e.code, e.message))
266 .unwrap_or_default();
267 lines.push(format!(
268 "- {marker} `{}` ({}){}",
269 entry.id, entry.action, detail
270 ));
271 }
272 if result.applied && !result.commit_sha.is_empty() {
273 lines.push(String::new());
274 lines.push(format!("Commit: `{}`", result.commit_sha));
275 }
276 lines.join("\n")
277}
278
279fn batch_refused_error(result: &memstead_base::ops::BatchResult) -> CliError {
286 let dominant = result.results.iter().find(|e| e.error.is_some());
287 let (code, failing_id, message) = match dominant {
288 Some(entry) => {
289 let err = entry.error.as_ref().expect("dominant entry has an error");
290 (err.code.as_str(), entry.id.to_string(), err.message.clone())
291 }
292 None => (
293 "",
294 String::new(),
295 "batch-update refused; nothing committed".to_string(),
296 ),
297 };
298 let kind = batch_refused_exit_kind(code);
299 let summary = format!(
300 "batch-update refused — {} item(s) failed, nothing committed; first failure [{}] on `{}`: {}",
301 result.failed, code, failing_id, message,
302 );
303 CliError::new(kind, "BATCH_REFUSED", summary)
304 .with_details(serde_json::to_value(result).unwrap_or(serde_json::Value::Null))
305}
306
307fn batch_refused_exit_kind(code: &str) -> ExitKind {
313 match code {
314 "HASH_MISMATCH" => ExitKind::HashMismatch,
315 "ENTITY_NOT_FOUND" | "UNKNOWN_MEM" => ExitKind::NotFound,
316 _ => ExitKind::Validation,
317 }
318}
319
320fn build_update_args(
327 engine: &memstead_base::Engine,
328 entry: EntryPayload,
329) -> anyhow::Result<(UpdateEntityArgs, Option<String>)> {
330 let mode_count =
331 entry.expected_hash.is_some() as u8 + entry.auto_hash as u8 + entry.force as u8;
332 if mode_count == 0 {
333 return Err(CliError::new(
334 ExitKind::Validation,
335 "INVALID_INPUT",
336 format!(
337 "entry `{}`: exactly one of `expected_hash`, `auto_hash`, or `force` must be set",
338 entry.id
339 ),
340 )
341 .into());
342 }
343 if mode_count > 1 {
344 return Err(CliError::new(
345 ExitKind::Validation,
346 "INVALID_INPUT",
347 format!(
348 "entry `{}`: `expected_hash`, `auto_hash`, and `force` are mutually exclusive",
349 entry.id
350 ),
351 )
352 .into());
353 }
354
355 let note = entry.note.clone();
357 let id = EntityId::canonical(&entry.id);
358 let expected_hash = if entry.force {
359 None
360 } else if entry.auto_hash {
361 engine.get_entity(&id).map(|e| e.content_hash.clone())
366 } else {
367 entry.expected_hash
368 };
369
370 let patch_sections = entry
371 .patch_sections
372 .into_iter()
373 .map(|(k, v)| {
374 (
375 k,
376 PatchArg {
377 old: v.old,
378 new: v.new,
379 all: v.all,
380 },
381 )
382 })
383 .collect();
384
385 let declare_relations = entry
386 .declare_relations
387 .into_iter()
388 .map(|r| RelateArg {
389 rel_type: r.rel_type,
390 to: EntityId::canonical(&r.to),
391 description: r.description,
392 })
393 .collect();
394
395 Ok((
396 UpdateEntityArgs {
397 anchors: entry.anchors,
398 id,
399 expected_hash,
400 sections: entry.sections,
401 append_sections: entry.append_sections,
402 patch_sections,
403 metadata: entry.metadata,
404 metadata_unset: entry.metadata_unset,
405 declare_relations,
406 dry_run: false,
407 relations_unset: Vec::new(),
408 },
409 note,
410 ))
411}
412
413fn build_entry_parse_error(
420 idx: usize,
421 entry_value: &serde_json::Value,
422 parse_err: &serde_json::Error,
423) -> CliError {
424 let known: std::collections::BTreeSet<&str> = [
425 "id",
426 "expected_hash",
427 "auto_hash",
428 "force",
429 "sections",
430 "append_sections",
431 "patch_sections",
432 "metadata",
433 "metadata_unset",
434 "declare_relations",
435 "note",
436 ]
437 .into_iter()
438 .collect();
439 let mut unknown: Vec<String> = Vec::new();
440 if let Some(map) = entry_value.as_object() {
441 for k in map.keys() {
442 if !known.contains(k.as_str()) {
443 unknown.push(k.clone());
444 }
445 }
446 }
447 let suggested = unknown
452 .first()
453 .and_then(|u| nearest_recognised_key(u))
454 .map(String::from);
455 let message = if unknown.is_empty() {
456 format!("entry {idx}: invalid shape — {parse_err}")
457 } else {
458 let display = unknown.join(", ");
459 format!(
460 "entry {idx}: unknown field(s) {display} — recognised mutation keys are {:?}",
461 RECOGNISED_MUTATION_KEYS
462 )
463 };
464 let mut details = serde_json::json!({
465 "entry_index": idx,
466 "unknown_keys": unknown,
467 "parser_error": parse_err.to_string(),
468 "recognised_keys": RECOGNISED_MUTATION_KEYS,
469 });
470 if let Some(s) = suggested {
471 details["suggested"] = serde_json::Value::String(s);
472 }
473 CliError::new(ExitKind::Validation, "INVALID_INPUT", message).with_details(details)
474}
475
476fn nearest_recognised_key(attempted: &str) -> Option<&'static str> {
482 let lower = attempted.to_lowercase();
483 let mut best: Option<(&'static str, usize)> = None;
484 for &key in RECOGNISED_MUTATION_KEYS {
485 let score = if lower.starts_with(key) || key.starts_with(&lower) {
487 100
488 } else if lower.contains(key) || key.contains(&lower) {
489 80
490 } else {
491 shared_prefix_len(&lower, key) * 4
492 };
493 if score > 0 {
494 best = match best {
495 Some((_, best_score)) if best_score >= score => best,
496 _ => Some((key, score)),
497 };
498 }
499 }
500 best.map(|(k, _)| k)
501}
502
503fn shared_prefix_len(a: &str, b: &str) -> usize {
504 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
517 fn entry_parse_error_names_unknown_keys_and_suggests_nearest() {
518 let entry: serde_json::Value = serde_json::json!({
519 "id": "specs--target",
520 "auto_hash": true,
521 "section_replacements": {"identity": "X"},
522 });
523 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
524 let err = build_entry_parse_error(0, &entry, &parse_err);
525 let details = err.details.expect("details payload must be present");
526 assert_eq!(details["entry_index"].as_u64(), Some(0));
527 let unknown: Vec<String> = details["unknown_keys"]
528 .as_array()
529 .unwrap()
530 .iter()
531 .map(|v| v.as_str().unwrap().to_string())
532 .collect();
533 assert_eq!(unknown, vec!["section_replacements".to_string()]);
534 assert_eq!(details["suggested"].as_str(), Some("sections"));
535 assert_eq!(err.code, "INVALID_INPUT");
536 }
537
538 #[test]
540 fn entry_parse_error_suggests_metadata_for_meta_typo() {
541 let entry: serde_json::Value = serde_json::json!({
542 "id": "specs--target",
543 "auto_hash": true,
544 "meta": {"level": "M1"},
545 });
546 let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
547 let err = build_entry_parse_error(7, &entry, &parse_err);
548 let details = err.details.expect("details payload");
549 assert_eq!(details["entry_index"].as_u64(), Some(7));
550 assert_eq!(details["suggested"].as_str(), Some("metadata"));
551 }
552
553 #[test]
558 fn entry_parse_accepts_every_documented_field() {
559 let entry: serde_json::Value = serde_json::json!({
560 "id": "specs--target",
561 "expected_hash": "abc",
562 "auto_hash": false,
563 "force": false,
564 "sections": {"identity": "A"},
565 "append_sections": {"purpose": "B"},
566 "patch_sections": {"identity": {"old": "X", "new": "Y", "all": true}},
567 "metadata": {"level": "M1"},
568 "metadata_unset": ["tags"],
569 "declare_relations": [{"to": "specs--other", "type": "USES"}],
570 "note": "per-entry provenance",
571 });
572 let parsed = serde_json::from_value::<EntryPayload>(entry).expect("must parse");
573 assert_eq!(parsed.id, "specs--target");
574 assert_eq!(parsed.sections.len(), 1);
575 assert_eq!(parsed.declare_relations.len(), 1);
576 assert_eq!(parsed.declare_relations[0].rel_type, "USES");
577 assert_eq!(parsed.note.as_deref(), Some("per-entry provenance"));
579 }
580}