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