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