Skip to main content

memstead_cli/commands/
batch_update.rs

1//! `memstead batch-update --from <file.json>` — update many entities in one call.
2//!
3//! Per-entry hash mode mirrors `memstead update`'s flag set:
4//!
5//! * `expected_hash: "..."` — strict optimistic lock.
6//! * `auto_hash: true` — read the entity's current hash and use it.
7//! * `force: true` — skip the hash check entirely.
8//!
9//! Exactly one of the three must be set per entry. Each entry resolves
10//! its hash mode independently — a mixed-mode batch is fine.
11//!
12//! ```json
13//! { "updates": [
14//!     { "id": "specs--x", "expected_hash": "...",
15//!       "sections": { "identity": "..." } },
16//!     { "id": "specs--y", "auto_hash": true,
17//!       "append_sections": { "specifies": "more" } },
18//!     { "id": "specs--z", "force": true,
19//!       "metadata": { "level": "M1" } }
20//! ] }
21//! ```
22
23use 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    /// JSON file with a top-level `updates: [...]` array.
40    #[arg(long = "from", value_name = "FILE")]
41    pub from: PathBuf,
42}
43
44/// Recognised mutation-content keys on an `EntryPayload`. Centralised
45/// for the empty-mutation guard and the unknown-key suggestion hint.
46const 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    /// Inline relations to declare atomically before
77    /// section/metadata mutations — mirrors `memstead_update.declare_relations`
78    /// on the MCP surface. The CLI batch payload aligns with the
79    /// recognised mutation-key set so the empty-mutation guard and
80    /// `EMPTY_UPDATE` envelope cover this shape uniformly.
81    #[serde(default)]
82    declare_relations: Vec<RelationPayload>,
83    /// Provenance anchors for THIS entry — matches the MCP `memstead_update`
84    /// `anchors[]` shape. Written into the mem-branch anchors sidecar in
85    /// the same batch commit; malformed input refuses `INVALID_ANCHOR`.
86    #[serde(default)]
87    anchors: Vec<memstead_base::anchor::AnchorInput>,
88    /// Agent-authored provenance note for THIS entry's commit — matches
89    /// the MCP mutation shape's `note`. Per-entry: distinct notes across
90    /// batch entries are expressible. Optional; omit for note-less
91    /// entries. (There is no batch-level `--note` flag, so no precedence
92    /// question arises.)
93    #[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    /// Full target entity id.
110    to: String,
111    /// Rel-type (UPPER_SNAKE_CASE; engine canonicalises).
112    #[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    // Two-phase parse so unknown-key refusals carry per-entry
128    // `entry_index` / `unknown_keys` / `suggested` recovery payloads
129    // instead of just serde's raw "unknown field" line/column text.
130    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    // Surface top-level unknown keys too (e.g. `update` typo for `updates`).
157    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    // Reload-before-op runs inside `batch_update` for every mem the
203    // batch touches; drain any `mem_changed` notice it stashed.
204    let mem_changed = engine.take_mem_changed_notices();
205
206    // A SUCCESSFUL batch renders exactly as before — the structured
207    // result on stdout (`--json`) or the per-entry breakdown (human) —
208    // and exits 0.
209    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    // A FAILED batch (atomic refusal — nothing committed) is surfaced as
223    // the standard error envelope (CLI F12): it carries a top-level
224    // `code` and maps to a non-zero exit code via `ExitKind`, consistent
225    // with single `update` and the documented exit-code table. A script
226    // branching on `$?` (or `--json | jq -r .code`) now detects the
227    // failure without parsing the per-entry envelope. The full result
228    // rides on `details`, so no information is lost. In human mode the
229    // per-entry breakdown still prints on stdout; the error summary
230    // rides stderr. In `--json` mode the single error envelope is the
231    // only thing on stdout, so it stays exactly one JSON document.
232    if !ctx.json {
233        print_markdown(&render_batch_markdown(&result));
234    }
235    Err(batch_refused_error(&result).into())
236}
237
238/// Render the per-entry markdown breakdown for a batch result (success
239/// or failure). Each entry shows a status marker, its id/action, and any
240/// per-entry error code+message; an applied batch appends its commit SHA.
241fn 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
279/// Build the error envelope for a refused (atomic) batch. The top-level
280/// `code` is the stable `BATCH_REFUSED` token; the `ExitKind` mirrors the
281/// dominant (refusal-tripping) entry's failure so `$?` matches single
282/// `update` and the documented table (hash mismatch → 4, missing entity /
283/// mem → 3, schema/policy refusal → 5). The full [`BatchResult`] rides
284/// on `details` — per-entry codes stay available without re-running.
285fn 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
307/// Map the dominant per-entry failure code to the process exit code,
308/// reusing the documented `0/1/3/4/5` taxonomy so a refused batch exits
309/// the same way the equivalent single `memstead update` would. Unrecognised
310/// codes fall to `Validation` (5) — the bucket for schema/policy refusals,
311/// which is what most batch-entry failures are.
312fn 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
320/// Map a single JSON entry to the engine's [`UpdateEntityArgs`],
321/// resolving the per-entry hash mode against the live engine: explicit
322/// hash passes through, `auto_hash` reads the entity's current hash
323/// and substitutes it, `force` clears the lock by setting
324/// `expected_hash: None`. The mutually-exclusive contract mirrors
325/// `memstead update`'s clap-level `conflicts_with_all`.
326fn 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    // Per-entry provenance note rides alongside the args to the engine.
356    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        // Missing-entity case falls through with `expected_hash: None`
362        // so the engine surfaces a typed `ENTITY_NOT_FOUND` for this
363        // entry. Under atomic semantics that refuses the whole batch
364        // (nothing commits) with this entry named in the result.
365        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
413/// Build the typed CLI error envelope for a per-entry deserialisation
414/// refusal. Walks the original JSON value to pick out keys not in the
415/// recognised entry-shape vocabulary so the recovery payload carries
416/// `entry_index`, `unknown_keys`, and a nearest-match `suggested`
417/// hint pointing at the recognised mutation key whose name is closest
418/// to the first unknown one (fuzzy-match shared with `memstead_schema`).
419fn 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    // Nearest-match suggestion for the first unknown key against the
448    // recognised mutation-content vocabulary (the keys this plan adds
449    // discipline to). Defaults to the literal vocabulary for callers
450    // with no fuzzy hit.
451    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
476/// Pick the recognised mutation-content key whose name is most
477/// similar to `attempted`, by simple substring + prefix scoring. Good
478/// enough for `section_replacements` → `sections`, `meta` →
479/// `metadata`, `declares` → `declare_relations`. Returns `None` for
480/// inputs with no plausible match.
481fn 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        // Score: full-prefix > substring > shared-stem letters.
486        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    /// Per-entry deserialisation refusal carries `entry_index`,
512    /// `unknown_keys`, and a nearest-match `suggested` hint pointing
513    /// at the recognised mutation key whose name is closest to the
514    /// first unknown one. Probe reproducer: `section_replacements`
515    /// → `sections`.
516    #[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    /// Complement AC: a `meta` → `metadata` fuzzy hit lands.
539    #[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    /// Complement AC: documented optional fields (`expected_hash`,
554    /// `auto_hash`, `force`, mutation maps) all parse cleanly under
555    /// `deny_unknown_fields`. Regression check that no documented
556    /// field name became an unknown key by accident.
557    #[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        // Per-entry note parses (distinct notes per batch entry).
578        assert_eq!(parsed.note.as_deref(), Some("per-entry provenance"));
579    }
580}