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    /// Rehearse the whole batch: run the full per-entry validation
43    /// (identical refusals, report-all) and report the would-be
44    /// receipt, committing nothing. `write_id` stays empty (the
45    /// rehearsal marker).
46    #[arg(long = "dry-run")]
47    pub dry_run: bool,
48}
49
50/// Recognised mutation-content keys on an `EntryPayload`. Centralised
51/// for the empty-mutation guard and the unknown-key suggestion hint.
52/// The engine's recognised mutation keys MINUS `relations_unset`, which
53/// `EntryPayload` does not accept. Taking the engine's list wholesale would
54/// advertise a key the parser (`deny_unknown_fields`) then rejects, which is
55/// the same lie in the other direction as the copies this const replaced.
56/// `batch_recognised_keys_are_a_subset_of_the_engines` pins the relationship.
57const 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, PatchesPayload>,
84    #[serde(default)]
85    sections_unset: Vec<String>,
86    /// Tolerated for template symmetry with `batch-create` entries and
87    /// `update --from`: when present it must match the mem encoded in
88    /// the entry's id (update cannot move an entity between mems); a
89    /// differing value refuses the entry. Until 2026-08-31 this key was
90    /// refused outright here (`deny_unknown_fields`, no field) while
91    /// both siblings accepted it — each mismatch cost one refused batch.
92    #[serde(default)]
93    mem: Option<String>,
94    #[serde(default)]
95    metadata: IndexMap<String, String>,
96    #[serde(default)]
97    metadata_unset: Vec<String>,
98    /// Inline relations to declare atomically before
99    /// section/metadata mutations — mirrors `memstead_update.declare_relations`
100    /// on the MCP surface. The CLI batch payload aligns with the
101    /// recognised mutation-key set so the empty-mutation guard and
102    /// `EMPTY_UPDATE` envelope cover this shape uniformly.
103    #[serde(default)]
104    declare_relations: Vec<RelationPayload>,
105    /// Provenance anchors for THIS entry — matches the MCP `memstead_update`
106    /// `anchors[]` shape. Written into the mem-branch anchors sidecar in
107    /// the same batch commit; malformed input refuses `INVALID_ANCHOR`.
108    #[serde(default)]
109    anchors: Vec<memstead_base::anchor::AnchorInput>,
110    /// Explicit anchor removals for THIS entry — matches the MCP
111    /// `memstead_update` `anchors_unset[]` shape; applied before the
112    /// entry's `anchors` merge in the same batch commit.
113    #[serde(default)]
114    anchors_unset: Vec<memstead_base::anchor::AnchorUnsetInput>,
115    /// Agent-authored provenance note for THIS entry's commit — matches
116    /// the MCP mutation shape's `note`. Per-entry: distinct notes across
117    /// batch entries are expressible. Optional; omit for note-less
118    /// entries. (There is no batch-level `--note` flag, so no precedence
119    /// question arises.)
120    #[serde(default)]
121    note: Option<String>,
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(deny_unknown_fields)]
126struct PatchPayload {
127    old: String,
128    new: String,
129    #[serde(default)]
130    all: bool,
131}
132
133/// One patch or a list per section — both shapes accepted, list applied
134/// in order (mirrors `update --from` and the MCP wire).
135#[derive(Debug, Deserialize)]
136#[serde(untagged)]
137enum PatchesPayload {
138    One(PatchPayload),
139    Many(Vec<PatchPayload>),
140}
141
142impl PatchesPayload {
143    fn into_vec(self) -> Vec<PatchPayload> {
144        match self {
145            PatchesPayload::One(p) => vec![p],
146            PatchesPayload::Many(v) => v,
147        }
148    }
149}
150
151#[derive(Debug, Deserialize)]
152#[serde(deny_unknown_fields)]
153struct RelationPayload {
154    /// Far end of the edge; the near end is the entity being updated.
155    target: String,
156    /// Rel-type (UPPER_SNAKE_CASE; engine canonicalises).
157    rel_type: String,
158    #[serde(default)]
159    description: Option<String>,
160}
161
162pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
163    // Envelope parsing shared with the batch family; the two-phase
164    // per-entry parse below keeps this command's richer unknown-key
165    // recovery payloads (`entry_index` / `unknown_keys` / `suggested`).
166    let updates_array = super::batch::parse_batch_envelope(&args.from, "updates")?;
167
168    let mut entries: Vec<EntryPayload> = Vec::with_capacity(updates_array.len());
169    for (idx, entry_value) in updates_array.into_iter().enumerate() {
170        match serde_json::from_value::<EntryPayload>(entry_value.clone()) {
171            Ok(entry) => entries.push(entry),
172            Err(e) => return Err(build_entry_parse_error(idx, &entry_value, &e).into()),
173        }
174    }
175
176    let mut engine = crate::setup::full_engine(ctx)?;
177
178    let updates: Vec<(UpdateEntityArgs, Option<String>)> = entries
179        .into_iter()
180        .map(|entry| build_update_args(&engine, entry))
181        .collect::<anyhow::Result<Vec<_>>>()?;
182    let result = engine
183        .batch_update(
184            updates,
185            Actor::Cli,
186            Some(&crate::setup::cli_client_id()),
187            args.dry_run,
188        )
189        .map_err(CliError::from_engine_op)?;
190    // Reload-before-op runs inside `batch_update` for every mem the
191    // batch touches; drain any `mem_changed` notice it stashed.
192    let mem_changed = engine.take_mem_changed_notices();
193
194    // A SUCCESSFUL batch renders exactly as before — the structured
195    // result on stdout (`--json`) or the per-entry breakdown (human) —
196    // and exits 0.
197    if result.applied {
198        if ctx.json {
199            let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
200            crate::commands::merge_mem_changed_json(&mut body, &mem_changed);
201            print_json(&body)?;
202        } else {
203            let mut md = super::batch::render_batch_markdown("update", &result, args.dry_run);
204            md.push_str(&crate::commands::render_mem_changed_block(&mem_changed));
205            print_markdown(&md);
206        }
207        return Ok(());
208    }
209
210    // A FAILED batch (atomic refusal — nothing committed) is surfaced as
211    // the standard error envelope (CLI F12): it carries a top-level
212    // `code` and maps to a non-zero exit code via `ExitKind`, consistent
213    // with single `update` and the documented exit-code table. A script
214    // branching on `$?` (or `--json | jq -r .code`) now detects the
215    // failure without parsing the per-entry envelope. The full result
216    // rides on `details`, so no information is lost. In human mode the
217    // per-entry breakdown still prints on stdout; the error summary
218    // rides stderr. In `--json` mode the single error envelope is the
219    // only thing on stdout, so it stays exactly one JSON document.
220    if !ctx.json {
221        print_markdown(&super::batch::render_batch_markdown(
222            "update",
223            &result,
224            args.dry_run,
225        ));
226    }
227    Err(super::batch::batch_refused_error("update", &result).into())
228}
229
230/// Map a single JSON entry to the engine's [`UpdateEntityArgs`],
231/// resolving the per-entry hash mode against the live engine: explicit
232/// hash passes through, `auto_hash` reads the entity's current hash
233/// and substitutes it, `force` clears the lock by setting
234/// `expected_hash: None`. The mutually-exclusive contract mirrors
235/// `memstead update`'s clap-level `conflicts_with_all`.
236fn build_update_args(
237    engine: &memstead_base::Engine,
238    entry: EntryPayload,
239) -> anyhow::Result<(UpdateEntityArgs, Option<String>)> {
240    let mode_count =
241        entry.expected_hash.is_some() as u8 + entry.auto_hash as u8 + entry.force as u8;
242    // An anchors-only entry needs no hash mode, for the reason every other
243    // update surface exempts one (consistency-sweep 03/04): the token would
244    // compare a value the write cannot move. Left in, this path refused an
245    // entry that `memstead update` accepts, which is the surface divergence
246    // the plan's criterion 4 is about.
247    // Hand-rolled rather than `UpdateEntityArgs::changes_content()` only
248    // because the args do not exist yet at this point: the hash mode has to be
249    // resolved before they can be built. `batch_entry_content_matches_the_engine_predicate`
250    // pins the two against each other so they cannot drift.
251    let changes_content = !entry.sections.is_empty()
252        || !entry.append_sections.is_empty()
253        || !entry.patch_sections.is_empty()
254        || !entry.metadata.is_empty()
255        || !entry.metadata_unset.is_empty()
256        || !entry.declare_relations.is_empty();
257    let anchors_only =
258        (!entry.anchors.is_empty() || !entry.anchors_unset.is_empty()) && !changes_content;
259    if mode_count == 0 && !anchors_only {
260        return Err(CliError::new(
261            ExitKind::Validation,
262            "INVALID_INPUT",
263            format!(
264                "entry `{}`: exactly one of `expected_hash`, `auto_hash`, or `force` must be set",
265                entry.id
266            ),
267        )
268        .into());
269    }
270    if mode_count > 1 {
271        return Err(CliError::new(
272            ExitKind::Validation,
273            "INVALID_INPUT",
274            format!(
275                "entry `{}`: `expected_hash`, `auto_hash`, and `force` are mutually exclusive",
276                entry.id
277            ),
278        )
279        .into());
280    }
281
282    // Per-entry provenance note rides alongside the args to the engine.
283    let note = entry.note.clone();
284    let id = EntityId::canonical(&entry.id);
285    // Template-symmetry `mem` key: tolerated when it matches the id's
286    // mem, refused when it contradicts it (same rule as `update --from`).
287    if let Some(m) = entry.mem.as_deref()
288        && m != id.mem()
289    {
290        return Err(CliError::new(
291            ExitKind::Validation,
292            "INVALID_INPUT",
293            format!(
294                "entry `{}`: template `mem` {m:?} does not match the mem in the id —                  update cannot move an entity between mems (delete + create instead)",
295                entry.id
296            ),
297        )
298        .into());
299    }
300    let expected_hash = if entry.force {
301        None
302    } else if entry.auto_hash {
303        // Missing-entity case falls through with `expected_hash: None`
304        // so the engine surfaces a typed `ENTITY_NOT_FOUND` for this
305        // entry. Under atomic semantics that refuses the whole batch
306        // (nothing commits) with this entry named in the result.
307        engine.get_entity(&id).map(|e| e.content_hash.clone())
308    } else if anchors_only {
309        // An EMPTY token is no token on an anchors-only entry, as on
310        // `memstead update` and both MCP flavours: passed through, `""` reaches
311        // the engine and can never match a real hash, so the identical payload
312        // that the other three surfaces write refused HASH_MISMATCH here
313        // (consistency-sweep 03/04, criterion 4).
314        entry.expected_hash.filter(|h| !h.is_empty())
315    } else {
316        entry.expected_hash
317    };
318
319    let patch_sections = entry
320        .patch_sections
321        .into_iter()
322        .map(|(k, v)| {
323            (
324                k,
325                v.into_vec()
326                    .into_iter()
327                    .map(|v| PatchArg {
328                        old: v.old,
329                        new: v.new,
330                        all: v.all,
331                    })
332                    .collect(),
333            )
334        })
335        .collect();
336
337    let declare_relations = entry
338        .declare_relations
339        .into_iter()
340        .map(|r| RelateArg {
341            rel_type: r.rel_type,
342            target: EntityId::canonical(&r.target),
343            description: r.description,
344        })
345        .collect();
346
347    Ok((
348        UpdateEntityArgs {
349            anchors: entry.anchors,
350            anchors_unset: entry.anchors_unset,
351            id,
352            expected_hash,
353            sections: entry.sections,
354            append_sections: entry.append_sections,
355            patch_sections,
356            sections_unset: entry.sections_unset,
357            metadata: entry.metadata,
358            metadata_unset: entry.metadata_unset,
359            declare_relations,
360            dry_run: false,
361            relations_unset: Vec::new(),
362        },
363        note,
364    ))
365}
366
367/// Build the typed CLI error envelope for a per-entry deserialisation
368/// refusal. Walks the original JSON value to pick out keys not in the
369/// recognised entry-shape vocabulary so the recovery payload carries
370/// `entry_index`, `unknown_keys`, and a nearest-match `suggested`
371/// hint pointing at the recognised mutation key whose name is closest
372/// to the first unknown one (fuzzy-match shared with `memstead_schema`).
373fn build_entry_parse_error(
374    idx: usize,
375    entry_value: &serde_json::Value,
376    parse_err: &serde_json::Error,
377) -> CliError {
378    let known: std::collections::BTreeSet<&str> = [
379        "id",
380        "expected_hash",
381        "auto_hash",
382        "force",
383        "sections",
384        "append_sections",
385        "patch_sections",
386        "metadata",
387        "metadata_unset",
388        "declare_relations",
389        "anchors",
390        "anchors_unset",
391        "note",
392    ]
393    .into_iter()
394    .collect();
395    let mut unknown: Vec<String> = Vec::new();
396    if let Some(map) = entry_value.as_object() {
397        for k in map.keys() {
398            if !known.contains(k.as_str()) {
399                unknown.push(k.clone());
400            }
401        }
402    }
403    // Nearest-match suggestion for the first unknown key against the
404    // recognised mutation-content vocabulary (the keys this plan adds
405    // discipline to). Defaults to the literal vocabulary for callers
406    // with no fuzzy hit.
407    let suggested = unknown
408        .first()
409        .and_then(|u| nearest_recognised_key(u))
410        .map(String::from);
411    let message = if unknown.is_empty() {
412        format!("entry {idx}: invalid shape — {parse_err}")
413    } else {
414        let display = unknown.join(", ");
415        format!(
416            "entry {idx}: unknown field(s) {display} — recognised mutation keys are {:?}",
417            RECOGNISED_MUTATION_KEYS
418        )
419    };
420    let mut details = serde_json::json!({
421        "entry_index": idx,
422        "unknown_keys": unknown,
423        "parser_error": parse_err.to_string(),
424        "recognised_keys": RECOGNISED_MUTATION_KEYS,
425    });
426    if let Some(s) = suggested {
427        details["suggested"] = serde_json::Value::String(s);
428    }
429    CliError::new(ExitKind::Validation, "INVALID_INPUT", message).with_details(details)
430}
431
432/// Pick the recognised mutation-content key whose name is most
433/// similar to `attempted`, by simple substring + prefix scoring. Good
434/// enough for `section_replacements` → `sections`, `meta` →
435/// `metadata`, `declares` → `declare_relations`. Returns `None` for
436/// inputs with no plausible match.
437fn nearest_recognised_key(attempted: &str) -> Option<&'static str> {
438    let lower = attempted.to_lowercase();
439    let mut best: Option<(&'static str, usize)> = None;
440    for &key in RECOGNISED_MUTATION_KEYS {
441        // Score: full-prefix > substring > shared-stem letters.
442        let score = if lower.starts_with(key) || key.starts_with(&lower) {
443            100
444        } else if lower.contains(key) || key.contains(&lower) {
445            80
446        } else {
447            shared_prefix_len(&lower, key) * 4
448        };
449        if score > 0 {
450            best = match best {
451                Some((_, best_score)) if best_score >= score => best,
452                _ => Some((key, score)),
453            };
454        }
455    }
456    best.map(|(k, _)| k)
457}
458
459fn shared_prefix_len(a: &str, b: &str) -> usize {
460    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
461}
462
463#[cfg(test)]
464mod tests {
465    /// The batch entry's own content predicate must answer as the engine's
466    /// does. It is hand-rolled because the hash mode is resolved before the
467    /// engine args exist, so drift between the two is possible and this is
468    /// what catches it: an entry whose only content is a section change must
469    /// be seen as content-changing by both.
470    #[test]
471    fn batch_entry_content_matches_the_engine_predicate() {
472        use memstead_base::UpdateEntityArgs;
473        let mut args = UpdateEntityArgs {
474            id: memstead_base::EntityId("m--e".into()),
475            expected_hash: None,
476            sections: Default::default(),
477            append_sections: Default::default(),
478            patch_sections: Default::default(),
479            sections_unset: Vec::new(),
480            metadata: Default::default(),
481            metadata_unset: Vec::new(),
482            dry_run: false,
483            declare_relations: Vec::new(),
484            anchors: Vec::new(),
485            anchors_unset: Vec::new(),
486            relations_unset: Vec::new(),
487        };
488        assert!(!args.changes_content(), "nothing named changes no content");
489        args.anchors.push(Default::default());
490        assert!(
491            !args.changes_content(),
492            "anchors are outside the content hash and must stay off this side"
493        );
494        args.sections.insert("purpose".into(), "x".into());
495        assert!(args.changes_content(), "a section change is content");
496    }
497
498    /// The batch surface may recognise FEWER keys than the engine (its entry
499    /// payload does not accept `relations_unset`), never more: advertising a
500    /// key the parser rejects is the same lie as omitting one it accepts.
501    #[test]
502    fn batch_recognised_keys_are_a_subset_of_the_engines() {
503        let engine_keys: std::collections::BTreeSet<&str> =
504            memstead_base::engine::error::RECOGNISED_MUTATION_KEYS
505                .iter()
506                .copied()
507                .collect();
508        for key in super::RECOGNISED_MUTATION_KEYS {
509            assert!(
510                engine_keys.contains(key),
511                "batch advertises `{key}`, which the engine does not recognise"
512            );
513        }
514    }
515
516    use super::*;
517
518    /// Per-entry deserialisation refusal carries `entry_index`,
519    /// `unknown_keys`, and a nearest-match `suggested` hint pointing
520    /// at the recognised mutation key whose name is closest to the
521    /// first unknown one. Probe reproducer: `section_replacements`
522    /// → `sections`.
523    #[test]
524    fn entry_parse_error_names_unknown_keys_and_suggests_nearest() {
525        let entry: serde_json::Value = serde_json::json!({
526            "id": "specs--target",
527            "auto_hash": true,
528            "section_replacements": {"identity": "X"},
529        });
530        let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
531        let err = build_entry_parse_error(0, &entry, &parse_err);
532        let details = err.details.expect("details payload must be present");
533        assert_eq!(details["entry_index"].as_u64(), Some(0));
534        let unknown: Vec<String> = details["unknown_keys"]
535            .as_array()
536            .unwrap()
537            .iter()
538            .map(|v| v.as_str().unwrap().to_string())
539            .collect();
540        assert_eq!(unknown, vec!["section_replacements".to_string()]);
541        assert_eq!(details["suggested"].as_str(), Some("sections"));
542        assert_eq!(err.code, "INVALID_INPUT");
543    }
544
545    /// Complement AC: a `meta` → `metadata` fuzzy hit lands.
546    #[test]
547    fn entry_parse_error_suggests_metadata_for_meta_typo() {
548        let entry: serde_json::Value = serde_json::json!({
549            "id": "specs--target",
550            "auto_hash": true,
551            "meta": {"level": "M1"},
552        });
553        let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
554        let err = build_entry_parse_error(7, &entry, &parse_err);
555        let details = err.details.expect("details payload");
556        assert_eq!(details["entry_index"].as_u64(), Some(7));
557        assert_eq!(details["suggested"].as_str(), Some("metadata"));
558    }
559
560    /// Complement AC: documented optional fields (`expected_hash`,
561    /// `auto_hash`, `force`, mutation maps) all parse cleanly under
562    /// `deny_unknown_fields`. Regression check that no documented
563    /// field name became an unknown key by accident.
564    #[test]
565    fn entry_parse_accepts_every_documented_field() {
566        let entry: serde_json::Value = serde_json::json!({
567            "id": "specs--target",
568            "expected_hash": "abc",
569            "auto_hash": false,
570            "force": false,
571            "sections": {"identity": "A"},
572            "append_sections": {"purpose": "B"},
573            "patch_sections": {"identity": {"old": "X", "new": "Y", "all": true}},
574            "metadata": {"level": "M1"},
575            "metadata_unset": ["tags"],
576            "declare_relations": [{"target": "specs--other", "rel_type": "USES"}],
577            "note": "per-entry provenance",
578        });
579        let parsed = serde_json::from_value::<EntryPayload>(entry).expect("must parse");
580        assert_eq!(parsed.id, "specs--target");
581        assert_eq!(parsed.sections.len(), 1);
582        assert_eq!(parsed.declare_relations.len(), 1);
583        assert_eq!(parsed.declare_relations[0].rel_type, "USES");
584        // Per-entry note parses (distinct notes per batch entry).
585        assert_eq!(parsed.note.as_deref(), Some("per-entry provenance"));
586    }
587}