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, args.dry_run);
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(
190            "update",
191            &result,
192            args.dry_run,
193        ));
194    }
195    Err(super::batch::batch_refused_error("update", &result).into())
196}
197
198/// Map a single JSON entry to the engine's [`UpdateEntityArgs`],
199/// resolving the per-entry hash mode against the live engine: explicit
200/// hash passes through, `auto_hash` reads the entity's current hash
201/// and substitutes it, `force` clears the lock by setting
202/// `expected_hash: None`. The mutually-exclusive contract mirrors
203/// `memstead update`'s clap-level `conflicts_with_all`.
204fn build_update_args(
205    engine: &memstead_base::Engine,
206    entry: EntryPayload,
207) -> anyhow::Result<(UpdateEntityArgs, Option<String>)> {
208    let mode_count =
209        entry.expected_hash.is_some() as u8 + entry.auto_hash as u8 + entry.force as u8;
210    if mode_count == 0 {
211        return Err(CliError::new(
212            ExitKind::Validation,
213            "INVALID_INPUT",
214            format!(
215                "entry `{}`: exactly one of `expected_hash`, `auto_hash`, or `force` must be set",
216                entry.id
217            ),
218        )
219        .into());
220    }
221    if mode_count > 1 {
222        return Err(CliError::new(
223            ExitKind::Validation,
224            "INVALID_INPUT",
225            format!(
226                "entry `{}`: `expected_hash`, `auto_hash`, and `force` are mutually exclusive",
227                entry.id
228            ),
229        )
230        .into());
231    }
232
233    // Per-entry provenance note rides alongside the args to the engine.
234    let note = entry.note.clone();
235    let id = EntityId::canonical(&entry.id);
236    let expected_hash = if entry.force {
237        None
238    } else if entry.auto_hash {
239        // Missing-entity case falls through with `expected_hash: None`
240        // so the engine surfaces a typed `ENTITY_NOT_FOUND` for this
241        // entry. Under atomic semantics that refuses the whole batch
242        // (nothing commits) with this entry named in the result.
243        engine.get_entity(&id).map(|e| e.content_hash.clone())
244    } else {
245        entry.expected_hash
246    };
247
248    let patch_sections = entry
249        .patch_sections
250        .into_iter()
251        .map(|(k, v)| {
252            (
253                k,
254                PatchArg {
255                    old: v.old,
256                    new: v.new,
257                    all: v.all,
258                },
259            )
260        })
261        .collect();
262
263    let declare_relations = entry
264        .declare_relations
265        .into_iter()
266        .map(|r| RelateArg {
267            rel_type: r.rel_type,
268            to: EntityId::canonical(&r.to),
269            description: r.description,
270        })
271        .collect();
272
273    Ok((
274        UpdateEntityArgs {
275            anchors: entry.anchors,
276            anchors_unset: entry.anchors_unset,
277            id,
278            expected_hash,
279            sections: entry.sections,
280            append_sections: entry.append_sections,
281            patch_sections,
282            metadata: entry.metadata,
283            metadata_unset: entry.metadata_unset,
284            declare_relations,
285            dry_run: false,
286            relations_unset: Vec::new(),
287        },
288        note,
289    ))
290}
291
292/// Build the typed CLI error envelope for a per-entry deserialisation
293/// refusal. Walks the original JSON value to pick out keys not in the
294/// recognised entry-shape vocabulary so the recovery payload carries
295/// `entry_index`, `unknown_keys`, and a nearest-match `suggested`
296/// hint pointing at the recognised mutation key whose name is closest
297/// to the first unknown one (fuzzy-match shared with `memstead_schema`).
298fn build_entry_parse_error(
299    idx: usize,
300    entry_value: &serde_json::Value,
301    parse_err: &serde_json::Error,
302) -> CliError {
303    let known: std::collections::BTreeSet<&str> = [
304        "id",
305        "expected_hash",
306        "auto_hash",
307        "force",
308        "sections",
309        "append_sections",
310        "patch_sections",
311        "metadata",
312        "metadata_unset",
313        "declare_relations",
314        "note",
315    ]
316    .into_iter()
317    .collect();
318    let mut unknown: Vec<String> = Vec::new();
319    if let Some(map) = entry_value.as_object() {
320        for k in map.keys() {
321            if !known.contains(k.as_str()) {
322                unknown.push(k.clone());
323            }
324        }
325    }
326    // Nearest-match suggestion for the first unknown key against the
327    // recognised mutation-content vocabulary (the keys this plan adds
328    // discipline to). Defaults to the literal vocabulary for callers
329    // with no fuzzy hit.
330    let suggested = unknown
331        .first()
332        .and_then(|u| nearest_recognised_key(u))
333        .map(String::from);
334    let message = if unknown.is_empty() {
335        format!("entry {idx}: invalid shape — {parse_err}")
336    } else {
337        let display = unknown.join(", ");
338        format!(
339            "entry {idx}: unknown field(s) {display} — recognised mutation keys are {:?}",
340            RECOGNISED_MUTATION_KEYS
341        )
342    };
343    let mut details = serde_json::json!({
344        "entry_index": idx,
345        "unknown_keys": unknown,
346        "parser_error": parse_err.to_string(),
347        "recognised_keys": RECOGNISED_MUTATION_KEYS,
348    });
349    if let Some(s) = suggested {
350        details["suggested"] = serde_json::Value::String(s);
351    }
352    CliError::new(ExitKind::Validation, "INVALID_INPUT", message).with_details(details)
353}
354
355/// Pick the recognised mutation-content key whose name is most
356/// similar to `attempted`, by simple substring + prefix scoring. Good
357/// enough for `section_replacements` → `sections`, `meta` →
358/// `metadata`, `declares` → `declare_relations`. Returns `None` for
359/// inputs with no plausible match.
360fn nearest_recognised_key(attempted: &str) -> Option<&'static str> {
361    let lower = attempted.to_lowercase();
362    let mut best: Option<(&'static str, usize)> = None;
363    for &key in RECOGNISED_MUTATION_KEYS {
364        // Score: full-prefix > substring > shared-stem letters.
365        let score = if lower.starts_with(key) || key.starts_with(&lower) {
366            100
367        } else if lower.contains(key) || key.contains(&lower) {
368            80
369        } else {
370            shared_prefix_len(&lower, key) * 4
371        };
372        if score > 0 {
373            best = match best {
374                Some((_, best_score)) if best_score >= score => best,
375                _ => Some((key, score)),
376            };
377        }
378    }
379    best.map(|(k, _)| k)
380}
381
382fn shared_prefix_len(a: &str, b: &str) -> usize {
383    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    /// Per-entry deserialisation refusal carries `entry_index`,
391    /// `unknown_keys`, and a nearest-match `suggested` hint pointing
392    /// at the recognised mutation key whose name is closest to the
393    /// first unknown one. Probe reproducer: `section_replacements`
394    /// → `sections`.
395    #[test]
396    fn entry_parse_error_names_unknown_keys_and_suggests_nearest() {
397        let entry: serde_json::Value = serde_json::json!({
398            "id": "specs--target",
399            "auto_hash": true,
400            "section_replacements": {"identity": "X"},
401        });
402        let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
403        let err = build_entry_parse_error(0, &entry, &parse_err);
404        let details = err.details.expect("details payload must be present");
405        assert_eq!(details["entry_index"].as_u64(), Some(0));
406        let unknown: Vec<String> = details["unknown_keys"]
407            .as_array()
408            .unwrap()
409            .iter()
410            .map(|v| v.as_str().unwrap().to_string())
411            .collect();
412        assert_eq!(unknown, vec!["section_replacements".to_string()]);
413        assert_eq!(details["suggested"].as_str(), Some("sections"));
414        assert_eq!(err.code, "INVALID_INPUT");
415    }
416
417    /// Complement AC: a `meta` → `metadata` fuzzy hit lands.
418    #[test]
419    fn entry_parse_error_suggests_metadata_for_meta_typo() {
420        let entry: serde_json::Value = serde_json::json!({
421            "id": "specs--target",
422            "auto_hash": true,
423            "meta": {"level": "M1"},
424        });
425        let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
426        let err = build_entry_parse_error(7, &entry, &parse_err);
427        let details = err.details.expect("details payload");
428        assert_eq!(details["entry_index"].as_u64(), Some(7));
429        assert_eq!(details["suggested"].as_str(), Some("metadata"));
430    }
431
432    /// Complement AC: documented optional fields (`expected_hash`,
433    /// `auto_hash`, `force`, mutation maps) all parse cleanly under
434    /// `deny_unknown_fields`. Regression check that no documented
435    /// field name became an unknown key by accident.
436    #[test]
437    fn entry_parse_accepts_every_documented_field() {
438        let entry: serde_json::Value = serde_json::json!({
439            "id": "specs--target",
440            "expected_hash": "abc",
441            "auto_hash": false,
442            "force": false,
443            "sections": {"identity": "A"},
444            "append_sections": {"purpose": "B"},
445            "patch_sections": {"identity": {"old": "X", "new": "Y", "all": true}},
446            "metadata": {"level": "M1"},
447            "metadata_unset": ["tags"],
448            "declare_relations": [{"to": "specs--other", "type": "USES"}],
449            "note": "per-entry provenance",
450        });
451        let parsed = serde_json::from_value::<EntryPayload>(entry).expect("must parse");
452        assert_eq!(parsed.id, "specs--target");
453        assert_eq!(parsed.sections.len(), 1);
454        assert_eq!(parsed.declare_relations.len(), 1);
455        assert_eq!(parsed.declare_relations[0].rel_type, "USES");
456        // Per-entry note parses (distinct notes per batch entry).
457        assert_eq!(parsed.note.as_deref(), Some("per-entry provenance"));
458    }
459}