Skip to main content

patchloom/ops/doc/
mod.rs

1//! Document format ops (JSON/YAML/TOML): detect, parse, serialize, preserve.
2//!
3//! size-waiver: accepted single-domain bulk (policy #1408). Format detect,
4//! multi-document YAML parse/write (#1718), and CST-preserving serialize
5//! entrypoints are one document surface; do not split for LOC alone.
6
7use crate::selector;
8use anyhow::Context;
9use serde::Deserialize;
10use std::borrow::Cow;
11use std::collections::HashSet;
12use std::path::Path;
13
14mod navigate;
15mod preserve;
16pub mod query;
17mod toml_preserve;
18mod yaml_cst;
19mod yaml_splice;
20
21use navigate::reject_blank_merge_overlay;
22pub use navigate::{
23    deep_merge, delete_at_selector, delete_where, move_at_path, navigate_mut, set_at_path,
24    update_matching,
25};
26use toml_preserve::apply_value_diff;
27use yaml_cst::{apply_yaml_mapping_diff, apply_yaml_sequence_diff, try_remove_subsequence};
28
29pub use yaml_splice::needs_yaml_quoting;
30
31#[derive(Debug, Clone, Copy)]
32pub enum FileFormat {
33    Json,
34    Yaml,
35    Toml,
36}
37
38pub fn detect_format(path: &str) -> anyhow::Result<FileFormat> {
39    match Path::new(path).extension().and_then(|e| e.to_str()) {
40        Some("json") => Ok(FileFormat::Json),
41        Some("yaml" | "yml") => Ok(FileFormat::Yaml),
42        Some("toml") => Ok(FileFormat::Toml),
43        Some(ext) => Err(crate::exit::InvalidInputError {
44            msg: format!(
45                "unsupported file extension: .{ext} (supported: .json, .yaml, .yml, .toml)"
46            ),
47        }
48        .into()),
49        None => Err(crate::exit::InvalidInputError {
50            msg: "file has no extension; doc commands require .json, .yaml, .yml, or .toml".into(),
51        }
52        .into()),
53    }
54}
55
56pub fn serialize_value(value: &serde_json::Value, format: &FileFormat) -> anyhow::Result<String> {
57    match format {
58        FileFormat::Json => {
59            let mut s = serde_json::to_string_pretty(value)?;
60            s.push('\n');
61            Ok(s)
62        }
63        FileFormat::Yaml => Ok(serde_yaml_ng::to_string(value)?),
64        FileFormat::Toml => {
65            let s = toml_edit::ser::to_string_pretty(value).map_err(|e| {
66                anyhow::Error::new(crate::exit::InvalidInputError {
67                    msg: format!("TOML serialization error: {e}"),
68                })
69            })?;
70            Ok(s)
71        }
72    }
73}
74
75/// Serialize a value back to its original format, preserving comments and
76/// formatting for TOML and YAML files.
77///
78/// For TOML, the original text is re-parsed with `toml_edit::DocumentMut`
79/// (which retains comments and whitespace), and only the paths that differ
80/// between `old_value` and `new_value` are updated.  Untouched keys keep
81/// their original formatting, inline comments, and section ordering.
82///
83/// For YAML, the original text is re-parsed with `yaml_edit::Document`
84/// (a Rowan-based CST that retains comments and whitespace), and only the
85/// paths that differ between `old_value` and `new_value` are updated.
86///
87/// JSON falls through to [`serialize_value`] (JSON has no comments).
88/// True when write text changed presentation style (e.g. YAML block-sequence
89/// indent collapsed) while values may still be correct (#2070 honesty).
90///
91/// Agents must not claim a pure surgical text edit when this is true.
92pub fn presentation_style_changed(original: &str, new_text: &str, format: &FileFormat) -> bool {
93    if original == new_text {
94        return false;
95    }
96    match format {
97        FileFormat::Yaml => {
98            yaml_block_sequence_style_marks(original) != yaml_block_sequence_style_marks(new_text)
99                || yaml_alias_identity_counts(original) != yaml_alias_identity_counts(new_text)
100        }
101        // JSON/TOML pretty-print drift is not flagged here (comment/order
102        // preservation paths already minimize noise).
103        FileFormat::Json | FileFormat::Toml => false,
104    }
105}
106
107/// Path-based presentation honesty for library, plan/tx, and CLI doc writes.
108///
109/// Single entry for surfaces that know a path string but not a pre-parsed
110/// [`FileFormat`]. Unknown/unsupported formats return false (no false alarms).
111pub fn style_changed_for_path(path: &str, original: &str, new_text: &str) -> bool {
112    let Ok(fmt) = detect_format(path) else {
113        return false;
114    };
115    presentation_style_changed(original, new_text, &fmt)
116}
117
118/// Counts of `&name`, `*name`, and `<<:` tokens, ignoring full-line and
119/// trailing comments so comment-only edits do not flag style.
120fn yaml_alias_identity_counts(text: &str) -> (usize, usize, usize) {
121    let mut anchors = 0usize;
122    let mut aliases = 0usize;
123    let mut merges = 0usize;
124    for line in text.lines() {
125        let code = yaml_line_without_comment(line);
126        anchors += count_yaml_prefixed_idents(code, '&');
127        aliases += count_yaml_prefixed_idents(code, '*');
128        merges += code.matches("<<:").count();
129    }
130    (anchors, aliases, merges)
131}
132
133fn yaml_line_without_comment(line: &str) -> &str {
134    let trimmed = line.trim_start();
135    if trimmed.starts_with('#') {
136        return "";
137    }
138    match line.find(" #") {
139        Some(i) => &line[..i],
140        None => line,
141    }
142}
143
144fn count_yaml_prefixed_idents(s: &str, prefix: char) -> usize {
145    let chars: Vec<char> = s.chars().collect();
146    let mut n = 0usize;
147    let mut i = 0usize;
148    while i < chars.len() {
149        if chars[i] == prefix {
150            let next_ok = chars
151                .get(i + 1)
152                .is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_');
153            if next_ok {
154                n += 1;
155                i += 2;
156                while i < chars.len()
157                    && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '-')
158                {
159                    i += 1;
160                }
161                continue;
162            }
163        }
164        i += 1;
165    }
166    n
167}
168
169/// `(dash_indent, spaces_after_dash)` for every block-sequence entry.
170/// Post-dash offset catches `-   name:` vs `- name:` (#2276).
171fn yaml_block_sequence_style_marks(text: &str) -> std::collections::BTreeSet<(usize, usize)> {
172    text.lines()
173        .filter_map(|line| {
174            let trimmed = line.trim_start();
175            let dash_indent = line.len() - trimmed.len();
176            if trimmed == "-" {
177                return Some((dash_indent, 0));
178            }
179            let rest = trimmed.strip_prefix('-')?;
180            if rest.starts_with(' ') || rest.starts_with('\t') {
181                let pad = rest.len() - rest.trim_start().len();
182                Some((dash_indent, pad))
183            } else {
184                None
185            }
186        })
187        .collect()
188}
189
190pub fn serialize_value_preserving(
191    original_content: &str,
192    old_value: &serde_json::Value,
193    new_value: &serde_json::Value,
194    format: &FileFormat,
195) -> anyhow::Result<String> {
196    match format {
197        FileFormat::Toml => {
198            let mut doc: toml_edit::DocumentMut = toml_source_for_parse(original_content)
199                .parse()
200                .map_err(|e| {
201                anyhow::Error::new(crate::exit::ParseErrorError {
202                    msg: format!("TOML re-parse for comment preservation: {e}"),
203                })
204            })?;
205            apply_value_diff(doc.as_item_mut(), old_value, new_value);
206            Ok(restore_toml_file_eol(original_content, doc.to_string()))
207        }
208        FileFormat::Yaml => {
209            // Multi-document streams must stay multi-document on write. Falling
210            // through to single-doc serialize turns `---` docs into a YAML
211            // sequence (`- item:`), which breaks kubectl/kustomize manifests.
212            if is_multi_document_yaml(original_content) {
213                return serialize_multi_document_yaml(original_content, old_value, new_value);
214            }
215            if let Some(result) = try_preserve_yaml(original_content, old_value, new_value)? {
216                return Ok(result);
217            }
218            // Fall back to non-preserving (with hoisted comments).
219            if old_value == new_value {
220                Ok(original_content.to_string())
221            } else {
222                let body = serialize_value(new_value, format)?;
223                Ok(preserve::hoist_comments(original_content, &body))
224            }
225        }
226        // JSON has no comments; return the original text when unchanged
227        // to avoid spurious formatting diffs (e.g. array compaction changes).
228        _ => {
229            if old_value == new_value {
230                Ok(original_content.to_string())
231            } else {
232                serialize_value(new_value, format)
233            }
234        }
235    }
236}
237
238/// Clean up whitespace artifacts left by `yaml_edit` CST mutations.
239///
240/// `Mapping::remove()` can leave trailing whitespace on lines and may
241/// drop the final newline. This trims each line's trailing spaces and
242/// ensures the output ends with exactly one newline.
243///
244/// Line endings match the input (`detect_eol`) so Windows CRLF YAML is not
245/// rewritten to LF by the CST preserve path.
246fn cleanup_yaml_cst_whitespace(text: &str) -> String {
247    let eol = crate::write::detect_eol(text);
248    let mut result: String = text
249        .lines()
250        .map(|line| line.trim_end())
251        .collect::<Vec<_>>()
252        .join(eol);
253    // Ensure a final newline (LF or CRLF matching input).
254    if result.is_empty() || !result.ends_with('\n') {
255        result.push_str(eol);
256    }
257    result
258}
259
260/// Fix broken block-mapping indentation left by `yaml_edit` CST removals.
261///
262/// When `Mapping::remove()` deletes an entry in a block mapping, the
263/// whitespace tokens surrounding the removed key may be absorbed by the
264/// adjacent entry, giving it extra leading spaces. For example, removing
265/// `name` from:
266///
267/// ```yaml
268/// app:
269///   name: "my-app"
270///   version: "1.0.0"
271///   port: "8080"
272/// ```
273///
274/// can produce:
275///
276/// ```yaml
277/// app:
278///     version: "1.0.0"
279///   port: "8080"
280/// ```
281///
282/// or (when the middle key is removed):
283///
284/// ```yaml
285/// app:
286///   name: "my-app"
287///     port: "8080"
288/// ```
289///
290/// This function detects entries whose indentation exceeds their siblings
291/// in the same block mapping and normalizes them to match.
292#[cfg(test)]
293fn fix_yaml_block_indentation(text: &str) -> String {
294    fix_yaml_block_indentation_with_original(None, text)
295}
296
297fn fix_yaml_block_indentation_with_original(original: Option<&str>, text: &str) -> String {
298    let lines: Vec<&str> = text.lines().collect();
299    let mut result: Vec<String> = Vec::with_capacity(lines.len());
300    // Probe once per finalize. Per-line `original.lines()` is O(n²) on CST retry.
301    let original_trim_ends: Option<HashSet<&str>> =
302        original.map(|orig| orig.lines().map(str::trim_end).collect());
303
304    for i in 0..lines.len() {
305        let line = lines[i];
306        let trimmed = line.trim_start();
307
308        // Skip empty lines; comments are handled below.
309        if trimmed.is_empty() {
310            result.push(line.to_string());
311            continue;
312        }
313
314        // Fix comment lines: a comment that is indented more than the next
315        // mapping entry at the same level has orphaned whitespace from a
316        // removed sibling.  Re-indent it to match the next entry.
317        if trimmed.starts_with('#') {
318            let comment_indent = line.len() - trimmed.len();
319            if comment_indent > 0
320                && let Some(next_indent) = next_entry_indent(&lines, i)
321                && next_indent < comment_indent
322            {
323                result.push(format!("{}{}", " ".repeat(next_indent), trimmed));
324                continue;
325            }
326            result.push(line.to_string());
327            continue;
328        }
329
330        let indent = line.len() - trimmed.len();
331        // CST remove of `<<` on a dash line leaves `-     name:`. Collapse
332        // extra spaces after `-` only on lines the original did not already
333        // contain. Pre-existing `-   name:` is style, not an artifact (#2276).
334        if is_yaml_sequence_item(trimmed)
335            && let Some(rest) = trimmed.strip_prefix('-')
336        {
337            let rest_trim = rest.trim_start();
338            if rest.len() - rest_trim.len() > 1
339                && (rest_trim.contains(": ") || rest_trim.ends_with(':'))
340                && !is_yaml_sequence_item(rest_trim)
341                && original_trim_ends
342                    .as_ref()
343                    .is_some_and(|orig| !orig.contains(line.trim_end()))
344            {
345                result.push(format!("{}- {rest_trim}", " ".repeat(indent)));
346                continue;
347            }
348        }
349        // `- name: A` is a sequence item, not a mapping sibling of `value:`.
350        let is_mapping_entry = indent > 0
351            && !is_yaml_sequence_item(trimmed)
352            && (trimmed.contains(": ") || trimmed.ends_with(':'));
353
354        if is_mapping_entry
355            && let Some(expected) = expected_sibling_indent(&lines, i, indent)
356            && expected < indent
357        {
358            result.push(format!("{}{}", " ".repeat(expected), trimmed));
359            continue;
360        }
361
362        result.push(line.to_string());
363    }
364
365    let eol = crate::write::detect_eol(text);
366    let mut out = result.join(eol);
367    if text.ends_with('\n') && !out.ends_with('\n') {
368        out.push_str(eol);
369    }
370    out
371}
372
373/// After a block sequence item is emptied, yaml-edit 0.3 `Sequence::set`
374/// of `{}` / `[]` can drop the item newline and glue the next sibling
375/// (`- {}  - name: B`, or `- {}- name:` at document root). Split that
376/// so semantic_eq accepts the CST instead of dumping (#2274 / #2275).
377fn repair_glued_block_sequence_items(text: &str) -> String {
378    let eol = crate::write::detect_eol(text);
379    let mut lines: Vec<String> = Vec::new();
380    for line in text.lines() {
381        let mut remaining = line.to_string();
382        loop {
383            match split_glued_block_sequence_line(&remaining) {
384                Some((first, second)) => {
385                    lines.push(first);
386                    remaining = second;
387                }
388                None => {
389                    lines.push(remaining);
390                    break;
391                }
392            }
393        }
394    }
395    let mut out = lines.join(eol);
396    if text.ends_with('\n') && !out.ends_with('\n') {
397        out.push_str(eol);
398    }
399    out
400}
401
402fn split_glued_block_sequence_line(line: &str) -> Option<(String, String)> {
403    let indent_len = line.len() - line.trim_start().len();
404    let trimmed = &line[indent_len..];
405    let after_dash = trimmed.strip_prefix('-')?;
406    let pad_len = after_dash.len() - after_dash.trim_start().len();
407    if pad_len == 0 {
408        return None;
409    }
410    let after_pad = after_dash.trim_start();
411    let first_item = if after_pad.starts_with("{}") {
412        "{}"
413    } else if after_pad.starts_with("[]") {
414        "[]"
415    } else {
416        return None;
417    };
418    let rest = &after_pad[first_item.len()..];
419    let pad = &after_dash[..pad_len];
420    let first = format!("{}-{pad}{first_item}", &line[..indent_len]);
421    let rest_trim = rest.trim_start();
422    // Nested items glue as `- {}  - name`. A document-root sequence
423    // glues with no spaces (`- {}- name: Othello`).
424    if is_yaml_sequence_item(rest_trim) {
425        let between = &rest[..rest.len() - rest_trim.len()];
426        if between.chars().all(|c| c == ' ' || c == '\t') {
427            let second = if between.is_empty() {
428                format!("{}{rest_trim}", &line[..indent_len])
429            } else {
430                format!("{between}{rest_trim}")
431            };
432            return Some((first, second));
433        }
434    }
435    // Last-item empty: Sequence::set drops the newline before the next
436    // mapping key (`- {}z: *x` at column 0, or `- {}  enabled: true`
437    // when that key is indented).
438    if !rest.is_empty()
439        && !rest_trim.starts_with('#')
440        && !is_yaml_sequence_item(rest_trim)
441        && (rest_trim.contains(": ") || rest_trim.ends_with(':'))
442    {
443        return Some((first, rest.to_string()));
444    }
445    None
446}
447
448/// Find the indentation of the next non-empty, non-comment line after `i`.
449/// Used to align orphaned comment lines with their associated mapping entry.
450fn next_entry_indent(lines: &[&str], i: usize) -> Option<usize> {
451    for line in &lines[i + 1..] {
452        let t = line.trim();
453        if !t.is_empty() && !t.starts_with('#') {
454            return Some(line.len() - t.len());
455        }
456    }
457    None
458}
459
460/// Determine the expected indentation for a mapping entry by inspecting
461/// its neighbors. Returns `Some(indent)` if a sibling at a lower indent
462/// is found (indicating the current line has orphaned extra whitespace).
463fn expected_sibling_indent(lines: &[&str], i: usize, current_indent: usize) -> Option<usize> {
464    let is_significant = |l: &&str| {
465        let t = l.trim();
466        !t.is_empty() && !t.starts_with('#')
467    };
468
469    // Strategy 1: look DOWN for a sibling with less indent.
470    if let Some(next_line) = lines[i + 1..].iter().find(|l| is_significant(l)) {
471        let nt = next_line.trim_start();
472        let ni = next_line.len() - nt.len();
473        let next_is_entry = !is_yaml_sequence_item(nt) && (nt.contains(": ") || nt.ends_with(':'));
474
475        if ni < current_indent && ni > 0 && next_is_entry {
476            // Verify: prev line must be a parent (indent < ni, ends ':')
477            // or another sibling at the correct indent (== ni).
478            let prev_ok = lines[..i]
479                .iter()
480                .rev()
481                .find(|l| is_significant(l))
482                .is_some_and(|l| {
483                    let t = l.trim_start();
484                    if is_yaml_sequence_item(t) {
485                        return false;
486                    }
487                    let pi = l.len() - t.len();
488                    (pi < ni && l.trim_end().ends_with(':')) || pi == ni
489                });
490            if prev_ok {
491                return Some(ni);
492            }
493        }
494    }
495
496    // Strategy 2: look UP for a sibling with less indent (handles the case
497    // where the corrupted line is the last entry in the mapping).
498    if let Some(prev_line) = lines[..i].iter().rev().find(|l| is_significant(l)) {
499        let pt = prev_line.trim_start();
500        let pi = prev_line.len() - pt.len();
501
502        // Prev must be a complete mapping entry (has a value after the colon,
503        // so it is a sibling rather than a parent). A parent key ends with ':'
504        // alone; a complete entry has ': <value>'. A sequence item
505        // (`- name: A`) is not a mapping sibling of the next key.
506        if pi < current_indent
507            && pi > 0
508            && pt.contains(": ")
509            && !is_yaml_parent_line(pt)
510            && !is_yaml_sequence_item(pt)
511        {
512            return Some(pi);
513        }
514    }
515
516    None
517}
518
519/// Block sequence item (`- ` or a lone `-`), not a mapping sibling.
520fn is_yaml_sequence_item(trimmed: &str) -> bool {
521    trimmed == "-" || trimmed.starts_with("- ")
522}
523
524/// Check if a trimmed YAML line is a parent key (value is on the next line).
525///
526/// Parent patterns:
527///   `key:`
528///   `key: # comment`
529///   `key:  `
530///
531/// Complete entry: `key: value`
532fn is_yaml_parent_line(trimmed: &str) -> bool {
533    if trimmed.ends_with(':') {
534        return true;
535    }
536    if let Some(pos) = trimmed.find(": ") {
537        let after = trimmed[pos + 2..].trim();
538        return after.is_empty() || after.starts_with('#');
539    }
540    false
541}
542
543/// Attempt CST-preserving update for YAML. Returns Some(result) on success,
544/// None if a text-level fallback (or plain serialize) is required.
545fn try_preserve_yaml(
546    original_content: &str,
547    old_value: &serde_json::Value,
548    new_value: &serde_json::Value,
549) -> anyhow::Result<Option<String>> {
550    use std::str::FromStr;
551
552    let file = yaml_edit::YamlFile::from_str(original_content).map_err(|e| {
553        anyhow::Error::new(crate::exit::ParseErrorError {
554            msg: format!("YAML re-parse for comment preservation: {e}"),
555        })
556    })?;
557    let promoted =
558        yaml_cst::rewrite_yaml_alias_object_edits(original_content, &file, old_value, new_value)?;
559    if let Some(spliced) = promoted.as_deref()
560        && yaml_semantic_eq(spliced, new_value)
561    {
562        return Ok(Some(cleanup_yaml_cst_whitespace(spliced)));
563    }
564    // Leftover array growth: splice the alias-rewritten text. A yaml-edit
565    // re-serialize of `- <<: *alias` plus overrides drops indent, then
566    // parse_yaml_semantic fails and we dump.
567    if let Some(spliced) = promoted.as_deref()
568        && let Some(reparsed) = parse_yaml_semantic(spliced)
569        && let Some(grown) = yaml_splice::splice_yaml_array_diffs(spliced, &reparsed, new_value)?
570    {
571        return Ok(Some(grown));
572    }
573    let (file, cst_old) = if let Some(spliced) = promoted.as_deref() {
574        match yaml_file_after_partial_alias_splice(spliced) {
575            Some(pair) => pair,
576            None => return Ok(None),
577        }
578    } else {
579        (file, old_value.clone())
580    };
581
582    if let Some(doc) = file.document() {
583        if let Some(mapping) = doc.as_mapping() {
584            if cst_old.is_object() && new_value.is_object() {
585                return try_preserve_yaml_object(
586                    promoted.as_deref().unwrap_or(original_content),
587                    &file,
588                    &mapping,
589                    &cst_old,
590                    new_value,
591                );
592            }
593        } else if let Some(seq) = doc.as_sequence()
594            && let (Some(old_arr), Some(new_arr)) = (cst_old.as_array(), new_value.as_array())
595        {
596            return try_preserve_yaml_array(
597                &file,
598                &seq,
599                promoted.as_deref().unwrap_or(original_content),
600                old_arr,
601                new_arr,
602                new_value,
603            );
604        }
605    }
606    Ok(None)
607}
608
609/// After an alias splice that is not yet semantic-eq, reparse for leftover CST diffs.
610/// Parse failure must dump (None), not CST the pre-splice document.
611fn yaml_file_after_partial_alias_splice(
612    spliced: &str,
613) -> Option<(yaml_edit::YamlFile, serde_json::Value)> {
614    use std::str::FromStr;
615
616    let reparsed = yaml_edit::YamlFile::from_str(spliced).ok()?;
617    let cst_old = parse_yaml_semantic(spliced)?;
618    Some((reparsed, cst_old))
619}
620
621fn try_preserve_yaml_object(
622    original: &str,
623    file: &yaml_edit::YamlFile,
624    mapping: &yaml_edit::Mapping,
625    old_value: &serde_json::Value,
626    new_value: &serde_json::Value,
627) -> anyhow::Result<Option<String>> {
628    let all_cst_applied = apply_yaml_mapping_diff(mapping, old_value, new_value)?;
629    // yaml_edit's Mapping::remove() can leave trailing whitespace on the
630    // line preceding the removed key and may shift the indentation of the
631    // next sibling entry. Clean both artifacts so the output is valid YAML
632    // that preserves original quote styles and key ordering.
633    // After an emptied block item, Sequence::set of `{}` can drop the
634    // item newline (#2274). Repair before semantic_eq or we dump and
635    // lose anchors (#2275).
636    let result = finalize_yaml_cst_text(original, &file.to_string());
637
638    // Compare semantically (merge keys resolved) so CST results that keep
639    // `<<: *anchor` / `*alias` form match parse_doc's flattened model.
640    // Without this, verification always fails on merge-key documents and the
641    // non-preserving fallback expands every anchor/alias into full copies.
642    // Array growth applied as a new local key (inherited via `<<` only)
643    // is already in the CST; do not require !has_array_growth or we dump.
644    if yaml_semantic_eq(&result, new_value) {
645        return Ok(Some(result));
646    }
647
648    // A same-length sequence can only Sequence::set one empty `{}` per
649    // pass. Reparse the repaired text and apply leftover empties.
650    if !all_cst_applied
651        && let Some(finished) = retry_yaml_cst_empties(original, &result, new_value)?
652    {
653        return Ok(Some(finished));
654    }
655
656    // Array growth or structure mismatch: try splice.
657    // If the CST produced invalid YAML (e.g., duplicated keys from
658    // misinterpreted indentation, #972), serde_yaml_ng will fail to parse
659    // it. In that case, skip the splice and fall through to the caller's
660    // non-preserving fallback instead of propagating the error.
661    //
662    // Feed the splice path a merge-resolved view of `result` so array diffs
663    // line up with `new_value` (also merge-resolved via parse_doc).
664    if let Some(reparsed) = parse_yaml_semantic(&result)
665        && let Some(spliced) = yaml_splice::splice_yaml_array_diffs(&result, &reparsed, new_value)?
666    {
667        return Ok(Some(spliced));
668    }
669    Ok(None)
670}
671
672fn finalize_yaml_cst_text(original: &str, cst: &str) -> String {
673    fix_yaml_block_indentation_with_original(
674        Some(original),
675        &repair_glued_empty_flow_after_colon(&repair_glued_block_sequence_items(
676            &cleanup_yaml_cst_whitespace(cst),
677        )),
678    )
679}
680
681/// yaml-edit 0.3.1 parent-set of `{}` / `[]` can omit the space after
682/// colon (`key:{}`). serde_yaml_ng rejects that, so we dump and lose
683/// sibling anchors. Insert the missing space.
684fn repair_glued_empty_flow_after_colon(text: &str) -> String {
685    let eol = crate::write::detect_eol(text);
686    let mut lines: Vec<String> = Vec::new();
687    for line in text.lines() {
688        lines.push(repair_glued_empty_flow_line(line));
689    }
690    let mut out = lines.join(eol);
691    if text.ends_with('\n') && !out.ends_with('\n') {
692        out.push_str(eol);
693    }
694    out
695}
696
697fn repair_glued_empty_flow_line(line: &str) -> String {
698    let indent_len = line.len() - line.trim_start().len();
699    let trimmed = &line[indent_len..];
700    if is_yaml_sequence_item(trimmed) {
701        return line.to_string();
702    }
703    let Some((key, rest)) = trimmed.split_once(':') else {
704        return line.to_string();
705    };
706    if key.is_empty() || key.contains('#') {
707        return line.to_string();
708    }
709    if rest.starts_with("{}") || rest.starts_with("[]") {
710        return format!("{}{key}: {rest}", &line[..indent_len]);
711    }
712    line.to_string()
713}
714
715fn retry_yaml_cst_empties(
716    original: &str,
717    start: &str,
718    new_value: &serde_json::Value,
719) -> anyhow::Result<Option<String>> {
720    use std::str::FromStr;
721
722    let mut text = start.to_string();
723    for _ in 0..32 {
724        let Some(current) = parse_yaml_semantic(&text) else {
725            return Ok(None);
726        };
727        if current == *new_value {
728            return Ok(Some(text));
729        }
730        let Ok(file) = yaml_edit::YamlFile::from_str(&text) else {
731            return Ok(None);
732        };
733        let Some(doc) = file.document() else {
734            return Ok(None);
735        };
736        if let Some(mapping) = doc.as_mapping() {
737            apply_yaml_mapping_diff(&mapping, &current, new_value)?;
738        } else if let Some(seq) = doc.as_sequence()
739            && let (Some(old_arr), Some(new_arr)) = (current.as_array(), new_value.as_array())
740        {
741            apply_yaml_sequence_diff(&seq, old_arr, new_arr)?;
742        } else {
743            return Ok(None);
744        }
745        let next = finalize_yaml_cst_text(original, &file.to_string());
746        if yaml_semantic_eq(&next, new_value) {
747            return Ok(Some(next));
748        }
749        if next == text {
750            return Ok(None);
751        }
752        text = next;
753    }
754    Ok(None)
755}
756
757fn try_preserve_yaml_array(
758    file: &yaml_edit::YamlFile,
759    seq: &yaml_edit::Sequence,
760    original_content: &str,
761    old_arr: &[serde_json::Value],
762    new_arr: &[serde_json::Value],
763    new_value: &serde_json::Value,
764) -> anyhow::Result<Option<String>> {
765    let applied = if old_arr.len() == new_arr.len() {
766        apply_yaml_sequence_diff(seq, old_arr, new_arr)?
767    } else if new_arr.len() < old_arr.len() {
768        try_remove_subsequence(seq, old_arr, new_arr)
769    } else {
770        false
771    };
772    // Same-length empties apply one `{}` per pass. Finalize the partial
773    // CST (glue + indent) and retry leftover empties like mapping-root.
774    if old_arr.len() == new_arr.len() || applied {
775        let result = finalize_yaml_cst_text(original_content, &file.to_string());
776        if yaml_semantic_eq(&result, new_value) {
777            return Ok(Some(result));
778        }
779        if !applied
780            && let Some(finished) = retry_yaml_cst_empties(original_content, &result, new_value)?
781        {
782            return Ok(Some(finished));
783        }
784    }
785
786    // Growth or failure: text splice.
787    if new_arr.len() > old_arr.len()
788        && let Some(spliced) =
789            yaml_splice::splice_yaml_root_sequence(original_content, old_arr, new_arr)?
790        && yaml_semantic_eq(&spliced, new_value)
791        && spliced.parse::<yaml_edit::YamlFile>().is_ok()
792    {
793        return Ok(Some(spliced));
794    }
795    Ok(None)
796}
797
798/// Parse YAML into the same semantic model as [`parse_doc`]: serde expands
799/// aliases, then merge keys (`<<`) are flattened.
800fn parse_yaml_semantic(text: &str) -> Option<serde_json::Value> {
801    let mut value: serde_json::Value = serde_yaml_ng::from_str(text).ok()?;
802    resolve_yaml_merge_keys(&mut value);
803    Some(value)
804}
805
806/// True when `text` parses to the same semantic value as `expected`
807/// (aliases expanded, merge keys resolved). Used to accept CST-preserving
808/// writes that keep `&anchor` / `*alias` / `<<: *ref` form.
809pub(super) fn yaml_semantic_eq(text: &str, expected: &serde_json::Value) -> bool {
810    parse_yaml_semantic(text).is_some_and(|v| v == *expected)
811}
812
813/// TOML 1.0 only names LF and CRLF. `toml_edit` rejects a lone CR.
814/// Normalize those to LF for parse; restore with [`restore_toml_file_eol`].
815fn toml_source_for_parse(content: &str) -> Cow<'_, str> {
816    let bytes = content.as_bytes();
817    let mut i = 0;
818    let mut lone = false;
819    while i < bytes.len() {
820        if bytes[i] == b'\r' {
821            if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
822                i += 2;
823            } else {
824                lone = true;
825                break;
826            }
827        } else {
828            i += 1;
829        }
830    }
831    if !lone {
832        return Cow::Borrowed(content);
833    }
834    let mut out = Vec::with_capacity(bytes.len());
835    i = 0;
836    while i < bytes.len() {
837        if bytes[i] == b'\r' {
838            if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
839                out.extend_from_slice(b"\r\n");
840                i += 2;
841            } else {
842                out.push(b'\n');
843                i += 1;
844            }
845        } else {
846            out.push(bytes[i]);
847            i += 1;
848        }
849    }
850    Cow::Owned(String::from_utf8(out).expect("input was UTF-8"))
851}
852
853/// After a TOML CST write, put CR-only files back on CR (EditorConfig `cr`).
854fn restore_toml_file_eol(original: &str, rendered: String) -> String {
855    if crate::write::detect_eol(original) == "\r" {
856        crate::write::normalize_eol(&rendered, crate::write::EolMode::Cr).into_owned()
857    } else {
858        rendered
859    }
860}
861
862pub fn parse_doc(content: &str, format: &FileFormat) -> anyhow::Result<serde_json::Value> {
863    // Notepad/VS/Out-File prefix. JSON rejects BOM; YAML multi-doc `---`
864    // after U+FEFF is not a document marker (parse_error at `a:`).
865    let content = crate::ops::file::strip_utf8_bom(content);
866    match format {
867        // Empty / whitespace-only files: treat as empty object so `doc set`
868        // can bootstrap a new document (YAML/TOML already accept empty input;
869        // serde_json rejects EOF — fixrealloop 2026-07-15).
870        FileFormat::Json => {
871            if content.trim().is_empty() {
872                Ok(serde_json::json!({}))
873            } else {
874                serde_json::from_str(content).map_err(|e| {
875                    anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })
876                })
877            }
878        }
879        FileFormat::Yaml => {
880            if is_multi_document_yaml(content) {
881                parse_multi_document_yaml(content).map_err(|e| {
882                    // Multi-doc path may already be typed; re-wrap plain anyhow.
883                    if crate::exit::is_parse_error(&e) {
884                        e
885                    } else {
886                        anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })
887                    }
888                })
889            } else {
890                let mut val: serde_json::Value = serde_yaml_ng::from_str(content).map_err(|e| {
891                    anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })
892                })?;
893                resolve_yaml_merge_keys(&mut val);
894                Ok(val)
895            }
896        }
897        FileFormat::Toml => toml_edit::de::from_str(&toml_source_for_parse(content))
898            .map_err(|e| anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })),
899    }
900}
901
902/// Parse for read-only queries (keys/len/get/has).
903///
904/// Empty / whitespace-only / BOM-ZWSP-only YAML is `{}` so keys/len at `.`
905/// match empty JSON/TOML. Write bootstrap still uses [`parse_doc`] (empty
906/// YAML stays Null). Comment-only and `---` preamble stay parsed YAML.
907pub fn parse_doc_for_query(
908    content: &str,
909    format: &FileFormat,
910) -> anyhow::Result<serde_json::Value> {
911    // Blank-text check is on the source, not the parsed value: ZWSP-only
912    // YAML deserializes as a string, not Null, and trim() leaves it intact.
913    if matches!(format, FileFormat::Yaml) && crate::containment::is_blank_text(content) {
914        return Ok(serde_json::json!({}));
915    }
916    parse_doc(content, format)
917}
918
919/// Load and parse a JSON/YAML/TOML file for read-only queries.
920///
921/// Load first so a missing path peels as `not_found` even when the
922/// extension is absent or unsupported. Empty YAML is `{}` via
923/// [`parse_doc_for_query`]. Write paths keep [`parse_doc`].
924pub fn load_for_query(path: &Path) -> anyhow::Result<serde_json::Value> {
925    let display = path.to_string_lossy();
926    let content = crate::files::load_text_strict(path, &display)?;
927    let format = detect_format(&display)?;
928    parse_doc_for_query(&content, &format).with_context(|| format!("parsing {display}"))
929}
930
931/// Check whether YAML content contains multiple documents.
932///
933/// A `---` on its own line is the YAML document separator. A single leading
934/// `---` is standard YAML preamble, not multi-document. We look for a second
935/// `---` separator after stripping the optional leading one.
936pub(crate) fn is_multi_document_yaml(content: &str) -> bool {
937    // Skip the optional leading document marker.
938    let rest = content.strip_prefix("---").map_or(content, |after| {
939        // The leading `---` must be followed by whitespace, a comment, newline,
940        // or EOF to count as a document marker rather than part of a value.
941        if is_after_yaml_marker(after) {
942            skip_to_next_line(after)
943        } else {
944            content
945        }
946    });
947
948    // Check if rest itself starts with a `---` separator (consecutive markers).
949    if rest.starts_with("---") && is_after_yaml_marker(&rest[3..]) {
950        return true;
951    }
952
953    // Look for `\n---` followed by whitespace, comment, newline, or EOF.
954    for (i, _) in rest.match_indices("\n---") {
955        let after_marker = &rest[i + 4..];
956        if is_after_yaml_marker(after_marker) {
957            return true;
958        }
959    }
960    false
961}
962
963/// Check whether the text after `---` constitutes a valid document marker
964/// ending: empty (EOF), newline, whitespace before newline, or `#` comment.
965fn is_after_yaml_marker(after: &str) -> bool {
966    if after.is_empty() {
967        return true;
968    }
969    let b = after.as_bytes()[0];
970    // Direct newline or carriage return.
971    if b == b'\n' || b == b'\r' {
972        return true;
973    }
974    // Trailing whitespace or comment: skip spaces/tabs, then expect newline/comment/EOF.
975    if b == b' ' || b == b'\t' || b == b'#' {
976        let rest = after
977            .as_bytes()
978            .iter()
979            .skip_while(|&&c| c == b' ' || c == b'\t')
980            .copied()
981            .next();
982        return rest.is_none() || rest == Some(b'\n') || rest == Some(b'\r') || rest == Some(b'#');
983    }
984    false
985}
986
987/// Skip past the current line (after `---`) to the start of the next line.
988fn skip_to_next_line(s: &str) -> &str {
989    match s.find('\n') {
990        Some(pos) => &s[pos + 1..],
991        None => "",
992    }
993}
994
995/// Parse multi-document YAML into a JSON array, resolving merge keys per doc.
996fn parse_multi_document_yaml(content: &str) -> anyhow::Result<serde_json::Value> {
997    let mut docs = Vec::new();
998    for de in serde_yaml_ng::Deserializer::from_str(content) {
999        let mut val: serde_json::Value = serde_json::Value::deserialize(de)?;
1000        resolve_yaml_merge_keys(&mut val);
1001        docs.push(val);
1002    }
1003    // Precondition: is_multi_document_yaml() returned true, so content has
1004    // ---  separators and the YAML deserializer always yields at least one doc.
1005    debug_assert!(!docs.is_empty(), "multi-doc YAML produced zero documents");
1006    Ok(serde_json::Value::Array(docs))
1007}
1008
1009/// Whether a single line (without trailing newline) is a YAML document marker.
1010fn is_yaml_document_separator_line(line: &str) -> bool {
1011    line.strip_prefix("---").is_some_and(is_after_yaml_marker)
1012}
1013
1014/// Split multi-document YAML into document body strings.
1015///
1016/// Returns `(had_leading_marker, bodies)`. Bodies exclude the `---` separator
1017/// lines. Caller should only invoke when [`is_multi_document_yaml`] is true.
1018pub(crate) fn split_multi_document_yaml(content: &str) -> (bool, Vec<String>) {
1019    let mut bodies: Vec<String> = Vec::new();
1020    let mut current = String::new();
1021    let mut leading_marker = false;
1022    let mut first_line = true;
1023    let mut saw_body = false;
1024
1025    for line in content.split_inclusive('\n') {
1026        let without_nl = line.trim_end_matches(['\n', '\r']);
1027        if is_yaml_document_separator_line(without_nl) {
1028            if first_line && !saw_body {
1029                // Leading stream marker (optional preamble), not a boundary.
1030                leading_marker = true;
1031                first_line = false;
1032                continue;
1033            }
1034            bodies.push(std::mem::take(&mut current));
1035            first_line = false;
1036            continue;
1037        }
1038        first_line = false;
1039        saw_body = true;
1040        current.push_str(line);
1041    }
1042    bodies.push(current);
1043    (leading_marker, bodies)
1044}
1045
1046/// Reassemble multi-document YAML from body strings.
1047///
1048/// `eol` is the dominant line ending from the original stream so separators
1049/// stay CRLF on Windows manifests (mixed EOL confuses diffs and some tools).
1050fn join_multi_document_yaml(leading_marker: bool, docs: &[String], eol: &str) -> String {
1051    let mut out = String::new();
1052    if leading_marker {
1053        out.push_str("---");
1054        out.push_str(eol);
1055    }
1056    for (i, doc) in docs.iter().enumerate() {
1057        if i > 0 {
1058            if !out.ends_with('\n') {
1059                out.push_str(eol);
1060            }
1061            out.push_str("---");
1062            out.push_str(eol);
1063        }
1064        let body = doc.trim_end_matches(['\n', '\r']);
1065        if !body.is_empty() {
1066            out.push_str(body);
1067            out.push_str(eol);
1068        }
1069    }
1070    if out.is_empty() {
1071        out.push_str(eol);
1072    }
1073    out
1074}
1075
1076/// Serialize one document body (single-doc preserve path, no multi-doc re-entry).
1077fn serialize_single_yaml_document(
1078    original_body: &str,
1079    old_value: &serde_json::Value,
1080    new_value: &serde_json::Value,
1081) -> anyhow::Result<String> {
1082    if old_value == new_value && !original_body.is_empty() {
1083        return Ok(original_body.to_string());
1084    }
1085    if !original_body.trim().is_empty()
1086        && let Some(result) = try_preserve_yaml(original_body, old_value, new_value)?
1087    {
1088        return Ok(result);
1089    }
1090    let body = serialize_value(new_value, &FileFormat::Yaml)?;
1091    if original_body.is_empty() {
1092        Ok(body)
1093    } else {
1094        Ok(preserve::hoist_comments(original_body, &body))
1095    }
1096}
1097
1098/// Write multi-document YAML while keeping `---` document separators.
1099///
1100/// Parse models multi-doc streams as a JSON array. Naively serializing that
1101/// array emits a single sequence document (`- kind: ...`), which is not a
1102/// multi-document stream and breaks tools that expect `---` separators
1103/// (e.g. `kubectl apply -f`).
1104fn serialize_multi_document_yaml(
1105    original_content: &str,
1106    old_value: &serde_json::Value,
1107    new_value: &serde_json::Value,
1108) -> anyhow::Result<String> {
1109    if old_value == new_value {
1110        return Ok(original_content.to_string());
1111    }
1112
1113    let (leading_marker, bodies) = split_multi_document_yaml(original_content);
1114
1115    let Some(new_docs) = new_value.as_array() else {
1116        // Unexpected: multi-doc parse always yields an array. Fall back.
1117        let body = serialize_value(new_value, &FileFormat::Yaml)?;
1118        return Ok(preserve::hoist_comments(original_content, &body));
1119    };
1120
1121    let old_docs = old_value.as_array().map(|a| a.as_slice()).unwrap_or(&[]);
1122    let mut out_docs: Vec<String> = Vec::with_capacity(new_docs.len());
1123
1124    // When document count is unchanged, pair by index (in-place field edits).
1125    // When count changes (whole-doc delete/append), pair by value identity so
1126    // surviving docs keep their original bodies/comments. Index pairing after
1127    // `doc.delete 0` wrongly maps body[0] onto the former doc 1.
1128    if old_docs.len() == new_docs.len() {
1129        for (i, new_doc) in new_docs.iter().enumerate() {
1130            let orig_body = bodies.get(i).map(String::as_str).unwrap_or("");
1131            let old_doc = old_docs.get(i);
1132            match old_doc {
1133                Some(old_doc) if old_doc == new_doc && !orig_body.is_empty() => {
1134                    out_docs.push(orig_body.to_string());
1135                }
1136                Some(old_doc) => {
1137                    out_docs.push(serialize_single_yaml_document(orig_body, old_doc, new_doc)?);
1138                }
1139                None => {
1140                    out_docs.push(serialize_value(new_doc, &FileFormat::Yaml)?);
1141                }
1142            }
1143        }
1144    } else {
1145        let mut used = vec![false; old_docs.len()];
1146        for new_doc in new_docs {
1147            let match_j = old_docs.iter().enumerate().find_map(|(j, old_doc)| {
1148                if !used[j] && old_doc == new_doc {
1149                    Some(j)
1150                } else {
1151                    None
1152                }
1153            });
1154            match match_j {
1155                Some(j) => {
1156                    used[j] = true;
1157                    let orig_body = bodies.get(j).map(String::as_str).unwrap_or("");
1158                    if !orig_body.is_empty() {
1159                        out_docs.push(orig_body.to_string());
1160                    } else {
1161                        out_docs.push(serialize_value(new_doc, &FileFormat::Yaml)?);
1162                    }
1163                }
1164                None => {
1165                    // New or edited without exact old identity: full serialize.
1166                    out_docs.push(serialize_value(new_doc, &FileFormat::Yaml)?);
1167                }
1168            }
1169        }
1170    }
1171
1172    let eol = crate::write::detect_eol(original_content);
1173    Ok(join_multi_document_yaml(leading_marker, &out_docs, eol))
1174}
1175
1176// ---------------------------------------------------------------------------
1177// Pure data helpers (value parsing, flattening, diffing)
1178// ---------------------------------------------------------------------------
1179
1180/// Parse a CLI value string into a [`serde_json::Value`].
1181///
1182/// Recognition order: JSON-quoted string, JSON object/array, boolean, null,
1183/// i64, f64, then fallback to bare string.
1184///
1185/// Bare floats (`2.0`, `1.5`) become JSON numbers by design (batch/CLI
1186/// contract). For a string version field, quote the value:
1187/// `doc set package.json version '"2.0"'` or plan/MCP typed string.
1188pub fn parse_value(s: &str) -> serde_json::Value {
1189    // JSON-quoted string
1190    if s.starts_with('"')
1191        && s.ends_with('"')
1192        && let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
1193    {
1194        return v;
1195    }
1196    // JSON object or array
1197    if (s.starts_with('{') || s.starts_with('['))
1198        && let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
1199    {
1200        return v;
1201    }
1202    // Booleans
1203    if s == "true" {
1204        return serde_json::Value::Bool(true);
1205    }
1206    if s == "false" {
1207        return serde_json::Value::Bool(false);
1208    }
1209    // Null
1210    if s == "null" {
1211        return serde_json::Value::Null;
1212    }
1213    // Integer
1214    if let Ok(n) = s.parse::<i64>() {
1215        return serde_json::Value::Number(n.into());
1216    }
1217    // Float
1218    if let Ok(n) = s.parse::<f64>()
1219        && let Some(num) = serde_json::Number::from_f64(n)
1220    {
1221        return serde_json::Value::Number(num);
1222    }
1223    // Bare string
1224    serde_json::Value::String(s.to_string())
1225}
1226
1227/// Recursively enumerate all leaf selector paths in a JSON value.
1228///
1229/// Uses a mutable `String` buffer for the path prefix to avoid
1230/// allocating a new `String` via `format!()` at every recursion level.
1231/// The buffer is extended and truncated as the recursion descends and
1232/// ascends, so only leaf paths produce a final `String::clone()`.
1233/// Append a key to the path buffer, quoting it if it contains separator
1234/// characters (`.`, `[`, `]`) to prevent ambiguity in flattened paths.
1235fn push_key_quoted(buf: &mut String, k: &str) {
1236    if k.contains('.') || k.contains('[') || k.contains(']') || k.contains('"') {
1237        buf.push('"');
1238        buf.push_str(&k.replace('"', "\\\""));
1239        buf.push('"');
1240    } else {
1241        buf.push_str(k);
1242    }
1243}
1244
1245pub fn flatten_value<'a>(
1246    value: &'a serde_json::Value,
1247    buf: &mut String,
1248    out: &mut Vec<(String, &'a serde_json::Value)>,
1249) {
1250    match value {
1251        serde_json::Value::Object(map) if !map.is_empty() => {
1252            for (k, v) in map {
1253                let restore = buf.len();
1254                if !buf.is_empty() {
1255                    buf.push('.');
1256                }
1257                push_key_quoted(buf, k);
1258                flatten_value(v, buf, out);
1259                buf.truncate(restore);
1260            }
1261        }
1262        serde_json::Value::Array(arr) if !arr.is_empty() => {
1263            for (i, v) in arr.iter().enumerate() {
1264                let restore = buf.len();
1265                buf.push('[');
1266                let _ = std::fmt::Write::write_fmt(buf, format_args!("{i}"));
1267                buf.push(']');
1268                flatten_value(v, buf, out);
1269                buf.truncate(restore);
1270            }
1271        }
1272        _ => {
1273            out.push((buf.clone(), value));
1274        }
1275    }
1276}
1277
1278/// Entry in a structured diff.
1279#[derive(Debug, Clone, serde::Serialize)]
1280pub struct DiffEntry {
1281    pub path: String,
1282    pub kind: &'static str,
1283    #[serde(skip_serializing_if = "Option::is_none")]
1284    pub old_value: Option<serde_json::Value>,
1285    #[serde(skip_serializing_if = "Option::is_none")]
1286    pub new_value: Option<serde_json::Value>,
1287}
1288
1289/// Compute structural differences between two JSON values.
1290///
1291/// Uses a mutable `String` buffer for the path prefix to avoid
1292/// allocating a new `String` via `format!()` at every recursion level.
1293pub fn diff_values(
1294    a: &serde_json::Value,
1295    b: &serde_json::Value,
1296    buf: &mut String,
1297    out: &mut Vec<DiffEntry>,
1298) {
1299    match (a, b) {
1300        (serde_json::Value::Object(ma), serde_json::Value::Object(mb)) => {
1301            for (k, va) in ma {
1302                let restore = buf.len();
1303                if !buf.is_empty() {
1304                    buf.push('.');
1305                }
1306                push_key_quoted(buf, k);
1307                if let Some(vb) = mb.get(k) {
1308                    diff_values(va, vb, buf, out);
1309                } else {
1310                    out.push(DiffEntry {
1311                        path: buf.clone(),
1312                        kind: "removed",
1313                        old_value: Some(va.clone()),
1314                        new_value: None,
1315                    });
1316                }
1317                buf.truncate(restore);
1318            }
1319            for (k, vb) in mb {
1320                if !ma.contains_key(k) {
1321                    let restore = buf.len();
1322                    if !buf.is_empty() {
1323                        buf.push('.');
1324                    }
1325                    push_key_quoted(buf, k);
1326                    out.push(DiffEntry {
1327                        path: buf.clone(),
1328                        kind: "added",
1329                        old_value: None,
1330                        new_value: Some(vb.clone()),
1331                    });
1332                    buf.truncate(restore);
1333                }
1334            }
1335        }
1336        (serde_json::Value::Array(aa), serde_json::Value::Array(ab)) => {
1337            let max_len = aa.len().max(ab.len());
1338            for i in 0..max_len {
1339                let restore = buf.len();
1340                buf.push('[');
1341                let _ = std::fmt::Write::write_fmt(buf, format_args!("{i}"));
1342                buf.push(']');
1343                match (aa.get(i), ab.get(i)) {
1344                    (Some(va), Some(vb)) => diff_values(va, vb, buf, out),
1345                    (Some(va), None) => out.push(DiffEntry {
1346                        path: buf.clone(),
1347                        kind: "removed",
1348                        old_value: Some(va.clone()),
1349                        new_value: None,
1350                    }),
1351                    (None, Some(vb)) => out.push(DiffEntry {
1352                        path: buf.clone(),
1353                        kind: "added",
1354                        old_value: None,
1355                        new_value: Some(vb.clone()),
1356                    }),
1357                    (None, None) => {}
1358                }
1359                buf.truncate(restore);
1360            }
1361        }
1362        _ => {
1363            if a != b {
1364                out.push(DiffEntry {
1365                    path: buf.clone(),
1366                    kind: "changed",
1367                    old_value: Some(a.clone()),
1368                    new_value: Some(b.clone()),
1369                });
1370            }
1371        }
1372    }
1373}
1374
1375/// Recursively resolve YAML merge keys (`<<`) in a parsed JSON value.
1376///
1377/// When `serde_yaml_ng` deserializes `<<: *anchor`, it produces a literal
1378/// `"<<"` key whose value is the referenced mapping.  This function walks
1379/// the tree and flattens those entries into the parent object, matching
1380/// YAML merge-key semantics (existing keys take precedence).
1381///
1382/// Recursion stops at `MAX_MERGE_DEPTH` (128, same cap as `deep_merge`).
1383fn resolve_yaml_merge_keys(value: &mut serde_json::Value) {
1384    resolve_yaml_merge_keys_inner(value, 0);
1385}
1386
1387fn resolve_yaml_merge_keys_inner(value: &mut serde_json::Value, depth: usize) {
1388    if depth >= navigate::MAX_MERGE_DEPTH {
1389        return;
1390    }
1391    match value {
1392        serde_json::Value::Object(map) => {
1393            // First, recurse into all child values (including the merge value itself).
1394            for v in map.values_mut() {
1395                resolve_yaml_merge_keys_inner(v, depth + 1);
1396            }
1397
1398            // Then resolve `<<` if present.
1399            if let Some(merge_val) = map.remove("<<") {
1400                match merge_val {
1401                    serde_json::Value::Object(merged) => {
1402                        for (k, v) in merged {
1403                            map.entry(k).or_insert(v);
1404                        }
1405                    }
1406                    serde_json::Value::Array(arr) => {
1407                        // Multiple merges: `<<: [*a, *b]` — first wins.
1408                        for item in arr {
1409                            if let serde_json::Value::Object(merged) = item {
1410                                for (k, v) in merged {
1411                                    map.entry(k).or_insert(v);
1412                                }
1413                            }
1414                        }
1415                    }
1416                    _ => {
1417                        // Non-object merge value — put it back as-is.
1418                        map.insert("<<".to_string(), merge_val);
1419                    }
1420                }
1421            }
1422        }
1423        serde_json::Value::Array(arr) => {
1424            for v in arr {
1425                resolve_yaml_merge_keys_inner(v, depth + 1);
1426            }
1427        }
1428        _ => {}
1429    }
1430}
1431
1432// ---------------------------------------------------------------------------
1433// Unified doc mutation dispatch
1434// ---------------------------------------------------------------------------
1435
1436/// Describes a single mutation to apply to a parsed document root.
1437///
1438/// This enum captures the 9 doc write operations so that both the CLI
1439/// (`cmd/doc.rs`) and the transaction engine (`tx.rs`) share a single
1440/// dispatch path instead of duplicating the match logic.
1441#[derive(Debug)]
1442pub enum DocMutation {
1443    Set {
1444        selector: String,
1445        value: serde_json::Value,
1446    },
1447    Delete {
1448        selector: String,
1449    },
1450    Merge {
1451        /// When set, deep-merge into this selector (e.g. multi-doc `0`).
1452        selector: Option<String>,
1453        value: serde_json::Value,
1454    },
1455    Append {
1456        selector: String,
1457        value: serde_json::Value,
1458    },
1459    Prepend {
1460        selector: String,
1461        value: serde_json::Value,
1462    },
1463    Update {
1464        selector: String,
1465        value: serde_json::Value,
1466    },
1467    Move {
1468        from: String,
1469        to: String,
1470    },
1471    Ensure {
1472        selector: String,
1473        value: serde_json::Value,
1474    },
1475    DeleteWhere {
1476        selector: String,
1477        predicate: String,
1478    },
1479}
1480
1481/// Result of applying a [`DocMutation`] to a document root.
1482#[derive(Debug)]
1483pub enum MutationResult {
1484    /// The mutation was applied and the document was modified.
1485    Applied,
1486    /// A delete / delete-where removed this many items (always >= 1).
1487    ///
1488    /// Callers that only care about "did anything change" can treat this like
1489    /// [`Applied`](Self::Applied). Callers that need counts (CLI/MCP JSON)
1490    /// use the payload for `removed`.
1491    Removed(usize),
1492    /// The selector matched nothing (e.g. delete on a missing key).
1493    NoMatch,
1494    /// The path already exists (used by `Ensure` when no write is needed).
1495    AlreadyExists,
1496    /// A type error occurred (e.g. append to a non-array). The string
1497    /// includes the operation name prefix for backward-compatible error
1498    /// messages (e.g. "doc append: target at 'x' is not an array").
1499    TypeError(String),
1500}
1501
1502/// Apply a [`DocMutation`] to an in-memory document root.
1503///
1504/// Callers are responsible for:
1505/// - Parsing the file and providing the root `Value`
1506/// - Serializing the modified root back to disk
1507/// - Mapping [`MutationResult`] to the appropriate exit code or error
1508pub fn apply_doc_mutation(
1509    root: &mut serde_json::Value,
1510    mutation: DocMutation,
1511) -> anyhow::Result<MutationResult> {
1512    match mutation {
1513        DocMutation::Set { selector, value } => {
1514            let sel = selector::parse_anyhow(&selector)?;
1515            set_at_path(root, &sel, value)?;
1516            Ok(MutationResult::Applied)
1517        }
1518        DocMutation::Delete { selector } => {
1519            let sel = selector::parse_anyhow(&selector)?;
1520            // delete_at_selector returns TypeError for bare keys on array parents.
1521            if delete_at_selector(root, &sel)? {
1522                Ok(MutationResult::Removed(1))
1523            } else {
1524                Ok(MutationResult::NoMatch)
1525            }
1526        }
1527        DocMutation::Merge { selector, value } => {
1528            // Multi-document YAML / top-level JSON arrays: deep_merge replaces
1529            // a non-object base with the overlay wholesale (object *or* array
1530            // overlay). Fail closed for any overlay on an array root so agents
1531            // cannot wipe a multi-doc stream (fixrealloop / MPI; #1872 incomplete).
1532            // Prefer `selector: "0"` (or CLI `--selector 0`) to merge into one doc.
1533            let target = if let Some(sel) = selector.as_deref().filter(|s| !s.is_empty()) {
1534                let parsed = selector::parse_anyhow(sel)?;
1535                navigate_mut(root, &parsed, false, "doc.merge")?
1536            } else {
1537                root
1538            };
1539            if target.is_array() {
1540                return Ok(MutationResult::TypeError(
1541                    "doc merge: target is a top-level array (multi-document YAML or JSON \
1542                     array); deep-merge would replace the whole stream with the overlay. \
1543                     Pass a selector to an object document (e.g. `--selector 0` / plan \
1544                     `\"selector\": \"0\"`) or use doc.set under `0.` / `[0].`"
1545                        .into(),
1546                ));
1547            }
1548            if !target.is_object() && !value.is_object() {
1549                // deep_merge replaces non-object bases; still allow object overlay
1550                // onto object or empty. Non-object target with object value is ok.
1551            }
1552            reject_blank_merge_overlay(&value)?;
1553            deep_merge(target, &value);
1554            Ok(MutationResult::Applied)
1555        }
1556        DocMutation::Append { selector, value } => {
1557            let sel = selector::parse_anyhow(&selector)?;
1558            let target = navigate_mut(root, &sel, false, "doc.append")?;
1559            match target.as_array_mut() {
1560                Some(arr) => {
1561                    arr.push(value);
1562                    Ok(MutationResult::Applied)
1563                }
1564                None => Ok(MutationResult::TypeError(format!(
1565                    "doc append: target at '{selector}' is not an array"
1566                ))),
1567            }
1568        }
1569        DocMutation::Prepend { selector, value } => {
1570            let sel = selector::parse_anyhow(&selector)?;
1571            let target = navigate_mut(root, &sel, false, "doc.prepend")?;
1572            match target.as_array_mut() {
1573                Some(arr) => {
1574                    arr.insert(0, value);
1575                    Ok(MutationResult::Applied)
1576                }
1577                None => Ok(MutationResult::TypeError(format!(
1578                    "doc prepend: target at '{selector}' is not an array"
1579                ))),
1580            }
1581        }
1582        DocMutation::Update { selector, value } => {
1583            let sel = selector::parse_anyhow(&selector)?;
1584            if update_matching(root, &sel, &value)? == 0 {
1585                // Bare key on multi-doc / array root: soft no_matches hides shape errors.
1586                if let Some(hint) = query::array_root_bare_key_hint(root, &sel) {
1587                    Ok(MutationResult::TypeError(hint))
1588                } else {
1589                    Ok(MutationResult::NoMatch)
1590                }
1591            } else {
1592                Ok(MutationResult::Applied)
1593            }
1594        }
1595        DocMutation::Move { from, to } => {
1596            let from_sel = selector::parse_anyhow(&from)?;
1597            let to_sel = selector::parse_anyhow(&to)?;
1598            move_at_path(root, &from_sel, &to_sel)?;
1599            Ok(MutationResult::Applied)
1600        }
1601        DocMutation::Ensure { selector, value } => {
1602            let sel = selector::parse_anyhow(&selector)?;
1603            if !selector::eval_result(root, &sel)?.is_empty() {
1604                Ok(MutationResult::AlreadyExists)
1605            } else {
1606                set_at_path(root, &sel, value)?;
1607                Ok(MutationResult::Applied)
1608            }
1609        }
1610        DocMutation::DeleteWhere {
1611            selector,
1612            predicate,
1613        } => {
1614            let sel = selector::parse_anyhow(&selector)?;
1615            let removed = delete_where(root, &sel, &predicate)?;
1616            if removed == 0 {
1617                Ok(MutationResult::NoMatch)
1618            } else {
1619                Ok(MutationResult::Removed(removed))
1620            }
1621        }
1622    }
1623}
1624
1625#[cfg(test)]
1626mod tests;