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];
54
55#[derive(Debug, Deserialize)]
56#[serde(deny_unknown_fields)]
57struct EntryPayload {
58    id: String,
59    #[serde(default)]
60    expected_hash: Option<String>,
61    #[serde(default)]
62    auto_hash: bool,
63    #[serde(default)]
64    force: bool,
65    #[serde(default)]
66    sections: IndexMap<String, String>,
67    #[serde(default)]
68    append_sections: IndexMap<String, String>,
69    #[serde(default)]
70    patch_sections: IndexMap<String, PatchPayload>,
71    #[serde(default)]
72    metadata: IndexMap<String, String>,
73    #[serde(default)]
74    metadata_unset: Vec<String>,
75    /// Inline relations to declare atomically before
76    /// section/metadata mutations — mirrors `memstead_update.declare_relations`
77    /// on the MCP surface. The CLI batch payload aligns with the
78    /// recognised mutation-key set so the empty-mutation guard and
79    /// `EMPTY_UPDATE` envelope cover this shape uniformly.
80    #[serde(default)]
81    declare_relations: Vec<RelationPayload>,
82    /// Agent-authored provenance note for THIS entry's commit — matches
83    /// the MCP mutation shape's `note`. Per-entry: distinct notes across
84    /// batch entries are expressible. Optional; omit for note-less
85    /// entries. (There is no batch-level `--note` flag, so no precedence
86    /// question arises.)
87    #[serde(default)]
88    note: Option<String>,
89}
90
91#[derive(Debug, Deserialize)]
92#[serde(deny_unknown_fields)]
93struct PatchPayload {
94    old: String,
95    new: String,
96    #[serde(default)]
97    all: bool,
98}
99
100#[derive(Debug, Deserialize)]
101#[serde(deny_unknown_fields)]
102struct RelationPayload {
103    /// Full target entity id.
104    to: String,
105    /// Rel-type (UPPER_SNAKE_CASE; engine canonicalises).
106    #[serde(rename = "type")]
107    rel_type: String,
108    #[serde(default)]
109    description: Option<String>,
110}
111
112pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
113    let bytes = std::fs::read(&args.from).map_err(|e| {
114        CliError::new(
115            ExitKind::Generic,
116            "INVALID_INPUT",
117            format!("failed to read {}: {e}", args.from.display()),
118        )
119    })?;
120
121    // Two-phase parse so unknown-key refusals carry per-entry
122    // `entry_index` / `unknown_keys` / `suggested` recovery payloads
123    // instead of just serde's raw "unknown field" line/column text.
124    let envelope: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
125        CliError::new(
126            ExitKind::Validation,
127            "INVALID_INPUT",
128            format!("invalid JSON in {}: {e}", args.from.display()),
129        )
130        .with_details(serde_json::json!({
131            "path": args.from.display().to_string(),
132            "parser_error": e.to_string(),
133        }))
134    })?;
135    let updates_value = envelope
136        .get("updates")
137        .cloned()
138        .unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
139    let updates_array = match &updates_value {
140        serde_json::Value::Array(a) => a.clone(),
141        _ => {
142            return Err(CliError::new(
143                ExitKind::Validation,
144                "INVALID_INPUT",
145                "`updates` must be a JSON array",
146            )
147            .into());
148        }
149    };
150    // Surface top-level unknown keys too (e.g. `update` typo for `updates`).
151    if let serde_json::Value::Object(map) = &envelope {
152        let unknown: Vec<String> = map
153            .keys()
154            .filter(|k| k.as_str() != "updates")
155            .cloned()
156            .collect();
157        if !unknown.is_empty() {
158            return Err(CliError::new(
159                ExitKind::Validation,
160                "INVALID_INPUT",
161                format!(
162                    "unknown top-level key(s) {unknown:?} — only `updates: [...]` is recognised"
163                ),
164            )
165            .with_details(serde_json::json!({
166                "unknown_keys": unknown,
167                "suggested": "updates",
168            }))
169            .into());
170        }
171    }
172
173    if updates_array.is_empty() {
174        return Err(
175            CliError::new(ExitKind::Validation, "INVALID_INPUT", "updates[] is empty").into(),
176        );
177    }
178
179    let mut entries: Vec<EntryPayload> = Vec::with_capacity(updates_array.len());
180    for (idx, entry_value) in updates_array.into_iter().enumerate() {
181        match serde_json::from_value::<EntryPayload>(entry_value.clone()) {
182            Ok(entry) => entries.push(entry),
183            Err(e) => return Err(build_entry_parse_error(idx, &entry_value, &e).into()),
184        }
185    }
186
187    let mut engine = crate::setup::pro_engine(ctx)?;
188
189    let updates: Vec<(UpdateEntityArgs, Option<String>)> = entries
190        .into_iter()
191        .map(|entry| build_update_args(&engine, entry))
192        .collect::<anyhow::Result<Vec<_>>>()?;
193    let result = engine
194        .batch_update(updates, Actor::Cli, None)
195        .map_err(CliError::from_engine_op)?;
196    // Reload-before-op runs inside `batch_update` for every mem the
197    // batch touches; drain any `mem_changed` notice it stashed.
198    let mem_changed = engine.take_mem_changed_notices();
199
200    // A SUCCESSFUL batch renders exactly as before — the structured
201    // result on stdout (`--json`) or the per-entry breakdown (human) —
202    // and exits 0.
203    if result.applied {
204        if ctx.json {
205            let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
206            crate::commands::merge_mem_changed_json(&mut body, &mem_changed);
207            print_json(&body)?;
208        } else {
209            let mut md = render_batch_markdown(&result);
210            md.push_str(&crate::commands::render_mem_changed_block(&mem_changed));
211            print_markdown(&md);
212        }
213        return Ok(());
214    }
215
216    // A FAILED batch (atomic refusal — nothing committed) is surfaced as
217    // the standard error envelope (CLI F12): it carries a top-level
218    // `code` and maps to a non-zero exit code via `ExitKind`, consistent
219    // with single `update` and the documented exit-code table. A script
220    // branching on `$?` (or `--json | jq -r .code`) now detects the
221    // failure without parsing the per-entry envelope. The full result
222    // rides on `details`, so no information is lost. In human mode the
223    // per-entry breakdown still prints on stdout; the error summary
224    // rides stderr. In `--json` mode the single error envelope is the
225    // only thing on stdout, so it stays exactly one JSON document.
226    if !ctx.json {
227        print_markdown(&render_batch_markdown(&result));
228    }
229    Err(batch_refused_error(&result).into())
230}
231
232/// Render the per-entry markdown breakdown for a batch result (success
233/// or failure). Each entry shows a status marker, its id/action, and any
234/// per-entry error code+message; an applied batch appends its commit SHA.
235fn render_batch_markdown(result: &memstead_base::ops::BatchResult) -> String {
236    let header = if result.applied {
237        format!(
238            "# Batch update applied — {} item(s) in one commit",
239            result.succeeded
240        )
241    } else {
242        format!(
243            "# Batch update REFUSED — {} item(s) failed, nothing committed",
244            result.failed
245        )
246    };
247    let mut lines = vec![header, String::new()];
248    for entry in &result.results {
249        let marker = if entry.error.is_some() {
250            "✗"
251        } else if entry.action == "not_applied" {
252            "·"
253        } else {
254            "✓"
255        };
256        let detail = entry
257            .error
258            .as_ref()
259            .map(|e| format!(" — [{}] {}", e.code, e.message))
260            .unwrap_or_default();
261        lines.push(format!(
262            "- {marker} `{}` ({}){}",
263            entry.id, entry.action, detail
264        ));
265    }
266    if result.applied && !result.commit_sha.is_empty() {
267        lines.push(String::new());
268        lines.push(format!("Commit: `{}`", result.commit_sha));
269    }
270    lines.join("\n")
271}
272
273/// Build the error envelope for a refused (atomic) batch. The top-level
274/// `code` is the stable `BATCH_REFUSED` token; the `ExitKind` mirrors the
275/// dominant (refusal-tripping) entry's failure so `$?` matches single
276/// `update` and the documented table (hash mismatch → 4, missing entity /
277/// mem → 3, schema/policy refusal → 5). The full [`BatchResult`] rides
278/// on `details` — per-entry codes stay available without re-running.
279fn batch_refused_error(result: &memstead_base::ops::BatchResult) -> CliError {
280    let dominant = result.results.iter().find(|e| e.error.is_some());
281    let (code, failing_id, message) = match dominant {
282        Some(entry) => {
283            let err = entry.error.as_ref().expect("dominant entry has an error");
284            (err.code.as_str(), entry.id.to_string(), err.message.clone())
285        }
286        None => (
287            "",
288            String::new(),
289            "batch-update refused; nothing committed".to_string(),
290        ),
291    };
292    let kind = batch_refused_exit_kind(code);
293    let summary = format!(
294        "batch-update refused — {} item(s) failed, nothing committed; first failure [{}] on `{}`: {}",
295        result.failed, code, failing_id, message,
296    );
297    CliError::new(kind, "BATCH_REFUSED", summary)
298        .with_details(serde_json::to_value(result).unwrap_or(serde_json::Value::Null))
299}
300
301/// Map the dominant per-entry failure code to the process exit code,
302/// reusing the documented `0/1/3/4/5` taxonomy so a refused batch exits
303/// the same way the equivalent single `memstead update` would. Unrecognised
304/// codes fall to `Validation` (5) — the bucket for schema/policy refusals,
305/// which is what most batch-entry failures are.
306fn batch_refused_exit_kind(code: &str) -> ExitKind {
307    match code {
308        "HASH_MISMATCH" => ExitKind::HashMismatch,
309        "ENTITY_NOT_FOUND" | "UNKNOWN_MEM" => ExitKind::NotFound,
310        _ => ExitKind::Validation,
311    }
312}
313
314/// Map a single JSON entry to the engine's [`UpdateEntityArgs`],
315/// resolving the per-entry hash mode against the live engine: explicit
316/// hash passes through, `auto_hash` reads the entity's current hash
317/// and substitutes it, `force` clears the lock by setting
318/// `expected_hash: None`. The mutually-exclusive contract mirrors
319/// `memstead update`'s clap-level `conflicts_with_all`.
320fn build_update_args(
321    engine: &memstead_base::Engine,
322    entry: EntryPayload,
323) -> anyhow::Result<(UpdateEntityArgs, Option<String>)> {
324    let mode_count =
325        entry.expected_hash.is_some() as u8 + entry.auto_hash as u8 + entry.force as u8;
326    if mode_count == 0 {
327        return Err(CliError::new(
328            ExitKind::Validation,
329            "INVALID_INPUT",
330            format!(
331                "entry `{}`: exactly one of `expected_hash`, `auto_hash`, or `force` must be set",
332                entry.id
333            ),
334        )
335        .into());
336    }
337    if mode_count > 1 {
338        return Err(CliError::new(
339            ExitKind::Validation,
340            "INVALID_INPUT",
341            format!(
342                "entry `{}`: `expected_hash`, `auto_hash`, and `force` are mutually exclusive",
343                entry.id
344            ),
345        )
346        .into());
347    }
348
349    // Per-entry provenance note rides alongside the args to the engine.
350    let note = entry.note.clone();
351    let id = EntityId::canonical(&entry.id);
352    let expected_hash = if entry.force {
353        None
354    } else if entry.auto_hash {
355        // Missing-entity case falls through with `expected_hash: None`
356        // so the engine surfaces a typed `ENTITY_NOT_FOUND` for this
357        // entry. Under atomic semantics that refuses the whole batch
358        // (nothing commits) with this entry named in the result.
359        engine.get_entity(&id).map(|e| e.content_hash.clone())
360    } else {
361        entry.expected_hash
362    };
363
364    let patch_sections = entry
365        .patch_sections
366        .into_iter()
367        .map(|(k, v)| {
368            (
369                k,
370                PatchArg {
371                    old: v.old,
372                    new: v.new,
373                    all: v.all,
374                },
375            )
376        })
377        .collect();
378
379    let declare_relations = entry
380        .declare_relations
381        .into_iter()
382        .map(|r| RelateArg {
383            rel_type: r.rel_type,
384            to: EntityId::canonical(&r.to),
385            description: r.description,
386        })
387        .collect();
388
389    Ok((
390        UpdateEntityArgs {
391            id,
392            expected_hash,
393            sections: entry.sections,
394            append_sections: entry.append_sections,
395            patch_sections,
396            metadata: entry.metadata,
397            metadata_unset: entry.metadata_unset,
398            declare_relations,
399            dry_run: false,
400            relations_unset: Vec::new(),
401        },
402        note,
403    ))
404}
405
406/// Build the typed CLI error envelope for a per-entry deserialisation
407/// refusal. Walks the original JSON value to pick out keys not in the
408/// recognised entry-shape vocabulary so the recovery payload carries
409/// `entry_index`, `unknown_keys`, and a nearest-match `suggested`
410/// hint pointing at the recognised mutation key whose name is closest
411/// to the first unknown one (fuzzy-match shared with `memstead_schema`).
412fn build_entry_parse_error(
413    idx: usize,
414    entry_value: &serde_json::Value,
415    parse_err: &serde_json::Error,
416) -> CliError {
417    let known: std::collections::BTreeSet<&str> = [
418        "id",
419        "expected_hash",
420        "auto_hash",
421        "force",
422        "sections",
423        "append_sections",
424        "patch_sections",
425        "metadata",
426        "metadata_unset",
427        "declare_relations",
428        "note",
429    ]
430    .into_iter()
431    .collect();
432    let mut unknown: Vec<String> = Vec::new();
433    if let Some(map) = entry_value.as_object() {
434        for k in map.keys() {
435            if !known.contains(k.as_str()) {
436                unknown.push(k.clone());
437            }
438        }
439    }
440    // Nearest-match suggestion for the first unknown key against the
441    // recognised mutation-content vocabulary (the keys this plan adds
442    // discipline to). Defaults to the literal vocabulary for callers
443    // with no fuzzy hit.
444    let suggested = unknown
445        .first()
446        .and_then(|u| nearest_recognised_key(u))
447        .map(String::from);
448    let message = if unknown.is_empty() {
449        format!("entry {idx}: invalid shape — {parse_err}")
450    } else {
451        let display = unknown.join(", ");
452        format!(
453            "entry {idx}: unknown field(s) {display} — recognised mutation keys are {:?}",
454            RECOGNISED_MUTATION_KEYS
455        )
456    };
457    let mut details = serde_json::json!({
458        "entry_index": idx,
459        "unknown_keys": unknown,
460        "parser_error": parse_err.to_string(),
461        "recognised_keys": RECOGNISED_MUTATION_KEYS,
462    });
463    if let Some(s) = suggested {
464        details["suggested"] = serde_json::Value::String(s);
465    }
466    CliError::new(ExitKind::Validation, "INVALID_INPUT", message).with_details(details)
467}
468
469/// Pick the recognised mutation-content key whose name is most
470/// similar to `attempted`, by simple substring + prefix scoring. Good
471/// enough for `section_replacements` → `sections`, `meta` →
472/// `metadata`, `declares` → `declare_relations`. Returns `None` for
473/// inputs with no plausible match.
474fn nearest_recognised_key(attempted: &str) -> Option<&'static str> {
475    let lower = attempted.to_lowercase();
476    let mut best: Option<(&'static str, usize)> = None;
477    for &key in RECOGNISED_MUTATION_KEYS {
478        // Score: full-prefix > substring > shared-stem letters.
479        let score = if lower.starts_with(key) || key.starts_with(&lower) {
480            100
481        } else if lower.contains(key) || key.contains(&lower) {
482            80
483        } else {
484            shared_prefix_len(&lower, key) * 4
485        };
486        if score > 0 {
487            best = match best {
488                Some((_, best_score)) if best_score >= score => best,
489                _ => Some((key, score)),
490            };
491        }
492    }
493    best.map(|(k, _)| k)
494}
495
496fn shared_prefix_len(a: &str, b: &str) -> usize {
497    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    /// Per-entry deserialisation refusal carries `entry_index`,
505    /// `unknown_keys`, and a nearest-match `suggested` hint pointing
506    /// at the recognised mutation key whose name is closest to the
507    /// first unknown one. Probe reproducer: `section_replacements`
508    /// → `sections`.
509    #[test]
510    fn entry_parse_error_names_unknown_keys_and_suggests_nearest() {
511        let entry: serde_json::Value = serde_json::json!({
512            "id": "specs--target",
513            "auto_hash": true,
514            "section_replacements": {"identity": "X"},
515        });
516        let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
517        let err = build_entry_parse_error(0, &entry, &parse_err);
518        let details = err.details.expect("details payload must be present");
519        assert_eq!(details["entry_index"].as_u64(), Some(0));
520        let unknown: Vec<String> = details["unknown_keys"]
521            .as_array()
522            .unwrap()
523            .iter()
524            .map(|v| v.as_str().unwrap().to_string())
525            .collect();
526        assert_eq!(unknown, vec!["section_replacements".to_string()]);
527        assert_eq!(details["suggested"].as_str(), Some("sections"));
528        assert_eq!(err.code, "INVALID_INPUT");
529    }
530
531    /// Complement AC: a `meta` → `metadata` fuzzy hit lands.
532    #[test]
533    fn entry_parse_error_suggests_metadata_for_meta_typo() {
534        let entry: serde_json::Value = serde_json::json!({
535            "id": "specs--target",
536            "auto_hash": true,
537            "meta": {"level": "M1"},
538        });
539        let parse_err = serde_json::from_value::<EntryPayload>(entry.clone()).unwrap_err();
540        let err = build_entry_parse_error(7, &entry, &parse_err);
541        let details = err.details.expect("details payload");
542        assert_eq!(details["entry_index"].as_u64(), Some(7));
543        assert_eq!(details["suggested"].as_str(), Some("metadata"));
544    }
545
546    /// Complement AC: documented optional fields (`expected_hash`,
547    /// `auto_hash`, `force`, mutation maps) all parse cleanly under
548    /// `deny_unknown_fields`. Regression check that no documented
549    /// field name became an unknown key by accident.
550    #[test]
551    fn entry_parse_accepts_every_documented_field() {
552        let entry: serde_json::Value = serde_json::json!({
553            "id": "specs--target",
554            "expected_hash": "abc",
555            "auto_hash": false,
556            "force": false,
557            "sections": {"identity": "A"},
558            "append_sections": {"purpose": "B"},
559            "patch_sections": {"identity": {"old": "X", "new": "Y", "all": true}},
560            "metadata": {"level": "M1"},
561            "metadata_unset": ["tags"],
562            "declare_relations": [{"to": "specs--other", "type": "USES"}],
563            "note": "per-entry provenance",
564        });
565        let parsed = serde_json::from_value::<EntryPayload>(entry).expect("must parse");
566        assert_eq!(parsed.id, "specs--target");
567        assert_eq!(parsed.sections.len(), 1);
568        assert_eq!(parsed.declare_relations.len(), 1);
569        assert_eq!(parsed.declare_relations[0].rel_type, "USES");
570        // Per-entry note parses (distinct notes per batch entry).
571        assert_eq!(parsed.note.as_deref(), Some("per-entry provenance"));
572    }
573}