Skip to main content

coding_tools/
patch.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! `ct-patch`'s structured-edit engine for JSON / JSONC / JSONL / YAML.
5//!
6//! It parses a dotted/bracketed node path (keys, `[N]` indices, `[key=value]`
7//! object predicates), and applies [`Op`]erations preserving everything outside
8//! the changed node. For the JSON family, edits are **byte-range splices** against
9//! the `jsonc-parser` syntax tree (comments, indentation, key order, trailing
10//! commas all preserved); [`apply_doc`] runs a sequence over one document and
11//! [`apply_jsonl`] runs them over each line. For YAML, [`apply_yaml`] uses the
12//! pure-Rust, comment-preserving `yaml-edit` backend (currently `--set`-replace
13//! and `--delete`; `--add`/`--move-*` error, as yaml-edit 0.2 mis-indents inserts).
14
15use jsonc_parser::ast::{Array, Object, ObjectPropName, Value};
16use jsonc_parser::common::Ranged;
17use jsonc_parser::{CollectOptions, ParseOptions, parse_to_ast};
18use serde_json::json;
19use yaml_edit::path::YamlPath;
20
21/// A path segment: an object key, an array index, or a predicate selecting the
22/// array element whose `key` equals `value` (`[key=value]`).
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Seg {
25    Key(String),
26    Index(usize),
27    Select { key: String, value: String },
28}
29
30/// Parse a dotted/bracketed path; a leading `.` is optional. Keys are
31/// dot-separated; `[N]` selects an array index and `[key=value]` selects the
32/// object in an array whose `key` equals `value`.
33///
34/// # Examples
35///
36/// ```
37/// use coding_tools::patch::{parse_path, Seg};
38///
39/// assert_eq!(
40///     parse_path("a[2].c").unwrap(),
41///     vec![Seg::Key("a".into()), Seg::Index(2), Seg::Key("c".into())]
42/// );
43/// assert_eq!(
44///     parse_path(".servers[name=web].port").unwrap(),
45///     vec![
46///         Seg::Key("servers".into()),
47///         Seg::Select { key: "name".into(), value: "web".into() },
48///         Seg::Key("port".into()),
49///     ]
50/// );
51/// assert!(parse_path("").is_err());
52/// assert!(parse_path("a[x]").is_err()); // not an index and not key=value
53/// ```
54pub fn parse_path(spec: &str) -> Result<Vec<Seg>, String> {
55    let body = spec.strip_prefix('.').unwrap_or(spec);
56    if body.is_empty() {
57        return Err(format!("empty path '{spec}'"));
58    }
59    let mut segs = Vec::new();
60    for part in body.split('.') {
61        let mut rest = part;
62        match rest.find('[') {
63            Some(br) => {
64                let key = &rest[..br];
65                if !key.is_empty() {
66                    segs.push(Seg::Key(key.to_string()));
67                }
68                rest = &rest[br..];
69                while let Some(stripped) = rest.strip_prefix('[') {
70                    let close = stripped
71                        .find(']')
72                        .ok_or_else(|| format!("unclosed '[' in path '{spec}'"))?;
73                    let inner = &stripped[..close];
74                    if let Some((k, v)) = inner.split_once('=') {
75                        segs.push(Seg::Select {
76                            key: k.to_string(),
77                            value: v.to_string(),
78                        });
79                    } else {
80                        let idx: usize = inner
81                            .parse()
82                            .map_err(|_| format!("invalid index '[{inner}]' in path '{spec}'"))?;
83                        segs.push(Seg::Index(idx));
84                    }
85                    rest = &stripped[close + 1..];
86                }
87                if !rest.is_empty() {
88                    return Err(format!("trailing characters '{rest}' in path '{spec}'"));
89                }
90            }
91            None => {
92                if rest.is_empty() {
93                    return Err(format!("empty segment in path '{spec}'"));
94                }
95                segs.push(Seg::Key(rest.to_string()));
96            }
97        }
98    }
99    Ok(segs)
100}
101
102/// Split a `PATH=VALUE` spec at the first `=` that is *outside* any `[...]`, so a
103/// predicate like `.a[name=x].b=1` splits into (`.a[name=x].b`, `1`).
104///
105/// # Examples
106///
107/// ```
108/// use coding_tools::patch::split_assign;
109///
110/// assert_eq!(
111///     split_assign(".servers[name=web].port=8443"),
112///     Some((".servers[name=web].port", "8443"))
113/// );
114/// assert_eq!(split_assign(".x=hi"), Some((".x", "hi")));
115/// assert_eq!(split_assign(".x"), None);
116/// ```
117pub fn split_assign(spec: &str) -> Option<(&str, &str)> {
118    let mut depth = 0i32;
119    for (i, c) in spec.char_indices() {
120        match c {
121            '[' => depth += 1,
122            ']' => depth = (depth - 1).max(0),
123            '=' if depth == 0 => return Some((&spec[..i], &spec[i + 1..])),
124            _ => {}
125        }
126    }
127    None
128}
129
130/// Normalise a `--set`/`--add` value: valid JSON is kept (compact); anything else
131/// is taken as a JSON string.
132///
133/// # Examples
134///
135/// ```
136/// use coding_tools::patch::normalize_value;
137///
138/// assert_eq!(normalize_value("8080"), "8080");           // a JSON number
139/// assert_eq!(normalize_value("true"), "true");           // a JSON bool
140/// assert_eq!(normalize_value("[1,2]"), "[1,2]");         // a JSON array
141/// assert_eq!(normalize_value("hello"), "\"hello\"");     // not JSON -> a string
142/// ```
143pub fn normalize_value(v: &str) -> String {
144    match serde_json::from_str::<serde_json::Value>(v) {
145        Ok(parsed) => parsed.to_string(),
146        Err(_) => serde_json::Value::String(v.to_string()).to_string(),
147    }
148}
149
150/// Where a `--move-*` relocates an array element within its list.
151#[derive(Debug, Clone, Copy)]
152pub enum MoveTo {
153    First,
154    Last,
155    Up,
156    Down,
157}
158
159/// A single patch operation, with the raw path text kept for messages.
160pub enum Op {
161    Set {
162        path: Vec<Seg>,
163        raw: String,
164        value: String,
165    },
166    Add {
167        path: Vec<Seg>,
168        raw: String,
169        value: String,
170    },
171    Delete {
172        path: Vec<Seg>,
173        raw: String,
174    },
175    Move {
176        path: Vec<Seg>,
177        raw: String,
178        to: MoveTo,
179    },
180}
181
182/// The key string of an object property.
183fn prop_key(name: &ObjectPropName) -> String {
184    match name {
185        ObjectPropName::String(s) => s.value.to_string(),
186        ObjectPropName::Word(w) => w.value.to_string(),
187    }
188}
189
190/// Whether a scalar value node equals the (unquoted) predicate `value` text.
191fn scalar_eq(node: &Value, value: &str) -> bool {
192    match node {
193        Value::StringLit(s) => s.value.as_ref() == value,
194        Value::NumberLit(n) => n.value == value,
195        Value::BooleanLit(b) => (if b.value { "true" } else { "false" }) == value,
196        Value::NullKeyword(_) => value == "null",
197        _ => false,
198    }
199}
200
201/// The index of the first array element that is an object with `key` == `value`.
202fn select_index(arr: &Array, key: &str, value: &str) -> Option<usize> {
203    arr.elements.iter().position(|e| match e {
204        Value::Object(o) => o
205            .properties
206            .iter()
207            .any(|p| prop_key(&p.name) == key && scalar_eq(&p.value, value)),
208        _ => false,
209    })
210}
211
212/// Resolve the final segment of a path to an array index within `parent`
213/// (for `Index` and `Select`; `Key` is not an array position).
214fn final_array_index(parent: &Value, last: &Seg, raw: &str) -> Result<usize, String> {
215    let arr = as_array(parent, raw)?;
216    match last {
217        Seg::Index(i) => Ok(*i),
218        Seg::Select { key, value } => select_index(arr, key, value)
219            .ok_or_else(|| format!("path '{raw}': no array element where {key}={value}")),
220        Seg::Key(_) => Err(format!(
221            "path '{raw}': expected an array element, got an object key"
222        )),
223    }
224}
225
226/// The leading whitespace of the line containing byte offset `pos`.
227fn line_indent(text: &str, pos: usize) -> String {
228    let line_start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
229    text[line_start..pos]
230        .chars()
231        .take_while(|c| *c == ' ' || *c == '\t')
232        .collect()
233}
234
235/// Replace `text[start..end]` with `repl`; report whether the text changed.
236fn splice(text: &str, start: usize, end: usize, repl: &str) -> (String, bool) {
237    let out = format!("{}{}{}", &text[..start], repl, &text[end..]);
238    let changed = out != text;
239    (out, changed)
240}
241
242/// Walk to the node at `segs` (all of them); `None` if any segment is absent.
243fn navigate<'a>(root: &'a Value<'a>, segs: &[Seg]) -> Option<&'a Value<'a>> {
244    let mut cur = root;
245    for seg in segs {
246        cur = match (seg, cur) {
247            (Seg::Key(k), Value::Object(o)) => {
248                &o.properties.iter().find(|p| prop_key(&p.name) == *k)?.value
249            }
250            (Seg::Index(i), Value::Array(a)) => a.elements.get(*i)?,
251            (Seg::Select { key, value }, Value::Array(a)) => {
252                a.elements.get(select_index(a, key, value)?)?
253            }
254            _ => return None,
255        };
256    }
257    Some(cur)
258}
259
260/// Append `"key": value` (or just `value` for arrays) into a container that
261/// already has elements, matching its inline/multiline style and indentation.
262fn append_into(
263    text: &str,
264    open: usize,
265    first_child: usize,
266    last_end: usize,
267    entry: &str,
268) -> (String, bool) {
269    let multiline = text[open + 1..first_child].contains('\n');
270    let insert = if multiline {
271        format!(",\n{}{entry}", line_indent(text, first_child))
272    } else {
273        format!(", {entry}")
274    };
275    splice(text, last_end, last_end, &insert)
276}
277
278/// Apply `--set` at `path`.
279fn set_in(
280    text: &str,
281    root: &Value,
282    path: &[Seg],
283    value: &str,
284    raw: &str,
285) -> Result<(String, bool), String> {
286    let (last, parents) = path.split_last().expect("path is non-empty");
287    let parent = navigate(root, parents)
288        .ok_or_else(|| format!("path '{raw}' not found (a parent segment is missing)"))?;
289    match last {
290        Seg::Key(k) => {
291            let obj = as_object(parent, raw)?;
292            if let Some(p) = obj.properties.iter().find(|p| prop_key(&p.name) == *k) {
293                let r = p.value.range();
294                Ok(splice(text, r.start, r.end, value))
295            } else {
296                Ok(add_key(text, obj, k, value))
297            }
298        }
299        Seg::Index(i) => {
300            let arr = as_array(parent, raw)?;
301            let len = arr.elements.len();
302            if *i < len {
303                let r = arr.elements[*i].range();
304                Ok(splice(text, r.start, r.end, value))
305            } else if *i == len {
306                Ok(append_elem(text, arr, value))
307            } else {
308                Err(format!(
309                    "path '{raw}': index {i} is out of range (length {len})"
310                ))
311            }
312        }
313        Seg::Select { key, value: want } => {
314            let arr = as_array(parent, raw)?;
315            let i = select_index(arr, key, want)
316                .ok_or_else(|| format!("path '{raw}': no array element where {key}={want}"))?;
317            let r = arr.elements[i].range();
318            Ok(splice(text, r.start, r.end, value))
319        }
320    }
321}
322
323/// Add a new `"key": value` property to an object.
324fn add_key(text: &str, obj: &Object, key: &str, value: &str) -> (String, bool) {
325    let entry = format!("{}: {value}", json!(key));
326    if obj.properties.is_empty() {
327        let pos = obj.range().start + 1;
328        return splice(text, pos, pos, &entry);
329    }
330    let first = obj.properties[0].range().start;
331    let last_end = obj.properties.last().unwrap().range().end;
332    append_into(text, obj.range().start, first, last_end, &entry)
333}
334
335/// Append a new element to an array.
336fn append_elem(text: &str, arr: &Array, value: &str) -> (String, bool) {
337    if arr.elements.is_empty() {
338        let pos = arr.range().start + 1;
339        return splice(text, pos, pos, value);
340    }
341    let first = arr.elements[0].range().start;
342    let last_end = arr.elements.last().unwrap().range().end;
343    append_into(text, arr.range().start, first, last_end, value)
344}
345
346/// Apply `--delete` at `path`. A path that does not resolve is a no-op.
347fn delete_in(text: &str, root: &Value, path: &[Seg], raw: &str) -> Result<(String, bool), String> {
348    let (last, parents) = path.split_last().expect("path is non-empty");
349    let parent = match navigate(root, parents) {
350        Some(p) => p,
351        None => return Ok((text.to_string(), false)),
352    };
353    match last {
354        Seg::Key(k) => {
355            let obj = as_object(parent, raw)?;
356            match obj.properties.iter().position(|p| prop_key(&p.name) == *k) {
357                Some(idx) => Ok(delete_member(
358                    text,
359                    obj.range().start,
360                    obj.range().end,
361                    &obj.properties
362                        .iter()
363                        .map(|p| (p.range().start, p.range().end))
364                        .collect::<Vec<_>>(),
365                    idx,
366                    "{}",
367                )),
368                None => Ok((text.to_string(), false)),
369            }
370        }
371        Seg::Index(i) => {
372            let arr = as_array(parent, raw)?;
373            if *i < arr.elements.len() {
374                Ok(delete_member(
375                    text,
376                    arr.range().start,
377                    arr.range().end,
378                    &arr.elements
379                        .iter()
380                        .map(|e| (e.range().start, e.range().end))
381                        .collect::<Vec<_>>(),
382                    *i,
383                    "[]",
384                ))
385            } else {
386                Ok((text.to_string(), false))
387            }
388        }
389        Seg::Select { key, value: want } => {
390            let arr = as_array(parent, raw)?;
391            match select_index(arr, key, want) {
392                Some(i) => Ok(delete_member(
393                    text,
394                    arr.range().start,
395                    arr.range().end,
396                    &arr.elements
397                        .iter()
398                        .map(|e| (e.range().start, e.range().end))
399                        .collect::<Vec<_>>(),
400                    i,
401                    "[]",
402                )),
403                None => Ok((text.to_string(), false)),
404            }
405        }
406    }
407}
408
409/// Remove member `idx` from a container, taking the adjacent comma with it so the
410/// surrounding members stay well-formed. `empty` is the literal for "now empty".
411fn delete_member(
412    text: &str,
413    open: usize,
414    close: usize,
415    members: &[(usize, usize)],
416    idx: usize,
417    empty: &str,
418) -> (String, bool) {
419    if members.len() == 1 {
420        return splice(text, open, close, empty);
421    }
422    let (start, end) = if idx + 1 < members.len() {
423        // Not last: take from this member's start up to the next member's start
424        // (sweeps the separating comma and whitespace).
425        (members[idx].0, members[idx + 1].0)
426    } else {
427        // Last: take from the previous member's end (sweeps the comma before it).
428        (members[idx - 1].1, members[idx].1)
429    };
430    splice(text, start, end, "")
431}
432
433fn as_object<'a>(node: &'a Value<'a>, raw: &str) -> Result<&'a Object<'a>, String> {
434    match node {
435        Value::Object(o) => Ok(o),
436        _ => Err(format!("path '{raw}': expected an object")),
437    }
438}
439
440fn as_array<'a>(node: &'a Value<'a>, raw: &str) -> Result<&'a Array<'a>, String> {
441    match node {
442        Value::Array(a) => Ok(a),
443        _ => Err(format!("path '{raw}': expected an array")),
444    }
445}
446
447/// Apply `--add` at `path`: append `value` to the array there.
448fn add_in(
449    text: &str,
450    root: &Value,
451    path: &[Seg],
452    value: &str,
453    raw: &str,
454) -> Result<(String, bool), String> {
455    let node = navigate(root, path).ok_or_else(|| format!("path '{raw}' not found"))?;
456    let arr = as_array(node, raw)?;
457    Ok(append_elem(text, arr, value))
458}
459
460/// Apply `--move-*` at `path` (which selects an array element): relocate it
461/// within its list, reordering element texts and keeping the separator style.
462fn move_in(
463    text: &str,
464    root: &Value,
465    path: &[Seg],
466    to: MoveTo,
467    raw: &str,
468) -> Result<(String, bool), String> {
469    let (last, parents) = path
470        .split_last()
471        .ok_or_else(|| format!("empty path '{raw}'"))?;
472    let parent = navigate(root, parents).ok_or_else(|| format!("path '{raw}' not found"))?;
473    let arr = as_array(parent, raw)?;
474    let i = final_array_index(parent, last, raw)?;
475    let len = arr.elements.len();
476    if i >= len {
477        return Err(format!(
478            "path '{raw}': index {i} is out of range (length {len})"
479        ));
480    }
481    if len < 2 {
482        return Ok((text.to_string(), false));
483    }
484    let j = match to {
485        MoveTo::First => 0,
486        MoveTo::Last => len - 1,
487        MoveTo::Up => i.saturating_sub(1),
488        MoveTo::Down => (i + 1).min(len - 1),
489    };
490    if i == j {
491        return Ok((text.to_string(), false));
492    }
493    let spans: Vec<(usize, usize)> = arr
494        .elements
495        .iter()
496        .map(|e| {
497            let r = e.range();
498            (r.start, r.end)
499        })
500        .collect();
501    let items: Vec<&str> = spans.iter().map(|&(s, e)| &text[s..e]).collect();
502    let mut order: Vec<usize> = (0..len).collect();
503    let moved = order.remove(i);
504    order.insert(j, moved);
505    // The glue between the first two elements is the separator style to reuse.
506    let sep = text[spans[0].1..spans[1].0].to_string();
507    let reordered: Vec<&str> = order.iter().map(|&k| items[k]).collect();
508    Ok(splice(
509        text,
510        spans[0].0,
511        spans[len - 1].1,
512        &reordered.join(&sep),
513    ))
514}
515
516/// Apply one [`Op`] to a single JSON(C) document, returning the new text and
517/// whether it changed.
518pub fn apply_op(text: &str, op: &Op) -> Result<(String, bool), String> {
519    let parsed = parse_to_ast(text, &CollectOptions::default(), &ParseOptions::default())
520        .map_err(|e| format!("parse error: {e}"))?;
521    let root = parsed.value.as_ref().ok_or("document is empty")?;
522    match op {
523        Op::Set { path, value, raw } => set_in(text, root, path, value, raw),
524        Op::Add { path, value, raw } => add_in(text, root, path, value, raw),
525        Op::Delete { path, raw } => delete_in(text, root, path, raw),
526        Op::Move { path, to, raw } => move_in(text, root, path, *to, raw),
527    }
528}
529
530/// Apply every op to a whole-document text (JSON/JSONC), in order. Returns the
531/// new text and the number of ops that changed it.
532///
533/// Edits are byte-range splices, so untouched bytes — including comments — are
534/// preserved exactly.
535///
536/// # Examples
537///
538/// ```
539/// use coding_tools::patch::{apply_doc, parse_path, normalize_value, Op};
540///
541/// let set = Op::Set {
542///     path: parse_path(".a").unwrap(),
543///     raw: ".a".into(),
544///     value: normalize_value("42"),
545/// };
546/// let (out, changes) =
547///     apply_doc("{\n  \"a\": 1, // keep me\n  \"b\": 2\n}\n", &[set]).unwrap();
548/// assert_eq!(changes, 1);
549/// // Only the value changed; the comment and layout are preserved.
550/// assert_eq!(out, "{\n  \"a\": 42, // keep me\n  \"b\": 2\n}\n");
551/// ```
552pub fn apply_doc(text: &str, ops: &[Op]) -> Result<(String, usize), String> {
553    let mut cur = text.to_string();
554    let mut changes = 0usize;
555    for op in ops {
556        let (next, changed) = apply_op(&cur, op)?;
557        if changed {
558            changes += 1;
559        }
560        cur = next;
561    }
562    Ok((cur, changes))
563}
564
565/// Apply every op to each non-blank line of a JSONL document.
566pub fn apply_jsonl(text: &str, ops: &[Op]) -> Result<(String, usize), String> {
567    let mut out = String::with_capacity(text.len());
568    let mut changes = 0usize;
569    for segment in text.split_inclusive('\n') {
570        let (body, nl) = match segment.strip_suffix('\n') {
571            Some(b) => (b, "\n"),
572            None => (segment, ""),
573        };
574        if body.trim().is_empty() {
575            out.push_str(segment);
576            continue;
577        }
578        let (patched, n) = apply_doc(body, ops)?;
579        changes += n;
580        out.push_str(&patched);
581        out.push_str(nl);
582    }
583    Ok((out, changes))
584}
585
586/// A path as plain mapping keys; errors if any segment is an array index or
587/// predicate (the YAML backend addresses mapping key paths in this version).
588fn key_path(path: &[Seg], raw: &str) -> Result<Vec<String>, String> {
589    path.iter()
590        .map(|s| match s {
591            Seg::Key(k) => Ok(k.clone()),
592            Seg::Index(_) => Err(format!(
593                "array-index paths are not yet supported for YAML ('{raw}')"
594            )),
595            Seg::Select { .. } => Err(format!(
596                "predicate paths are not yet supported for YAML ('{raw}')"
597            )),
598        })
599        .collect()
600}
601
602/// The leading run of blank/`#`-comment lines. `yaml-edit` 0.2 drops the
603/// document-leading comment block on round-trip, so we capture and re-attach it.
604fn leading_trivia(text: &str) -> String {
605    let mut end = 0;
606    for line in text.split_inclusive('\n') {
607        let trimmed = line.trim_start();
608        if trimmed.is_empty() || trimmed.starts_with('#') {
609            end += line.len();
610        } else {
611            break;
612        }
613    }
614    text[..end].to_string()
615}
616
617/// Set a value at a dotted key path with its correct YAML type (so `8080` is a
618/// number, `true` a bool, `"x"` a string), via `yaml-edit`'s typed `AsYaml`.
619fn yaml_set(doc: &yaml_edit::Document, dotted: &str, value: &str, raw: &str) -> Result<(), String> {
620    // `value` is already normalized JSON, so it always parses.
621    let parsed: serde_json::Value = serde_json::from_str(value)
622        .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
623    match parsed {
624        serde_json::Value::Bool(b) => doc.set_path(dotted, b),
625        serde_json::Value::String(s) => doc.set_path(dotted, s.as_str()),
626        serde_json::Value::Number(n) => {
627            if let Some(i) = n.as_i64() {
628                doc.set_path(dotted, i);
629            } else if let Some(u) = n.as_u64() {
630                doc.set_path(dotted, u);
631            } else {
632                doc.set_path(dotted, n.as_f64().unwrap());
633            }
634        }
635        serde_json::Value::Null => {
636            return Err(format!(
637                "path '{raw}': null values are not yet supported for YAML"
638            ));
639        }
640        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
641            return Err(format!(
642                "path '{raw}': array/object values are not yet supported for YAML"
643            ));
644        }
645    }
646    Ok(())
647}
648
649/// Apply every op to a YAML document via the pure-Rust, comment-preserving
650/// `yaml-edit` backend. Returns the new text and the number of ops that changed
651/// it. Supports `--set` (replace an existing key) and `--delete`; `--add` and
652/// `--move-*` (and array-index/predicate paths) error rather than risk malformed
653/// output.
654///
655/// # Examples
656///
657/// ```
658/// use coding_tools::patch::{apply_yaml, parse_path, normalize_value, Op};
659///
660/// let set = Op::Set {
661///     path: parse_path(".server.port").unwrap(),
662///     raw: ".server.port".into(),
663///     value: normalize_value("9090"),
664/// };
665/// let yaml = "# cfg\nserver:\n  host: localhost   # inline\n  port: 8080\n";
666/// let (out, changes) = apply_yaml(yaml, &[set]).unwrap();
667/// assert_eq!(changes, 1);
668/// assert!(out.contains("# cfg"));       // leading comment preserved
669/// assert!(out.contains("port: 9090"));  // a number, not a quoted string
670/// assert!(out.contains("# inline"));    // inline comment preserved
671/// ```
672pub fn apply_yaml(text: &str, ops: &[Op]) -> Result<(String, usize), String> {
673    use std::str::FromStr;
674    let doc = yaml_edit::Document::from_str(text).map_err(|e| format!("yaml parse error: {e}"))?;
675    let mut changes = 0usize;
676    for op in ops {
677        let before = doc.to_string();
678        match op {
679            Op::Set { path, value, raw } => {
680                let keys = key_path(path, raw)?;
681                let dotted = keys.join(".");
682                // Replace-only for now: yaml-edit's auto-create mis-indents new
683                // keys, so creating keys is reserved for the insert verbs.
684                if doc.get_path(&dotted).is_none() {
685                    return Err(format!(
686                        "path '{raw}': key does not exist (adding new YAML keys is handled by the insert verbs)"
687                    ));
688                }
689                yaml_set(&doc, &dotted, value, raw)?;
690            }
691            Op::Delete { path, raw } => {
692                let keys = key_path(path, raw)?;
693                let (last, parents) = keys
694                    .split_last()
695                    .ok_or_else(|| format!("empty path '{raw}'"))?;
696                let mut map = doc
697                    .as_mapping()
698                    .ok_or_else(|| format!("path '{raw}': document root is not a mapping"))?;
699                for k in parents {
700                    map = map
701                        .get_mapping(k)
702                        .ok_or_else(|| format!("path '{raw}': '{k}' is not a mapping"))?;
703                }
704                map.remove(last);
705            }
706            // yaml-edit 0.2 mis-indents structural inserts (producing invalid
707            // YAML), so --add and --move-* are JSON-family only for now.
708            Op::Add { raw, .. } => {
709                return Err(format!("--add is not yet supported for YAML ('{raw}')"));
710            }
711            Op::Move { raw, .. } => {
712                return Err(format!("--move-* is not yet supported for YAML ('{raw}')"));
713            }
714        }
715        if doc.to_string() != before {
716            changes += 1;
717        }
718    }
719    // Re-attach the document-leading comment block if yaml-edit dropped it.
720    let leading = leading_trivia(text);
721    let mut out = doc.to_string();
722    if !leading.is_empty() && !out.starts_with(&leading) {
723        out = format!("{leading}{out}");
724    }
725    Ok((out, changes))
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    fn set(text: &str, path: &str, value: &str) -> (String, bool) {
733        apply_op(
734            text,
735            &Op::Set {
736                path: parse_path(path).unwrap(),
737                raw: path.to_string(),
738                value: normalize_value(value),
739            },
740        )
741        .unwrap()
742    }
743
744    fn delete(text: &str, path: &str) -> (String, bool) {
745        apply_op(
746            text,
747            &Op::Delete {
748                path: parse_path(path).unwrap(),
749                raw: path.to_string(),
750            },
751        )
752        .unwrap()
753    }
754
755    fn add(text: &str, path: &str, value: &str) -> (String, bool) {
756        apply_op(
757            text,
758            &Op::Add {
759                path: parse_path(path).unwrap(),
760                raw: path.to_string(),
761                value: normalize_value(value),
762            },
763        )
764        .unwrap()
765    }
766
767    fn move_el(text: &str, path: &str, to: MoveTo) -> (String, bool) {
768        apply_op(
769            text,
770            &Op::Move {
771                path: parse_path(path).unwrap(),
772                raw: path.to_string(),
773                to,
774            },
775        )
776        .unwrap()
777    }
778
779    #[test]
780    fn path_parsing() {
781        assert_eq!(
782            parse_path(".a.b").unwrap(),
783            vec![Seg::Key("a".into()), Seg::Key("b".into())]
784        );
785        assert_eq!(parse_path("[0]").unwrap(), vec![Seg::Index(0)]);
786        assert_eq!(
787            parse_path("a[name=web].port").unwrap(),
788            vec![
789                Seg::Key("a".into()),
790                Seg::Select {
791                    key: "name".into(),
792                    value: "web".into()
793                },
794                Seg::Key("port".into()),
795            ]
796        );
797        assert!(parse_path("").is_err());
798        assert!(parse_path("a[x]").is_err());
799    }
800
801    #[test]
802    fn assign_splits_outside_brackets() {
803        assert_eq!(split_assign(".a[n=b].c=1"), Some((".a[n=b].c", "1")));
804        assert_eq!(split_assign(".x=hi"), Some((".x", "hi")));
805        assert_eq!(split_assign(".x"), None);
806    }
807
808    #[test]
809    fn value_normalisation() {
810        assert_eq!(normalize_value("42"), "42");
811        assert_eq!(normalize_value("name"), "\"name\"");
812    }
813
814    #[test]
815    fn set_replaces_value_preserving_comments_and_layout() {
816        let t = "{\n  \"a\": 1, // keep me\n  \"b\": 2\n}\n";
817        let (out, changed) = set(t, ".a", "42");
818        assert!(changed);
819        assert_eq!(out, "{\n  \"a\": 42, // keep me\n  \"b\": 2\n}\n");
820    }
821
822    #[test]
823    fn set_adds_missing_key_multiline_with_indent() {
824        let (out, _) = set("{\n  \"a\": 1\n}\n", ".b", "true");
825        assert_eq!(out, "{\n  \"a\": 1,\n  \"b\": true\n}\n");
826    }
827
828    #[test]
829    fn set_string_fallback_quotes_value() {
830        let (out, _) = set("{\"a\":1}", ".a", "hello");
831        assert_eq!(out, "{\"a\":\"hello\"}");
832    }
833
834    #[test]
835    fn nested_set_and_array_index() {
836        let t = "{\n  \"x\": { \"y\": [10, 20, 30] }\n}\n";
837        let (out, changed) = set(t, ".x.y[1]", "99");
838        assert!(changed);
839        assert_eq!(out, "{\n  \"x\": { \"y\": [10, 99, 30] }\n}\n");
840    }
841
842    #[test]
843    fn append_array_element_via_index() {
844        let (out, _) = set("[1, 2]", "[2]", "3"); // index == len appends
845        assert_eq!(out, "[1, 2, 3]");
846    }
847
848    #[test]
849    fn predicate_selects_object_in_array() {
850        let t = "{ \"xs\": [ {\"n\":\"a\",\"v\":1}, {\"n\":\"b\",\"v\":2} ] }";
851        let (out, changed) = set(t, ".xs[n=b].v", "9");
852        assert!(changed);
853        assert_eq!(
854            out,
855            "{ \"xs\": [ {\"n\":\"a\",\"v\":1}, {\"n\":\"b\",\"v\":9} ] }"
856        );
857        let (del, _) = delete(t, ".xs[n=a]");
858        assert_eq!(del, "{ \"xs\": [ {\"n\":\"b\",\"v\":2} ] }");
859    }
860
861    #[test]
862    fn delete_takes_its_comma() {
863        assert_eq!(
864            delete("{\"a\":1,\"b\":2,\"c\":3}", ".b").0,
865            "{\"a\":1,\"c\":3}"
866        );
867        assert_eq!(delete("{\"a\":1,\"b\":2}", ".b").0, "{\"a\":1}");
868        assert_eq!(delete("{ \"a\": 1 }", ".a").0, "{}");
869        assert!(!delete("{\"a\":1}", ".z").1); // missing key is a no-op
870    }
871
872    #[test]
873    fn add_appends_without_index() {
874        let (out, changed) = add("{\"xs\": [1, 2]}", ".xs", "3");
875        assert!(changed);
876        assert_eq!(out, "{\"xs\": [1, 2, 3]}");
877    }
878
879    #[test]
880    fn move_reorders_preserving_separator() {
881        assert_eq!(
882            move_el("{\"xs\": [1, 2, 3]}", ".xs[0]", MoveTo::Last).0,
883            "{\"xs\": [2, 3, 1]}"
884        );
885        assert_eq!(
886            move_el("{\"xs\": [1, 2, 3]}", ".xs[2]", MoveTo::Up).0,
887            "{\"xs\": [1, 3, 2]}"
888        );
889        assert!(!move_el("{\"xs\": [1]}", ".xs[0]", MoveTo::Last).1); // single-element no-op
890    }
891
892    fn yaml_op_set(path: &str, value: &str) -> Op {
893        Op::Set {
894            path: parse_path(path).unwrap(),
895            raw: path.to_string(),
896            value: normalize_value(value),
897        }
898    }
899
900    #[test]
901    fn yaml_set_replace_and_delete_preserve_comments() {
902        let yaml = "# top\nserver:\n  host: localhost   # inline\n  port: 8080\n  debug: true\n";
903        let del = Op::Delete {
904            path: parse_path(".server.debug").unwrap(),
905            raw: ".server.debug".to_string(),
906        };
907        let (out, changes) = apply_yaml(yaml, &[yaml_op_set(".server.port", "9090"), del]).unwrap();
908        assert_eq!(changes, 2);
909        assert!(out.contains("# top"), "leading comment kept: {out:?}");
910        assert!(out.contains("port: 9090"), "number not quoted: {out:?}");
911        assert!(out.contains("# inline"), "inline comment kept: {out:?}");
912        assert!(!out.contains("debug:"), "debug deleted: {out:?}");
913    }
914
915    #[test]
916    fn yaml_add_and_predicate_paths_error() {
917        let add = Op::Add {
918            path: parse_path(".server.tags").unwrap(),
919            raw: ".server.tags".to_string(),
920            value: normalize_value("x"),
921        };
922        let e = apply_yaml("server:\n  tags:\n    - a\n", &[add]).unwrap_err();
923        assert!(e.contains("not yet supported for YAML"), "{e}");
924
925        let pred = apply_yaml("xs: []\n", &[yaml_op_set(".xs[n=a].v", "1")]).unwrap_err();
926        assert!(
927            pred.contains("predicate paths are not yet supported"),
928            "{pred}"
929        );
930    }
931}