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