Skip to main content

memstead_cli/commands/
update.rs

1//! `memstead update` — strict-by-default entity update.
2//!
3//! Hash handling offers three opt-ins:
4//!
5//! * **Default (strict).** `--expected-hash <h>` must be supplied. Matches
6//!   MCP's `memstead_update` contract. Safe for scripts, CI, pre-commit hooks.
7//! * **`--auto-hash`.** Refetch the current hash immediately before writing.
8//!   Ergonomic for one-off interactive edits; the user accepts the race window.
9//! * **`--force`.** Skip the hash check entirely. Explicit opt-out.
10//!
11//! Only one of the three may be used per invocation.
12
13use std::path::PathBuf;
14
15use clap::Parser;
16use indexmap::IndexMap;
17use serde::Deserialize;
18
19#[cfg(feature = "mem-repo")]
20use memstead_base::ops::PatchArg;
21use memstead_base::vcs::Actor;
22use memstead_base::{EntityId, UpdateEntityArgs};
23
24use crate::CliError;
25use crate::output::{ExitKind, print_json, print_markdown};
26use crate::setup::{CliContext, CliEngine};
27
28#[derive(Parser, Debug)]
29pub struct Args {
30    /// Full entity ID (e.g. `specs--my-entity`). Required unless `--from` is given.
31    pub id: Option<String>,
32
33    /// Hash from `memstead entity <id>` (the `_hash` field). Required unless
34    /// `--auto-hash` or `--force` is given.
35    #[arg(long = "expected-hash", value_name = "HASH")]
36    pub expected_hash: Option<String>,
37
38    /// Refetch the current hash immediately before writing.
39    /// Convenient for interactive use; accepts the race window between
40    /// the refetch and the write.
41    #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
42    pub auto_hash: bool,
43
44    /// Skip the hash check entirely (explicit overwrite).
45    #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
46    pub force: bool,
47
48    /// Replace section content: repeatable `--section key=value`. Body
49    /// wiki-links must take slug-form (`[[idempotency]]`, not the
50    /// title-case `[[Idempotency]]`) — a non-slug target refuses with
51    /// `INVALID_WIKI_LINK_TARGET` carrying a `proposed_slug` to retry with.
52    #[arg(long = "section", value_name = "KEY=VALUE")]
53    pub sections: Vec<String>,
54
55    /// Append to section content: repeatable `--append key=value`.
56    #[arg(long = "append", value_name = "KEY=VALUE")]
57    pub append: Vec<String>,
58
59    /// Find-and-replace inside a section: repeatable `--patch key=OLD=>NEW`.
60    /// Use `=>` (two chars) as the separator between old and new. Exact match
61    /// of the first occurrence; use `--patch-all` to replace every occurrence.
62    #[arg(long = "patch", value_name = "KEY=OLD=>NEW")]
63    pub patch: Vec<String>,
64
65    /// Replace every occurrence of OLD in the section — sibling of `--patch`.
66    /// Repeatable `--patch-all key=OLD=>NEW`.
67    #[arg(long = "patch-all", value_name = "KEY=OLD=>NEW")]
68    pub patch_all: Vec<String>,
69
70    /// Metadata field: repeatable `--metadata key=value`.
71    #[arg(long = "metadata", value_name = "KEY=VALUE")]
72    pub metadata: Vec<String>,
73
74    /// Remove a metadata field: repeatable `--metadata-unset KEY`. Silent
75    /// no-op if the key is absent; errors on read-only fields (mem/id/type
76    /// plus the engine-stamped created_date/last_modified) or
77    /// schema-required fields.
78    #[arg(long = "metadata-unset", value_name = "KEY")]
79    pub metadata_unset: Vec<String>,
80
81    /// Atomic batched relation declaration: repeatable
82    /// `--declare-relations REL_TYPE:TARGET_ID`. Each entry is
83    /// validated like an individual `memstead relate` call (schema-shape,
84    /// cross-mem policy, target-id grammar) and appended to the
85    /// entity's relations BEFORE the strict wiki-link/relation
86    /// validator runs. Lets the agent add `[[target]]` body
87    /// wiki-links AND declare the backing relation in one
88    /// `memstead update` call without an interleaved `memstead relate`.
89    /// Absent Write-mem targets are auto-stubbed identically to
90    /// `memstead relate`'s add path. Each successful declaration is
91    /// echoed in the response's `relations_declared` (with
92    /// `target_was_stubbed` flagging the auto-stub case).
93    #[arg(long = "declare-relations", value_name = "REL_TYPE:TARGET_ID")]
94    pub declare_relations: Vec<String>,
95
96    /// Provenance anchor: repeatable `--anchor '<json>'`, each a JSON
97    /// object of the anchor shape. Written into the mem-branch anchors
98    /// sidecar in the same commit as the update; a malformed anchor
99    /// refuses `INVALID_ANCHOR`. An update carrying only `--anchor` (no
100    /// section/metadata change) still commits the sidecar. Ignored when
101    /// `--from` is given (the file's `anchors[]` is authoritative).
102    #[arg(long = "anchor", value_name = "JSON")]
103    pub anchors: Vec<String>,
104
105    /// Preview what would change without writing.
106    #[arg(long)]
107    pub dry_run: bool,
108
109    /// JSON file matching MCP `memstead_update` args shape. When set, flags
110    /// above except the hash-mode flags are ignored.
111    #[arg(long = "from", value_name = "FILE")]
112    pub from: Option<PathBuf>,
113
114    /// Agent-authored provenance note (≤280 chars). When
115    /// `[mutations].require_notes = true` a missing note adds a
116    /// `NOTE_MISSING` warning.
117    #[arg(long)]
118    pub note: Option<String>,
119}
120
121/// On-disk JSON payload shape — mirrors MCP `UpdateParams` + hash flags.
122/// `expected_hash` inside the file takes effect only in strict mode.
123#[derive(Debug, Deserialize)]
124#[serde(deny_unknown_fields)]
125struct UpdatePayload {
126    id: String,
127    expected_hash: Option<String>,
128    #[serde(default)]
129    sections: IndexMap<String, String>,
130    #[serde(default)]
131    append_sections: IndexMap<String, String>,
132    #[serde(default)]
133    patch_sections: IndexMap<String, PatchPayload>,
134    #[serde(default)]
135    metadata: IndexMap<String, String>,
136    #[serde(default)]
137    metadata_unset: Vec<String>,
138    #[serde(default)]
139    declare_relations: Vec<DeclareRelationPayload>,
140    /// Provenance anchors — matches the MCP `memstead_update` `anchors[]`
141    /// shape; validated engine-side into a typed `INVALID_ANCHOR` refusal
142    /// on malformed input.
143    #[serde(default)]
144    anchors: Vec<memstead_base::anchor::AnchorInput>,
145    #[serde(default)]
146    dry_run: bool,
147}
148
149#[derive(Debug, Deserialize, Clone)]
150#[serde(deny_unknown_fields)]
151#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
152struct DeclareRelationPayload {
153    /// Target entity id (`mem--slug` or cross-mem form).
154    to: String,
155    /// Relationship type — case-insensitive on input; engine
156    /// canonicalises to UPPER_SNAKE_CASE.
157    rel_type: String,
158    /// Optional per-edge description. Validated against the rel-type's
159    /// `per_edge_description` posture in the engine.
160    #[serde(default)]
161    description: Option<String>,
162}
163
164#[derive(Debug, Deserialize)]
165#[serde(deny_unknown_fields)]
166#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
167struct PatchPayload {
168    old: String,
169    new: String,
170    #[serde(default)]
171    all: bool,
172}
173
174pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
175    let payload = if let Some(ref file) = args.from {
176        let bytes = std::fs::read(file).map_err(|e| {
177            CliError::new(
178                ExitKind::Generic,
179                "INVALID_INPUT",
180                format!("failed to read {}: {e}", file.display()),
181            )
182        })?;
183        let parsed: UpdatePayload = serde_json::from_slice(&bytes).map_err(|e| {
184            CliError::new(
185                ExitKind::Validation,
186                "INVALID_INPUT",
187                format!("invalid JSON in {}: {e}", file.display()),
188            )
189            .with_details(serde_json::json!({
190                "path": file.display().to_string(),
191                "parser_error": e.to_string(),
192            }))
193        })?;
194        parsed
195    } else {
196        let id = args.id.clone().ok_or_else(|| {
197            CliError::new(
198                ExitKind::Validation,
199                "INVALID_INPUT",
200                "missing entity ID (or pass --from <file.json>)",
201            )
202        })?;
203        UpdatePayload {
204            id,
205            expected_hash: args.expected_hash.clone(),
206            sections: parse_kv_list(&args.sections, "--section")?,
207            append_sections: parse_kv_list(&args.append, "--append")?,
208            patch_sections: parse_patch_list_combined(&args.patch, &args.patch_all)?,
209            metadata: parse_kv_list(&args.metadata, "--metadata")?,
210            metadata_unset: args.metadata_unset.clone(),
211            declare_relations: parse_declare_relations(&args.declare_relations)?,
212            anchors: super::create::parse_anchor_list(&args.anchors)?,
213            dry_run: args.dry_run,
214        }
215    };
216
217    let entity_id = EntityId::canonical(&payload.id);
218
219    match ctx.cli_engine()? {
220        #[cfg(feature = "mem-repo")]
221        CliEngine::MemRepo(mut engine) => {
222            let expected_hash = resolve_hash_mem_repo(
223                &engine,
224                &entity_id,
225                payload.expected_hash,
226                args.auto_hash,
227                args.force,
228            )?;
229
230            let patch_sections = payload
231                .patch_sections
232                .into_iter()
233                .map(|(k, v)| {
234                    (
235                        k,
236                        PatchArg {
237                            old: v.old,
238                            new: v.new,
239                            all: v.all,
240                        },
241                    )
242                })
243                .collect();
244
245            let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
246                .declare_relations
247                .iter()
248                .map(|r| memstead_base::ops::RelateArg {
249                    to: EntityId::canonical(&r.to),
250                    rel_type: r.rel_type.clone(),
251                    description: r.description.clone(),
252                })
253                .collect();
254            let update_args = UpdateEntityArgs {
255                anchors: payload.anchors,
256                id: entity_id.clone(),
257                expected_hash: Some(expected_hash),
258                sections: payload.sections,
259                append_sections: payload.append_sections,
260                patch_sections,
261                metadata: payload.metadata,
262                metadata_unset: payload.metadata_unset,
263                dry_run: payload.dry_run,
264                declare_relations,
265                relations_unset: Vec::new(),
266            };
267
268            let result = engine
269                .update_entity_with_ctx(
270                    update_args,
271                    &crate::setup::cli_ctx_with_note(args.note.clone()),
272                )
273                .map_err(CliError::from_engine_op)?;
274            let mem_changed = engine.take_mem_changed_notices();
275
276            if ctx.json {
277                let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
278                super::merge_mem_changed_json(&mut body, &mem_changed);
279                print_json(&body)?;
280            } else {
281                let header = if payload.dry_run {
282                    format!("# Dry-run `{}`", result.id)
283                } else {
284                    format!("# Updated `{}`", result.id)
285                };
286                let sections_line = render_section_mutations(&result.modified_sections);
287                let metadata_line = render_metadata_mutations(&result.modified_metadata);
288                let mut body = format!("{header}\n\n- Title: {}", result.title);
289                if let Some(line) = sections_line {
290                    body.push_str(&format!("\n- Sections: {line}"));
291                }
292                if let Some(line) = metadata_line {
293                    body.push_str(&format!("\n- Metadata: {line}"));
294                }
295                if !result.relations_declared.is_empty() {
296                    let parts: Vec<String> = result
297                        .relations_declared
298                        .iter()
299                        .map(|r| {
300                            let stubbed_tag = if r.target_was_stubbed {
301                                " (stubbed)"
302                            } else {
303                                ""
304                            };
305                            format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
306                        })
307                        .collect();
308                    body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
309                }
310                if !result.orphan_stubs_removed.is_empty() {
311                    let ids: Vec<String> = result
312                        .orphan_stubs_removed
313                        .iter()
314                        .map(|i| i.to_string())
315                        .collect();
316                    body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
317                }
318                if !result.warnings.is_empty() {
319                    let parts: Vec<String> =
320                        result.warnings.iter().map(|w| w.to_string()).collect();
321                    body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
322                }
323                body.push_str(&format!("\n- Hash: `{}`", result.content_hash));
324                body.push_str(&super::render_mem_changed_block(&mem_changed));
325                print_markdown(&body);
326            }
327        }
328        CliEngine::Filesystem(mut engine) => {
329            // The filesystem-mem `memstead_update` surface is intentionally
330            // smaller than mem-repo's: whole-section replacement,
331            // metadata set, and metadata unset are honoured;
332            // append_sections / patch_sections / dry_run are not yet
333            // wired on the filesystem engine. Surface that as a clear
334            // validation error rather than silently dropping the flags.
335            if !payload.append_sections.is_empty() {
336                return Err(CliError::new(
337                    ExitKind::Validation,
338                    "INVALID_INPUT",
339                    "--append is not yet supported on filesystem-mem `memstead update`",
340                )
341                .into());
342            }
343            if !payload.patch_sections.is_empty() {
344                return Err(CliError::new(
345                    ExitKind::Validation,
346                    "INVALID_INPUT",
347                    "--patch / --patch-all are not yet supported on filesystem-mem `memstead update`",
348                )
349                .into());
350            }
351            if payload.dry_run {
352                return Err(CliError::new(
353                    ExitKind::Validation,
354                    "INVALID_INPUT",
355                    "--dry-run is not yet supported on filesystem-mem `memstead update`",
356                )
357                .into());
358            }
359
360            let expected_hash = resolve_hash_filesystem(
361                &engine,
362                &entity_id,
363                payload.expected_hash,
364                args.auto_hash,
365                args.force,
366            )?;
367
368            let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
369                .declare_relations
370                .iter()
371                .map(|r| memstead_base::ops::RelateArg {
372                    to: EntityId::canonical(&r.to),
373                    rel_type: r.rel_type.clone(),
374                    description: r.description.clone(),
375                })
376                .collect();
377            let update_args = UpdateEntityArgs {
378                anchors: payload.anchors,
379                id: entity_id.clone(),
380                expected_hash: Some(expected_hash),
381                sections: payload.sections,
382                // CLI's update surface doesn't accept
383                // append_sections / patch_sections on its wire
384                // today; pass empty.
385                append_sections: IndexMap::new(),
386                patch_sections: IndexMap::new(),
387                metadata: payload.metadata,
388                metadata_unset: payload.metadata_unset,
389                declare_relations,
390                dry_run: false,
391                relations_unset: Vec::new(),
392            };
393            let outcome = engine
394                .update_entity(update_args, Actor::Cli, None, args.note.as_deref())
395                .map_err(CliError::from_engine_op)?;
396
397            if ctx.json {
398                let relations_declared: Vec<serde_json::Value> = outcome
399                    .relations_declared
400                    .iter()
401                    .map(|r| {
402                        serde_json::json!({
403                            "rel_type": r.rel_type,
404                            "target": r.target.to_string(),
405                            "target_was_stubbed": r.target_was_stubbed,
406                        })
407                    })
408                    .collect();
409                print_json(&serde_json::json!({
410                    "id": outcome.id.as_ref(),
411                    "file_path": outcome.file_path,
412                    "_hash": outcome.content_hash,
413                    "modified_sections": outcome.modified_sections.replaced,
414                    "modified_metadata_set": outcome.modified_metadata.set,
415                    "modified_metadata_unset": outcome.modified_metadata.unset,
416                    "relations_declared": relations_declared,
417                    // Engine-emitted warnings (e.g. `NOTE_MISSING` under
418                    // `[mutations].require_notes`) ride the response.
419                    "warnings": outcome.warnings,
420                    "orphan_stubs_removed": outcome
421                        .orphan_stubs_removed
422                        .iter()
423                        .map(|i| i.to_string())
424                        .collect::<Vec<_>>(),
425                }))?;
426            } else {
427                let mut body = format!("# Updated `{}`", outcome.id);
428                if !outcome.modified_sections.replaced.is_empty() {
429                    let parts: Vec<String> = outcome
430                        .modified_sections
431                        .replaced
432                        .iter()
433                        .map(|k| format!("{k} (replaced)"))
434                        .collect();
435                    body.push_str(&format!("\n- Sections: {}", parts.join(", ")));
436                }
437                if !outcome.modified_metadata.set.is_empty()
438                    || !outcome.modified_metadata.unset.is_empty()
439                {
440                    let mut parts = Vec::new();
441                    for k in &outcome.modified_metadata.set {
442                        parts.push(format!("{k} (set)"));
443                    }
444                    for k in &outcome.modified_metadata.unset {
445                        parts.push(format!("{k} (unset)"));
446                    }
447                    body.push_str(&format!("\n- Metadata: {}", parts.join(", ")));
448                }
449                if !outcome.relations_declared.is_empty() {
450                    let parts: Vec<String> = outcome
451                        .relations_declared
452                        .iter()
453                        .map(|r| {
454                            let stubbed_tag = if r.target_was_stubbed {
455                                " (stubbed)"
456                            } else {
457                                ""
458                            };
459                            format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
460                        })
461                        .collect();
462                    body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
463                }
464                if !outcome.orphan_stubs_removed.is_empty() {
465                    let ids: Vec<String> = outcome
466                        .orphan_stubs_removed
467                        .iter()
468                        .map(|i| i.to_string())
469                        .collect();
470                    body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
471                }
472                if !outcome.warnings.is_empty() {
473                    let parts: Vec<String> =
474                        outcome.warnings.iter().map(|w| w.to_string()).collect();
475                    body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
476                }
477                body.push_str(&format!("\n- Hash: `{}`", outcome.content_hash));
478                print_markdown(&body);
479            }
480        }
481    }
482    Ok(())
483}
484
485/// Render `modified_sections` as `identity (replaced), constraints (appended)`.
486/// Returns `None` when nothing was modified, letting the caller omit the line.
487#[cfg(feature = "mem-repo")]
488fn render_section_mutations(m: &memstead_git_branch::ModifiedSections) -> Option<String> {
489    let mut parts = Vec::new();
490    for k in &m.replaced {
491        parts.push(format!("{k} (replaced)"));
492    }
493    for k in &m.appended {
494        parts.push(format!("{k} (appended)"));
495    }
496    for k in &m.patched {
497        parts.push(format!("{k} (patched)"));
498    }
499    if parts.is_empty() {
500        None
501    } else {
502        Some(parts.join(", "))
503    }
504}
505
506/// Render `modified_metadata` as `level (set), tags (unset)`. `None` when empty.
507#[cfg(feature = "mem-repo")]
508fn render_metadata_mutations(m: &memstead_git_branch::ModifiedMetadata) -> Option<String> {
509    let mut parts = Vec::new();
510    for k in &m.set {
511        parts.push(format!("{k} (set)"));
512    }
513    for k in &m.unset {
514        parts.push(format!("{k} (unset)"));
515    }
516    if parts.is_empty() {
517        None
518    } else {
519        Some(parts.join(", "))
520    }
521}
522
523/// Resolve the hash the update will be issued with.
524///
525/// * `--force` and `--auto-hash` both refetch from the engine's in-memory
526///   store. Because the CLI initializes a fresh engine per invocation, the
527///   loaded hash matches the on-disk content as long as no concurrent writer
528///   changed the file between load and update (race window is microseconds).
529///   The two flags exist to encode user intent — `--auto-hash` for "I didn't
530///   bother reading the entity first," `--force` for "I intend to overwrite
531///   regardless of what's there."
532/// * Strict (default) → use the explicit `--expected-hash` / JSON field, else error.
533#[cfg(feature = "mem-repo")]
534fn resolve_hash_mem_repo(
535    engine: &memstead_base::Engine,
536    id: &EntityId,
537    explicit: Option<String>,
538    auto_hash: bool,
539    force: bool,
540) -> anyhow::Result<String> {
541    if auto_hash || force {
542        let entity = engine.get_entity(id).ok_or_else(|| {
543            CliError::new(
544                ExitKind::NotFound,
545                "ENTITY_NOT_FOUND",
546                format!("entity not found: {id}"),
547            )
548            .with_details(serde_json::json!({ "id": id.to_string() }))
549        })?;
550        return Ok(entity.content_hash.clone());
551    }
552    require_explicit_hash(explicit)
553}
554
555/// Filesystem-mem counterpart of [`resolve_hash_mem_repo`]. Same
556/// semantics; differs only in the engine accessor type.
557fn resolve_hash_filesystem(
558    engine: &memstead_base::Engine,
559    id: &EntityId,
560    explicit: Option<String>,
561    auto_hash: bool,
562    force: bool,
563) -> anyhow::Result<String> {
564    if auto_hash || force {
565        let entity = engine.get_entity(id).ok_or_else(|| {
566            CliError::new(
567                ExitKind::NotFound,
568                "ENTITY_NOT_FOUND",
569                format!("entity not found: {id}"),
570            )
571            .with_details(serde_json::json!({ "id": id.to_string() }))
572        })?;
573        return Ok(entity.content_hash.clone());
574    }
575    require_explicit_hash(explicit)
576}
577
578fn require_explicit_hash(explicit: Option<String>) -> anyhow::Result<String> {
579    match explicit {
580        Some(h) if !h.is_empty() => Ok(h),
581        _ => Err(CliError::new(
582            ExitKind::Validation,
583            crate::HASH_FLAG_REQUIRED_CODE,
584            "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
585             or use --auto-hash for one-off interactive updates, or --force to overwrite.",
586        )
587        .into()),
588    }
589}
590
591/// Parse repeatable `--declare-relations REL_TYPE:TARGET_ID` into
592/// the structured payload used downstream. Splits on the FIRST `:`
593/// so the target id can itself contain colons (cross-mem
594/// `[[mem:slug]]` form). The rel-type half must match the
595/// `[A-Za-z][A-Za-z_]*` grammar already used by `memstead relate`;
596/// validation against the workspace's schema vocabulary happens at
597/// the engine layer.
598fn parse_declare_relations(items: &[String]) -> anyhow::Result<Vec<DeclareRelationPayload>> {
599    let mut out = Vec::with_capacity(items.len());
600    for raw in items {
601        let (rel_type, target) = raw.split_once(':').ok_or_else(|| {
602            CliError::new(
603                ExitKind::Validation,
604                "INVALID_INPUT",
605                format!("--declare-relations: expected REL_TYPE:TARGET_ID, got `{raw}`"),
606            )
607        })?;
608        if rel_type.is_empty() || target.is_empty() {
609            return Err(CliError::new(
610                ExitKind::Validation,
611                "INVALID_INPUT",
612                format!(
613                    "--declare-relations: REL_TYPE and TARGET_ID must both be non-empty, got `{raw}`"
614                ),
615            )
616            .into());
617        }
618        out.push(DeclareRelationPayload {
619            to: target.to_string(),
620            rel_type: rel_type.to_string(),
621            description: None,
622        });
623    }
624    Ok(out)
625}
626
627fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
628    let mut out = IndexMap::with_capacity(items.len());
629    for raw in items {
630        let (k, v) = raw.split_once('=').ok_or_else(|| {
631            CliError::new(
632                ExitKind::Validation,
633                "INVALID_INPUT",
634                format!("{flag}: expected KEY=VALUE, got `{raw}`"),
635            )
636        })?;
637        out.insert(k.to_string(), v.to_string());
638    }
639    Ok(out)
640}
641
642fn parse_patch_list_combined(
643    first_only: &[String],
644    all: &[String],
645) -> anyhow::Result<IndexMap<String, PatchPayload>> {
646    let mut out = IndexMap::with_capacity(first_only.len() + all.len());
647    for (items, flag, replace_all) in [(first_only, "--patch", false), (all, "--patch-all", true)] {
648        for raw in items {
649            let (key, rest) = raw.split_once('=').ok_or_else(|| {
650                CliError::new(
651                    ExitKind::Validation,
652                    "INVALID_INPUT",
653                    format!("{flag}: expected KEY=OLD=>NEW, got `{raw}`"),
654                )
655            })?;
656            let (old, new) = rest.split_once("=>").ok_or_else(|| {
657                CliError::new(
658                    ExitKind::Validation,
659                    "INVALID_INPUT",
660                    format!("{flag}: expected KEY=OLD=>NEW (missing `=>`), got `{raw}`"),
661                )
662            })?;
663            if out.contains_key(key) {
664                return Err(CliError::new(
665                    ExitKind::Validation,
666                    "INVALID_INPUT",
667                    format!(
668                        "duplicate patch for section `{key}` -- only one of --patch / --patch-all per section"
669                    ),
670                )
671                .into());
672            }
673            out.insert(
674                key.to_string(),
675                PatchPayload {
676                    old: old.to_string(),
677                    new: new.to_string(),
678                    all: replace_all,
679                },
680            );
681        }
682    }
683    Ok(out)
684}