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